aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-23 16:39:43 +0200
committerDanilo M. <danix@danix.xyz>2026-10-23 16:39:43 +0200
commit1a8ec7844d757e2f01b71ff96405f49731711169 (patch)
tree8bfac1f414ad33cdca619d9de3070166a06e05c3 /docs
parent9a5af1bf2b1fd76b7d61925cfa162b9cd1a42f94 (diff)
downloaddesktop-assistant-1a8ec7844d757e2f01b71ff96405f49731711169.tar.gz
desktop-assistant-1a8ec7844d757e2f01b71ff96405f49731711169.zip
docs: plan cloud models, daemon side
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-10-23-cloud-models.md806
1 files changed, 806 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-23-cloud-models.md b/docs/superpowers/plans/2026-10-23-cloud-models.md
new file mode 100644
index 0000000..4c86cee
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-23-cloud-models.md
@@ -0,0 +1,806 @@
+# Cloud models (daemon) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let a chat use a cloud model from an OpenAI-compatible provider, picked per conversation, with only opted-in tools offered to it.
+
+**Architecture:** All in `assistant.py`. `[[cloud]]` tables in config name providers; their keys come from `key_cmd` at startup into `PROVIDERS`. A model id `"<provider>:<model>"` routes a request to that provider (`route()`), everything else goes to llama-server as today. The conversation row stores the chosen model; `ask` may carry one; a `models` command lists local and cloud models; `--cloud-models <name>` fetches a provider's list for the settings page.
+
+**Tech Stack:** Python 3.12 stdlib only (`http.client`, `urllib`, `sqlite3`, `shlex`, `subprocess`). Self-check: `python3 assistant.py --test` (prints `ok`).
+
+Spec: `docs/superpowers/specs/2026-10-23-cloud-models-design.md`. The quickshell side (settings section, header dropdown) is a separate plan in that repo, written after this one lands.
+
+Decision not in the spec: `write` (the canvas's To .md) has no conversation, so it stays on the local loaded model.
+
+---
+
+## File map
+
+- Modify `assistant.py`: config (`DEFAULTS`, `merge`, new `check_cloud`), `load_tools` (cloud flag), providers (`provider_key`, `load_providers`, `route`, `cloud_models`, `send_models`), storage (`db_open`, `save_turn`), request path (`model_inputs`, `ask`, `stream`, `reply`, `answer`, `retitle`), socket (`cur`, `hello`, `start_new`, `handle`, `command`), `main`, `test`, `__main__`.
+- Modify `examples/config.toml`, `examples/tools/{calc,now,search,weather}.toml`, `README.md`, `AGENTS.md`, the spec's status line.
+
+Run every test with: `python3 assistant.py --test 2>&1 | tail -3` from the repo root. It logs a lot (expected tracebacks included); the last line is `ok` on success, an `AssertionError` traceback otherwise.
+
+---
+
+### Task 1: `[[cloud]]` config and the tool `cloud` flag
+
+**Files:**
+- Modify: `assistant.py` (`DEFAULTS` ~line 50, `merge` ~66, `load_tools` ~88, `test()` start ~1097)
+
+- [ ] **Step 1: Write the failing tests**
+
+In `test()`, right after the existing `for bad in ({"card": "X"}, ...)` loop, add:
+
+```python
+ good = {"name": "or", "url": "https://llm.example.org/v1", "key_cmd": "echo k",
+ "models": ["a/b"]}
+ assert merge(DEFAULTS, {"cloud": [good]})["cloud"] == [good]
+ assert merge(DEFAULTS, {"cloud": [{**good, "models": []}]}) # before the first fetch
+ assert DEFAULTS["cloud"] == []
+ for bad in ({"cloud": good}, {"cloud": [{**good, "extra": 1}]},
+ {"cloud": [{**good, "name": "a:b"}]}, {"cloud": [good, good]},
+ {"cloud": [{**good, "models": "a/b"}]}, {"cloud": [{**good, "url": ""}]}):
+ try:
+ merge(DEFAULTS, bad)
+ assert False, bad
+ except SystemExit as e:
+ assert "config: cloud" in str(e), (bad, e)
+ with tempfile.TemporaryDirectory() as d:
+ with open(os.path.join(d, "x.toml"), "w") as f:
+ f.write('description = "d"\ncommand = ["date"]\ncloud = "yes"\n')
+ try:
+ load_tools(d)
+ assert False
+ except SystemExit as e:
+ assert "cloud must be true or false" in str(e)
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+Run the self-check. Expected: `SystemExit: config: unknown key cloud` (the good case).
+
+- [ ] **Step 3: Implement**
+
+`DEFAULTS`, after `"thinking": True, ...`:
+
+```python
+ "cloud": [], # [[cloud]] providers, see check_cloud
+```
+
+New function above `merge`:
+
+```python
+def check_cloud(v):
+ # [[cloud]] tables. A provider's name prefixes its models' ids
+ # ("name:model"), so it is unique and holds no colon. models may be
+ # empty: the settings page saves a provider before fetching its list.
+ if not isinstance(v, list) or not all(isinstance(p, dict) for p in v):
+ raise SystemExit("config: cloud must be [[cloud]] tables")
+ seen = set()
+ for p in v:
+ name = p.get("name")
+ if set(p) != {"name", "url", "key_cmd", "models"}:
+ raise SystemExit(f"config: cloud {name!r} needs exactly name, url, key_cmd, models")
+ if (not all(isinstance(p[k], str) and p[k] for k in ("name", "url", "key_cmd"))
+ or not isinstance(p["models"], list)
+ or not all(isinstance(m, str) for m in p["models"])):
+ raise SystemExit(f"config: cloud {name!r}: name, url and key_cmd are strings, "
+ "models a list of strings")
+ if ":" in name or name in seen:
+ raise SystemExit(f"config: cloud name {name!r} is repeated or has a colon")
+ seen.add(name)
+ return v
+```
+
+In `merge`, just before `out[k] = v`:
+
+```python
+ if k == "cloud":
+ v = check_cloud(v)
+```
+
+In `load_tools`, after the `needs description and command` check:
+
+```python
+ if not isinstance(t.get("cloud", False), bool):
+ raise SystemExit(f"{path}: cloud must be true or false")
+```
+
+- [ ] **Step 4: Run, expect `ok`**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add assistant.py
+git commit -m "config: [[cloud]] providers and the tool cloud flag
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: Providers, keys, routing, `--cloud-models`
+
+**Files:**
+- Modify: `assistant.py` (imports, new functions right before `model_inputs` ~line 696, `main`, `__main__`, module docstring, `test()` fake server)
+
+- [ ] **Step 1: Write the failing tests**
+
+In `test()`'s `Fake` handler, first line of `do_GET` and first line of `do_POST`:
+
+```python
+ script["auth"] = self.headers.get("Authorization")
+```
+
+Right after `CFG["llm"] = f"http://127.0.0.1:{srv.server_address[1]}/v1"`, add:
+
+```python
+ # Cloud providers: the key from key_cmd, models fetched with it. The
+ # fake answers /cloud/v1 like /v1, so the path tells them apart.
+ base = f"http://127.0.0.1:{srv.server_address[1]}"
+ CFG["cloud"] = [{"name": "fake", "url": base + "/cloud/v1/", "key_cmd": "echo sk-test",
+ "models": ["vendor/x:free"]},
+ {"name": "broken", "url": base + "/v1", "key_cmd": "false", "models": ["y"]}]
+ PROVIDERS.update(load_providers(CFG["cloud"]))
+ assert set(PROVIDERS) == {"fake"} and PROVIDERS["fake"]["key"] == "sk-test"
+ assert route("fake:vendor/x:free") == (base + "/cloud/v1", "sk-test", "vendor/x:free", "fake")
+ assert route("Gemma:q4") == (CFG["llm"], None, "Gemma:q4", None) # no such provider
+ try:
+ route("broken:y")
+ assert False
+ except Refused as e:
+ assert "no key" in str(e)
+ assert cloud_models("fake") == ["m"] and script["auth"] == "Bearer sk-test"
+ for name, why in (("broken", "key_cmd failed"), ("nope", "no cloud provider")):
+ try:
+ cloud_models(name)
+ assert False, name
+ except SystemExit as e:
+ assert why in str(e), (name, e)
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+Expected: `NameError: name 'PROVIDERS' is not defined`.
+
+- [ ] **Step 3: Implement**
+
+Imports, alphabetical with the rest: `import shlex` after `import re`; `import urllib.error` before `import urllib.parse`.
+
+New block right before `def model_inputs`:
+
+```python
+# Cloud providers, named in [[cloud]]. A key is key_cmd's stdout, read once
+# at startup; a provider whose key_cmd fails is left out, so its models are
+# not offered and a chat still on one is refused.
+PROVIDERS = {} # name -> {"url", "key", "models"}
+
+
+def provider_key(p):
+ # Only the exit status is reported: a secret tool's stderr is not worth
+ # the risk of it holding the secret.
+ try:
+ r = subprocess.run(shlex.split(p["key_cmd"]), capture_output=True, text=True,
+ stdin=subprocess.DEVNULL, timeout=10)
+ except (OSError, ValueError, subprocess.TimeoutExpired) as e:
+ raise RuntimeError(f"cloud {p['name']}: key_cmd failed ({type(e).__name__})") from None
+ if r.returncode or not r.stdout.strip():
+ raise RuntimeError(f"cloud {p['name']}: key_cmd failed (exit {r.returncode})")
+ return r.stdout.strip()
+
+
+def load_providers(cloud):
+ out = {}
+ for p in cloud:
+ try:
+ out[p["name"]] = {"url": p["url"].rstrip("/"), "key": provider_key(p),
+ "models": p["models"]}
+ except RuntimeError as e:
+ log(e)
+ return out
+
+
+def route(model):
+ # (base url, key, the id the server knows, provider or None). A prefix
+ # is a provider only when one by that name is configured, so a local id
+ # with a colon stays local.
+ name, sep, rest = (model or "").partition(":")
+ if sep and name in PROVIDERS:
+ p = PROVIDERS[name]
+ return p["url"], p["key"], rest, name
+ if sep and any(p["name"] == name for p in CFG["cloud"]):
+ raise Refused(f"cloud {name}: no key (key_cmd failed at startup)")
+ return CFG["llm"], None, model, None
+
+
+def cloud_models(name):
+ # For the settings page (--cloud-models): the provider's model ids,
+ # fetched here so the key never reaches it.
+ p = next((p for p in CFG["cloud"] if p["name"] == name), None)
+ if p is None:
+ raise SystemExit(f"no cloud provider named {name}")
+ try:
+ key = provider_key(p)
+ except RuntimeError as e:
+ raise SystemExit(str(e)) from None
+ req = urllib.request.Request(p["url"].rstrip("/") + "/models",
+ headers={"Authorization": f"Bearer {key}"})
+ try:
+ with urllib.request.urlopen(req, timeout=20) as r:
+ return sorted(m["id"] for m in json.load(r)["data"])
+ except urllib.error.HTTPError as e:
+ raise SystemExit(f"cloud {name} {e.code}: "
+ f"{e.read().decode(errors='replace')[:300]}") from None
+ except (OSError, KeyError, TypeError, ValueError) as e:
+ raise SystemExit(f"cloud {name}: {e}") from None
+```
+
+`main()`, right after `DB = db_open(DB_PATH)`:
+
+```python
+ PROVIDERS.update(load_providers(CFG["cloud"]))
+```
+
+`__main__`, before the final `else`:
+
+```python
+ elif len(sys.argv) == 3 and sys.argv[1] == "--cloud-models":
+ print(json.dumps(cloud_models(sys.argv[2])))
+```
+
+Module docstring, the Run paragraph becomes:
+
+```
+Run: python3 assistant.py (--test runs the self-check, --defaults
+prints the built-in config as JSON, for a settings UI, --cloud-models NAME
+prints that [[cloud]] provider's model ids as JSON)
+```
+
+- [ ] **Step 4: Run, expect `ok`**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add assistant.py
+git commit -m "cloud: provider keys from key_cmd, routing by model prefix, --cloud-models
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: The conversation's model in the database
+
+**Files:**
+- Modify: `assistant.py` (`db_open` ~145, `save_turn` ~158, `test()` storage section)
+
+- [ ] **Step 1: Write the failing tests**
+
+In `test()`, after `assert load_conv(db, 999) is None`:
+
+```python
+ m = save_turn(db, None, "q", "r", [], [], model="fake:x")
+ pick = lambda: db.execute("SELECT model FROM conversations WHERE id = ?", (m,)).fetchone()[0]
+ assert pick() == "fake:x"
+ save_turn(db, m, "q", "r", [], [], model="local")
+ assert pick() == "local"
+ save_turn(db, m, "q", "r", [], [])
+ assert pick() is None # an ask with no choice: the loaded one
+ delete_conv(db, m)
+```
+
+In the old-database check (`# An old database gains the files column.`), after its assert:
+
+```python
+ assert "model" in {r[1] for r in db_open(os.path.join(d, "h.db"))
+ .execute("PRAGMA table_info(conversations)")}
+```
+
+The last assert relies on the default: the chat's stored model is what its turns were asked with. Task 4 always passes the chat's current choice, so a NULL write only happens for a chat that never had one.
+
+- [ ] **Step 2: Run, expect failure**
+
+Expected: `TypeError: save_turn() got an unexpected keyword argument 'model'`.
+
+- [ ] **Step 3: Implement**
+
+`db_open`, after the `files` column migration:
+
+```python
+ # Added with cloud models: the model a conversation is asked with, NULL
+ # for whatever the router has loaded.
+ if "model" not in {r[1] for r in conn.execute("PRAGMA table_info(conversations)")}:
+ conn.execute("ALTER TABLE conversations ADD COLUMN model TEXT")
+```
+
+`save_turn`: signature `def save_turn(conn, conv, user, reply, tools, messages, files=(), model=None):`, and the two conversation statements become:
+
+```python
+ if conv is None:
+ conv = conn.execute("INSERT INTO conversations(title, created, updated, model) "
+ "VALUES (?, ?, ?, ?)", (title, now, now, model)).lastrowid
+ else:
+ conn.execute("UPDATE conversations SET updated = ?, model = ? WHERE id = ?",
+ (now, model, conv))
+```
+
+- [ ] **Step 4: Run, expect `ok`**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add assistant.py
+git commit -m "history: keep the model a conversation is asked with
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: Requests go where the chat's model lives
+
+**Files:**
+- Modify: `assistant.py` (`TOOL_SCHEMA` line 111, `cur` ~416, `hello`, `start_new`, `handle`, `command`, `model_inputs`, `ask`, `stream`, `reply`, `answer`, `retitle`, `test()`)
+
+- [ ] **Step 1: Write the failing tests**
+
+At the top of `test()`, next to the other imports:
+
+```python
+ import contextlib
+ import io
+```
+
+`global DB, TOOLS, TOOL_SCHEMA` becomes `global DB, TOOLS`, and delete the line `TOOL_SCHEMA = schema(TOOLS)`.
+
+In `Fake.do_POST`, right after the title branch's `return` (before `script["body"] = body`):
+
+```python
+ script.get("paths", []).append(self.path)
+```
+
+First hello check: after `assert h["pending"] is None and "ctx_max" in h` add:
+
+```python
+ assert h["chat_model"] is None
+```
+
+The two places comparing a fresh conversation event change to carry the model:
+
+```python
+ assert until("conversation")[-1] == {"type": "conversation", "id": None, "turns": [],
+ "model": None}
+```
+
+```python
+ assert {"type": "conversation", "id": None, "turns": [], "model": None} in seen
+```
+
+Right before `# llama-server down: an error, not a dead thread.`:
+
+```python
+ # Cloud: the chat's model picks the provider, and only cloud tools
+ # go along. A call to another one, copied from history, gets no
+ # output. The key reaches the provider and nothing else.
+ saved = TOOLS
+ TOOLS = {"now": {"description": "d", "command": ["sh", "-c", "echo noon"], "cloud": True},
+ "mail": {"description": "m", "command": ["sh", "-c", "echo secret subject"]}}
+ logged = io.StringIO()
+ with contextlib.redirect_stdout(logged):
+ send(type="new")
+ until("conversation")
+ script["bodies"], script["paths"] = [], []
+ script["replies"] = [
+ [{"reasoning": "hm"},
+ {"tool_calls": [{"index": 0, "id": "k1", "function": {"name": "mail",
+ "arguments": "{}"}}]}],
+ [{"content": "No mail tool."}]]
+ send(type="ask", text="any mail?", model="fake:vendor/x:free")
+ seen = until("done")
+ first, second = script["bodies"]
+ assert script["paths"] == ["/cloud/v1/chat/completions"] * 2
+ assert script["auth"] == "Bearer sk-test"
+ assert first["model"] == "vendor/x:free" and "chat_template_kwargs" not in first
+ assert [t["function"]["name"] for t in first["tools"]] == ["now"]
+ assert second["messages"][-1] == {"role": "tool", "tool_call_id": "k1",
+ "content": "error: no tool named mail"}
+ assert ("think", "hm") in [(e["type"], e.get("delta")) for e in seen]
+ meters = [e for e in seen if e["type"] == "ctx"]
+ assert meters[-1]["model"] == "fake:vendor/x:free" and meters[-1]["max"] == 0
+ assert "sk-test" not in json.dumps(seen)
+ conv = cur["id"]
+ assert DB.execute("SELECT model FROM conversations WHERE id = ?",
+ (conv,)).fetchone()[0] == "fake:vendor/x:free"
+ # The next ask with no model stays on the chat's.
+ script["replies"] = [[{"content": "Still cloud."}]]
+ send(type="ask", text="again")
+ until("done")
+ assert script["paths"][-1] == "/cloud/v1/chat/completions"
+ # Reopened, the chat reports its model.
+ send(type="new")
+ assert until("conversation")[-1]["model"] is None
+ send(type="open", id=conv)
+ assert until("conversation")[-1]["model"] == "fake:vendor/x:free"
+ # A local pick goes to the router with every tool and the
+ # thinking switch.
+ script["replies"] = [[{"content": "Local."}]]
+ send(type="ask", text="local now", model="m")
+ until("done")
+ assert script["paths"][-1] == "/v1/chat/completions"
+ assert script["body"]["model"] == "m" and "chat_template_kwargs" in script["body"]
+ assert [t["function"]["name"] for t in script["body"]["tools"]] == ["now", "mail"]
+ # The dropdown's list: the router's models, then the cloud ones
+ # whose key loaded.
+ send(type="models")
+ items = until("models")[-1]["items"]
+ assert {"id": "m", "status": "loaded", "cloud": False} in items
+ assert {"id": "fake:vendor/x:free", "provider": "fake", "cloud": True} in items
+ assert not [i for i in items if i["id"].startswith("broken:")]
+ send(type="ask", text="x", model="")
+ assert until("error")[-1]["message"] == "bad model"
+ send(type="ask", text="x", model="broken:y")
+ e = until("error")[-1]
+ assert "no key" in e["message"] and e["turn"] is True
+ send(type="new")
+ until("conversation")
+ TOOLS = saved
+ assert "sk-test" not in logged.getvalue(), logged.getvalue()
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+Expected: `KeyError: 'chat_model'` at the first hello.
+
+- [ ] **Step 3: Implement the socket side**
+
+Delete `TOOL_SCHEMA = schema(TOOLS) or None` (line 111): `answer` builds the schema per chat now.
+
+`cur`:
+
+```python
+cur = {"id": None, "history": [], "turns": [], "pending": None, "model": None}
+```
+
+`hello()` gains the chat's choice (its `model` stays the one answering, from `ctx`):
+
+```python
+ return {"type": "hello", **now_state, "model": ctx["model"], "ctx_used": ctx["used"],
+ "ctx_max": ctx["max"], "id": cur["id"], "turns": cur["turns"],
+ "pending": cur["pending"], "chat_model": cur["model"]}
+```
+
+`start_new()`:
+
+```python
+ cur.update(id=None, history=[], turns=[], model=None)
+```
+
+`handle()`, in the `bad = (...)` chain, after the `"bad audio"` line:
+
+```python
+ else "bad model" if kind == "ask" and "model" in cmd and not (
+ isinstance(cmd["model"], str) and cmd["model"])
+```
+
+`command()`:
+
+```python
+ elif kind == "ask":
+ text, files, audio = cmd["text"].strip(), cmd.get("files", []), cmd.get("audio")
+ if text or files or audio:
+ interrupt()
+ # A pick applies from this ask on and stays with the chat.
+ cur["model"] = cmd.get("model", cur["model"])
+ threading.Thread(target=reply, args=(turn, text, files, audio, cur["model"]),
+ daemon=True).start()
+```
+
+`new`: `emit({"type": "conversation", "id": None, "turns": [], "model": None})`.
+
+`open`, replacing its last two lines:
+
+```python
+ interrupt()
+ model = DB.execute("SELECT model FROM conversations WHERE id = ?",
+ (cmd["id"],)).fetchone()[0]
+ cur.update(id=cmd["id"], history=got[0], turns=got[1], model=model)
+ emit({"type": "conversation", "id": cur["id"], "turns": cur["turns"], "model": model})
+```
+
+`delete` of the current one: `emit({"type": "conversation", "id": None, "turns": [], "model": None})`.
+
+New branch before the final `else`:
+
+```python
+ elif kind == "models":
+ # Off the lock: the router can take seconds to answer.
+ threading.Thread(target=send_models, daemon=True).start()
+```
+
+New function after `cloud_models`:
+
+```python
+def send_models():
+ # The canvas's model dropdown: the router's models, then every cloud
+ # model whose provider's key loaded.
+ try:
+ with urllib.request.urlopen(CFG["llm"] + "/models", timeout=10) as r:
+ local = [{"id": m["id"], "status": m["status"]["value"], "cloud": False}
+ for m in json.load(r)["data"]]
+ except (OSError, KeyError, TypeError, ValueError):
+ local = [] # router down: the cloud ones still show
+ emit({"type": "models", "items": local + [
+ {"id": f"{n}:{m}", "provider": n, "cloud": True}
+ for n, p in PROVIDERS.items() for m in p["models"]]})
+```
+
+- [ ] **Step 4: Implement the request side**
+
+`model_inputs`:
+
+```python
+def model_inputs(model=None):
+ # (id, inputs) of the model a chat uses: its pick, else whatever the
+ # router has resident (asking for another would swap it out under other
+ # clients). inputs is None when not reported, as for every cloud model.
+ if model and route(model)[3]:
+ return model, None
+ with urllib.request.urlopen(CFG["llm"] + "/models", timeout=10) as r:
+ models = json.load(r)["data"]
+ m = (next((m for m in models if m["id"] == model), None)
+ or next((m for m in models if m["status"]["value"] != "unloaded"), models[0]))
+ if model and m["id"] != model:
+ log(f"no local model {model}; using {m['id']}")
+ return m["id"], (m.get("architecture") or {}).get("input_modalities")
+```
+
+`ask`, signature and the lines up to the connection:
+
+```python
+def ask(messages, calls, tools=None, mine=None, think=None, meter=True, model=None):
+ # Yields ("think", text) and ("token", text) as they stream; tool-call
+ # fragments go into calls. mine is the reply's turn: a stop shuts the
+ # connection from the other thread (interrupt), which ends the reading.
+ # model is the id model_inputs gave, None for the router's loaded one.
+ url, key, mid, cloud = route(model or llm_model())
+ if cloud:
+ ctx.update(model=model, max=0) # no /props: the meter shows tokens used only
+ else:
+ context_size(mid)
+ body = {"model": mid, "messages": messages, "stream": True,
+ "stream_options": {"include_usage": True}}
+ if not cloud:
+ # llama.cpp's own field; strict providers refuse unknown ones.
+ body["chat_template_kwargs"] = {
+ "enable_thinking": CFG["thinking"] if think is None else think}
+ if tools:
+ body["tools"] = tools
+ if CFG["temperature"] is not None:
+ body["temperature"] = CFG["temperature"]
+ # http.client rather than urllib: it exposes the socket a stop must shut.
+ u = urllib.parse.urlsplit(url + "/chat/completions")
+```
+
+and its `yield from` line:
+
+```python
+ yield from stream(conn, u.path, body, calls, meter, key, cloud or "llama-server")
+```
+
+`stream`:
+
+```python
+def stream(conn, path, body, calls, meter=True, key=None, who="llama-server"):
+ headers = {"Content-Type": "application/json"}
+ if key:
+ headers["Authorization"] = f"Bearer {key}"
+ conn.request("POST", path, json.dumps(body).encode(), headers)
+ r = conn.getresponse()
+ if r.status >= 400:
+ # The body says why (a full context, a bad request, a spent quota);
+ # the status alone does not.
+ raise RuntimeError(f"{who} {r.status}: {r.read().decode(errors='replace')[:2000]}")
+```
+
+In its loop, the reasoning lines become:
+
+```python
+ # llama-server sends reasoning_content, OpenRouter reasoning.
+ think = delta.get("reasoning_content") or delta.get("reasoning")
+ if think:
+ yield "think", think
+```
+
+and the last line: `raise RuntimeError(other.decode(errors="replace")[:300] or f"{who} sent nothing")`.
+
+`reply`:
+
+```python
+def reply(mine, text, files=(), audio=None, model=None):
+ try:
+ if audio:
+ # Always the local model: the audio never leaves, only the words.
+ text = transcribe(mine, audio)
+ if text is None:
+ return
+ answer(mine, text, files, model)
+```
+
+`answer`, signature `def answer(mine, text, files=(), chosen=None):`; replace
+
+```python
+ model, inputs = model_inputs()
+ images = inputs is None or "image" in inputs
+```
+
+with
+
+```python
+ model, inputs = model_inputs(chosen)
+ images = inputs is None or "image" in inputs
+ # A cloud chat is offered only the tools marked cloud = true: the others
+ # would send mail, money or calendar to the provider. One of them called
+ # anyway (copied from history) answers "no tool named".
+ offered = ({n: t for n, t in TOOLS.items() if t.get("cloud") is True}
+ if route(model)[3] else TOOLS)
+ offer = schema(offered) or None
+```
+
+In the round loop:
+
+```python
+ for kind, piece in ask(sent, calls, offer if rnd < CFG["tool_rounds"] else None,
+ mine, model=model):
+```
+
+```python
+ result = run_tool(offered, c["name"], c["arguments"])
+```
+
+Save and title:
+
+```python
+ cur["id"] = save_turn(DB, cur["id"], text, said, used, entry, chips, chosen)
+```
+
+```python
+ if fresh:
+ retitle(mine, conv, text, said, model)
+```
+
+`retitle`: signature `def retitle(mine, conv, user, said, model=None):`, and its ask call gets `model=model`:
+
+```python
+ text = "".join(p for k, p in ask(msgs, {}, None, mine, think=False, meter=False,
+ model=model)
+ if k == "token")
+```
+
+- [ ] **Step 5: Run, expect `ok`**
+
+Also `grep -n TOOL_SCHEMA assistant.py` must print nothing.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add assistant.py
+git commit -m "cloud: a chat's model routes its requests, cloud chats get cloud tools only
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: Examples and docs
+
+**Files:**
+- Modify: `examples/config.toml`, `examples/tools/{calc,now,search,weather}.toml`, `README.md`, `AGENTS.md`, `docs/superpowers/specs/2026-10-23-cloud-models-design.md`
+
+- [ ] **Step 1: Tool flags**
+
+```bash
+cd "$(git rev-parse --show-toplevel)"
+for t in calc now search weather; do sed -i '/^command = /a cloud = true' examples/tools/$t.toml; done
+grep -c '^cloud = true' examples/tools/*.toml | grep -v ':0'
+```
+
+Expected: exactly `calc`, `now`, `search`, `weather`, each `:1`.
+
+- [ ] **Step 2: `examples/config.toml`**, append:
+
+```toml
+
+# Cloud models, picked per chat in the canvas header, never a fallback.
+# One table per provider with an OpenAI-compatible API. key_cmd prints the
+# API key and runs without a shell, once at startup: the key is never
+# stored here. models are the ids the canvas offers; the settings page
+# fetches the list with `assistant.py --cloud-models <name>`. A cloud chat
+# is offered only the tools whose file says cloud = true.
+# [[cloud]]
+# name = "openrouter"
+# url = "https://openrouter.ai/api/v1"
+# key_cmd = "pass show llm/openrouter"
+# models = ["mistralai/mistral-large"]
+```
+
+Then verify it still parses: `python3 -c "import tomllib; tomllib.load(open('examples/config.toml','rb'))"` (no output).
+
+- [ ] **Step 3: `README.md`**
+
+Opening paragraph, last sentence (`The model and the history stay on the machine; only tools you enable (weather, web search) reach the network.`) becomes:
+
+```
+The history stays on the machine. So does the model, unless a chat is
+switched to a cloud model you configured; only tools you enable (weather,
+web search) reach the network otherwise.
+```
+
+In the tool TOML example, after `timeout = 10`:
+
+```toml
+cloud = false # true: also offered to cloud models
+```
+
+After the `config.toml` bullet, a new bullet:
+
+```
+- `[[cloud]]` in `config.toml`: providers with an OpenAI-compatible API,
+ each with a `key_cmd` that prints its key (see the example). A chat
+ picks a model in the canvas header; a cloud chat is sent its history,
+ attachments and the results of the tools marked `cloud = true`, and
+ nothing from the others. Voice is always transcribed locally.
+```
+
+- [ ] **Step 4: `AGENTS.md`**
+
+Line 5-6, `Nothing leaves the machine except what an enabled tool fetches.` becomes:
+
+```
+Nothing leaves the machine except what an enabled tool fetches, and a
+chat the user switched to a cloud model (2026-10-23).
+```
+
+After the `Tools (2026-09-26)` paragraph, a new one:
+
+```
+Cloud models (2026-10-23): `[[cloud]]` providers in `config.toml`, keys
+from `key_cmd`, ids `provider:model`, chosen per chat and stored on the
+conversation. Only tools with `cloud = true` go to a cloud chat. Spec:
+`docs/superpowers/specs/2026-10-23-cloud-models-design.md`.
+```
+
+Agent rule `- Everything stays local: no cloud STT/TTS/LLM fallbacks.` becomes:
+
+```
+- No silent cloud fallback, ever. A cloud model is used only when the user
+ picks it for a chat; tools reach a cloud chat only with `cloud = true`;
+ audio is always transcribed locally; no cloud STT/TTS.
+```
+
+- [ ] **Step 5: Spec status**
+
+`Date: 2026-10-23. Status: approved, not implemented.` becomes `Date: 2026-10-23. Status: daemon implemented (2026-10-23); quickshell side pending.`
+
+- [ ] **Step 6: Self-check and commit**
+
+Run the self-check, expect `ok`. Then:
+
+```bash
+git add examples README.md AGENTS.md docs/superpowers/specs/2026-10-23-cloud-models-design.md
+git commit -m "docs: cloud models, example provider and cloud tools
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: Live check (with the user)
+
+Not automatable: it needs a real provider and key. Ask the user before each outward step.
+
+- [ ] **Step 1:** Ask the user which provider, its `key_cmd`, and one or two model ids. Ask before touching `~/.config/desktop-assistant/` (their live config): add the `[[cloud]]` table, and add `cloud = true` to their installed `calc`, `now`, `search`, `weather` tool files.
+- [ ] **Step 2:** `python3 assistant.py --cloud-models <name> | head -c 300`: a JSON array of ids.
+- [ ] **Step 3:** Run a second daemon on a scratch socket and data dir, as the clone tool check did (AGENTS.md, "Clone tool, live check"), so the real history is untouched. Short path: a Unix socket path is limited to 108 bytes.
+- [ ] **Step 4:** Through it, with `model` set to the cloud id: a weather question (tool `weather`, answered), "how many unread mails do I have?" (no mail tool offered: must say it cannot tell), a voice clip from `/data/voice-models/samples/stt/` (`user` event from the local model, answer from the cloud), then the same chat with a local `model` and back. Record first-token times, `ctx` used, and anything the provider refused (unknown fields such as `stream_options`).
+- [ ] **Step 5:** Write the findings section into AGENTS.md (`### Cloud models, live check (2026-10-23)`), ask before committing.