diff options
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/specs/2026-10-23-cloud-models-design.md | 152 |
1 files changed, 152 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-10-23-cloud-models-design.md b/docs/superpowers/specs/2026-10-23-cloud-models-design.md new file mode 100644 index 0000000..5747c22 --- /dev/null +++ b/docs/superpowers/specs/2026-10-23-cloud-models-design.md @@ -0,0 +1,152 @@ +# Cloud models: design + +Date: 2026-10-23. Status: approved, not implemented. + +## Goal + +Let a chat use a cloud model instead of the one llama-server has loaded. +Providers and their models are set up in the quickshell settings window; +the canvas header's model name becomes a dropdown that picks the model +for that chat. The preferred model may be a cloud one. + +This changes the project's rule "Everything stays local: no cloud +STT/TTS/LLM fallbacks". The new rule: no silent cloud fallback, ever. A +cloud model is used only when the user picks it for a chat; tools opt in +to cloud chats one by one; audio never leaves the machine. + +## What leaves the machine in a cloud chat + +The provider receives the system prompt, the date stamp, the history +window (10 turns, tool calls with placeholder results), the question, +attachments, and the results of the tools that opted in. That is the +privacy cost, and the reason tools default to staying out. + +## Config + +One `[[cloud]]` table per provider in `config.toml`: + +```toml +[[cloud]] +name = "openrouter" +url = "https://openrouter.ai/api/v1" +key_cmd = "pass show llm/openrouter" +models = ["mistralai/mistral-large", "deepseek/deepseek-chat"] +``` + +- Providers are OpenAI-compatible only: `GET {url}/models` and + `POST {url}/chat/completions`, `Authorization: Bearer <key>`. That + covers OpenRouter, Mistral, Groq, DeepSeek, OpenAI, and the + OpenAI-compatible endpoints of Gemini and Anthropic. +- `key_cmd` is an argv string (split with `shlex`, run without a shell); + its stdout, stripped, is the key. Run once per provider at startup. The + key is never logged, never sent on the socket, never written by the + settings page. +- `name` must be unique and contain no `:`. `merge()` today handles + tables, not arrays of tables: `cloud` is validated on its own (list of + tables, exactly the four keys, all strings except `models`, a list of + strings). A typo fails loudly, as for every other key. +- `key_cmd` failing (non-zero exit, empty output, 10 s timeout) drops + that provider: one log line, its models absent from `models`. The + daemon still starts. + +## Model ids + +`"<provider>:<model>"` for a cloud model, the bare router id for a local +one. A prefix counts only when it names a configured provider, so a local +id containing `:` stays local. Split on the first `:` only: cloud model +ids contain `/` and sometimes `:` (OpenRouter's `:free`). + +## Daemon + +- **Per-chat model.** `conversations` gets a `model TEXT` column; an + existing database gains it with `ALTER TABLE` when missing. `ask` takes + an optional `model`; the first ask of a chat stores it, a different one + updates it. NULL (old chats, asks without `model`) means today's + behaviour: whatever the router has loaded. Opening a chat reports its + model (`open` answers with it) so the canvas can set the dropdown. +- **Local model.** A chosen local id is sent to the router as is, and the + router swaps it in if another is loaded. The old rule "the daemon never + asks for a model" becomes "only for a model the user picked for this + chat". `ctx` still carries the model actually answering. +- **Cloud model.** Same request body minus `chat_template_kwargs` (strict + providers reject unknown fields). No `/props`: `ctx` max is 0 and the + canvas shows used tokens only, from `usage`. Reasoning deltas are read + from `reasoning_content` (llama-server) or `reasoning` (OpenRouter). + `temperature` from config applies when set. Vision and audio are + unknown for cloud models (`/models` does not report them), so + attachments are sent and a provider refusal comes back as `error`. +- **Tools.** A tool file may set `cloud = true`. A cloud chat is offered + only those; the default is false. A local chat is offered all of them, + as today. The example tools ship with the flag only where the output is + not personal (`calc`, `now`, `search`, `weather`); mail, finance, + calendar, playing, VMs, Bluetooth, updates, modes, clone and project + stay out. +- **Voice.** Transcription always uses the local loaded model, whatever + the chat's model. The audio never leaves; only the transcript does, as + the text question. If the loaded model cannot hear, the ask is refused + as today. +- **Titles.** Written by the chat's own model. +- **`models` command.** Answers with the router's models (id, status) + plus every configured cloud model, each with `cloud: true|false` and, + for cloud, its provider name. Feeds the dropdown. +- **`--cloud-models <name>`.** CLI mode: reads `config.toml`, runs that + provider's `key_cmd`, prints the provider's `/models` ids as JSON, one + array, sorted, and exits. The settings page runs it to fetch the list, + so the key stays in a short-lived daemon process and never reaches QML. +- **Errors.** Provider HTTP error: `error` event with status and body, + capped at 2000 characters like tool errors, nothing written to history. + Timeouts as for the router. + +## Settings page (quickshell, `desktop/modules/assistant/`) + +A "Cloud" section under "Model": one card per provider with name, URL, +key command, and Fetch. Fetch saves the draft, runs `assistant.py +--cloud-models <name>`, and shows the ids as a checklist; the checked +ones become `models`. Add and remove provider. Saved through the existing +line-by-line `Config.js` path, still parsed with `tomllib` before the +atomic replace. Restart offered as for every config change. + +Prefer extends to cloud models: `Prefs.assistantModel` holds the +prefixed id. Opening the canvas with a cloud preference loads nothing +locally. + +## Canvas header (quickshell, `desktop/assistant/`) + +The model name becomes a dropdown from the `models` command: local +models, then cloud models grouped by provider, cloud ones marked with an +icon. A new chat starts on the preferred model; a reopened chat on its +stored one. Picking a model applies from the next ask. + +Switching a chat that already has turns from local to cloud sends its +whole history window to the provider on the next ask, so the first such +switch in a chat shows one line: "History in this chat goes to +<provider>." + +## Testing + +`--test` gains a fake provider: a local `http.server` stub on a random +port, `key_cmd` an `echo`. It checks: prefix routing (cloud id to the +stub with the Bearer key, local id to the router stub), a local id with +`:` stays local, `cloud = false` tools absent from the cloud request, +no `chat_template_kwargs` in it, the `model` column stored and reported +on reopen, the `ALTER TABLE` on an old database, a failing `key_cmd` +dropping its provider, and the key absent from every socket event and +log line. + +Live check after: one cloud chat with a tool-using question (`weather`), +one asking for mail (must answer that it has no tool for it), a voice +question in a cloud chat (transcribed locally), and switching a chat +local to cloud and back. + +## Docs + +AGENTS.md: the opening line and the "Everything stays local" agent rule +rewritten to the rule in Goal; a findings section after the live check. +README: config example for `[[cloud]]` and the `cloud = true` tool flag. + +## Scope + +Two repos, one plan each: the daemon here first (config, routing, tool +filter, `models`, `--cloud-models`, DB column), then quickshell (settings +section, dropdown). Out: non-OpenAI-compatible APIs, per-model +capability detection, cost tracking, cloud STT/TTS. |
