diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-09 11:56:00 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-09 11:56:00 +0200 |
| commit | 949c5be9143d4f1a0d803747e634da3477daf613 (patch) | |
| tree | 8a25ac45671ab45b68a3b3bfd0eeffe93f2bf400 /docs/superpowers/specs | |
| parent | e3421bd781a103d3495a8a741bc04541ada59c6d (diff) | |
| download | llamachat-949c5be9143d4f1a0d803747e634da3477daf613.tar.gz llamachat-949c5be9143d4f1a0d803747e634da3477daf613.zip | |
docs: spec for external cloud providers
Design for talking to OpenAI-compatible cloud providers alongside the
local router. Providers are config table entries, local becomes the
"default" one, and a bare base_url still synthesizes it so existing
configs keep working.
Cloud models carry no presets.ini entry and cost money, so missing
metadata (context size, vision, prices) is entered through a per-model
dialog stored in models.ini, and an approximate per-conversation cost
sits next to the context meter. Token counts and the producing model
move onto the messages table so cost survives reopening a chat and
prices each reply at whatever produced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/specs')
| -rw-r--r-- | docs/superpowers/specs/2026-08-09-external-providers-design.md | 275 |
1 files changed, 275 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-08-09-external-providers-design.md b/docs/superpowers/specs/2026-08-09-external-providers-design.md new file mode 100644 index 0000000..1174ded --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-external-providers-design.md @@ -0,0 +1,275 @@ +# External cloud providers + +Date: 2026-08-09 + +## Goal + +Let llamachat talk to OpenAI-compatible cloud providers (together.ai, +siliconflow) alongside the local llama.cpp router, without the local path +losing any of its current behaviour and without the existing config file +needing to be edited. + +Cloud models cost money and carry no `presets.ini` entry, so two problems +come with them: the client has no context size or vision flag to work from, +and every turn resends the whole conversation at a real price. Both are +addressed by making the missing metadata enterable through a dialog and by +putting an approximate cost next to the context meter. + +## Providers + +`config.toml` grows a `[providers.*]` table. The local router stops being +special and becomes an ordinary entry named `default`. + +```toml +[providers.default] +base_url = "http://localhost:8181" + +[providers.together] +base_url = "https://api.together.xyz" +api_key = "pass:api/together" +filter = ["qwen", "deepseek"] +ctx_size = 32768 +price_in = 0.60 +price_out = 0.60 +``` + +Per-provider keys, all optional except `base_url`: + +| Key | Meaning | +| --- | --- | +| `base_url` | Required. API root, `/v1/...` is appended as today. | +| `api_key` | Absent means no `Authorization` header is sent. | +| `filter` | List of case-insensitive substrings. Empty or absent shows every model. | +| `ctx_size` | Prefill for the model dialog. Not applied to requests. | +| `vision` | Prefill for the model dialog. | +| `price_in` | USD per million input tokens. Prefill. | +| `price_out` | USD per million output tokens. Prefill. | + +### Backward compatibility + +A top-level `base_url` with no `[providers]` table synthesizes +`[providers.default]` from it. An existing config keeps working untouched +and nothing needs migrating. If both a top-level `base_url` and an explicit +`[providers.default]` are present, the explicit one wins. + +### Model identity + +Models are addressed as `provider:model`. The `default` provider is the +exception: its models are shown and stored bare, so the local dropdown reads +exactly as it does now and existing sessions keep resolving. + +A model id containing a colon is therefore ambiguous only in principle; the +split is on the first colon, and any provider name that is not configured +falls back to treating the whole string as a `default` model. That keeps an +old session pointing at a local model working even if the model name itself +grows a colon. + +### Filtering + +Filtering applies to cloud providers so the dropdown stays usable: +together.ai lists roughly 200 models. A model is kept when any filter +substring appears in its id, compared case-insensitively. `"qwen"` matches +`Qwen/Qwen2.5-72B-Instruct-Turbo`. + +The `default` provider is never filtered. + +When a provider's filter matches nothing, the status bar reports it by name +with the counts, e.g. `together: 0 of 213 models matched filter`. The +provider contributes no models rather than silently falling back to all of +them. + +A provider that cannot be reached does not break the model list: its models +are omitted and the failure is reported in the status bar. Local models must +remain selectable when the network is down. + +## API keys + +`api_key` is a single field, dispatched on prefix: + +| Form | Behaviour | +| --- | --- | +| `pass:NAME` | Runs `pass show NAME`, takes the first line. | +| `env:VAR` | Reads the environment variable. | +| anything else | Used literally. | + +Resolution is lazy: nothing runs until the first request to that provider, +so a session that only touches local models never invokes `pass` and never +triggers a pinentry. The resolved value is cached in memory for the process +lifetime and never written to disk. + +The `pass` subprocess gets a 30 second timeout. A stuck pinentry then +surfaces as a backend error naming the provider instead of freezing the +worker thread. A missing entry, a non-zero exit, or empty output are all +reported the same way. + +A literal key in `config.toml` works but the generated comments point at +`pass:` as the better option. + +## Model metadata + +Three layers resolve each of `ctx_size`, `vision`, `price_in`, `price_out`, +most specific first: + +1. `models.ini` entry for that exact `provider:model` +2. `[providers.X]` default +3. unknown + +Nothing has to be set in `config.toml`. The provider block is a convenience +that prefills the dialog; the dialog alone is enough. + +Unknown degrades per value: + +| Value | Unset behaviour | +| --- | --- | +| `ctx_size` | Meter shows tokens used with no limit and no percentage. Attachment budget uses a fallback. | +| `vision` | Treated as unknown, not as false: attaching an image is permitted and no dialog offers to switch models. | +| `price_in` / `price_out` | No cost figure. Shown as blank for `default`, `?` for a provider that has an `api_key`. | + +The `?` distinguishes "local, free" from "cloud, price not entered". Both +would otherwise render as an empty label, which reads as free. + +### models.ini + +Written by the app, beside `config.toml` and `state.ini`, parsed with +configparser. Sections are keyed by full model id. + +```ini +[together:Qwen/Qwen2.5-72B-Instruct-Turbo] +ctx_size = 32768 +vision = false +price_in = 1.20 +price_out = 1.20 + +[together:meta-llama/Llama-Vision-Free] +configured = false +``` + +`configured = false` records a cancelled dialog: the model was offered and +skipped, so it is never offered again. That is distinct from an absent +section, which means never asked. + +The file grows one section per cloud model touched, including abandoned +ones. At the expected scale that is tens of entries in a plain text file the +user can prune by hand, so no cleanup logic is written. + +### Model dialog + +Four fields, prefilled from the provider defaults: context size, a vision +checkbox, input price, output price. Prices are per million tokens. Empty +price fields are valid and mean unpriced. + +Two triggers: + +- **First selection.** Choosing a model in the dropdown that has no + `models.ini` section and belongs to a provider other than `default` opens + the dialog once, before anything is typed. Cancel writes + `configured = false`. +- **On demand.** A button beside the model dropdown opens the dialog for the + current model, including local ones, and regardless of any previous + cancel. This is how a wrong value gets corrected. + +Local models are exempt from the automatic trigger because `presets.ini` +already answers context and vision for them and they cost nothing. The +on-demand path still reaches them, and a `models.ini` entry overrides the +preset when one exists. + +## Cost + +### Storage + +`messages` gains three nullable columns: + +| Column | Filled | +| --- | --- | +| `prompt_tokens` | On assistant rows, from the usage chunk. | +| `completion_tokens` | On assistant rows, from the usage chunk. | +| `model` | On assistant rows, the model that produced the reply. | + +The migration is additive. Existing rows read as NULL and contribute zero, +so old conversations show a cost of zero rather than a fabricated one. + +`model` is needed on the message rather than the session because `sessions` +records only the current model. A conversation that switched models would +otherwise be priced entirely at whichever model happens to be selected, and +switching mid-conversation is exactly what a mixed local-and-cloud setup +invites. + +### Readout + +Beside the context meter: the conversation's cost so far, plus what sending +the current draft would cost, shown separately so the two are not conflated: + +``` +$0.043 +$0.011 +``` + +Cost so far is summed from the stored token counts, pricing each assistant +message by its own `model`, so it survives reopening a conversation. The +projected figure reuses the token estimate the context meter already +computes for the draft, priced at the currently selected model's input rate. + +For an unpriced cloud model the figure is `?`. For a local model the label is +empty. + +### Why the projection matters + +`_chat_context` rebuilds every request from all stored rows, so reopening a +long conversation and sending one message resends the entire history. That +is free locally and billed per turn on a cloud provider. Showing the +projected cost of the next send is what makes that visible before it is +paid rather than after. + +No trimming, summarization or context management is part of this work. +Visibility is the whole intervention: an expensive conversation is apparent, +and starting a new one is the user's call. + +## Out of scope + +- History trimming or summarization for long cloud conversations. +- A lifetime or per-month spend total. The stored counts make it a `SUM` + away if it is ever wanted. +- Per-model tables in `config.toml`. The dialog covers that ground. +- Streaming or tool-call behaviour differences between providers, beyond not + assuming they are absent. See the risk below. + +## Risks + +**Tool calling and reasoning content are unverified on cloud providers.** +The web search loop depends on tool-call fragments arriving in the delta's +`tool_calls` field and a `finish_reason` of `tool_calls`, and the reasoning +pane depends on `reasoning_content` deltas. Both are llama.cpp behaviours +that cloud providers implement with variations. Search on a cloud model may +need per-provider work once it is tried against a real endpoint. Nothing in +this design assumes it works out of the box, and local search behaviour must +not regress. + +**A wrong price entered once is silently wrong forever.** The cost figure is +only as good as the numbers typed into the dialog, and no API publishes them +in a standard form. The readout is explicitly an approximation. + +**Provider outages must not break the local path.** Model listing, key +resolution and cost display all have to fail soft, leaving local models +usable. + +## Testing + +Following the project's existing test approach, written before the +implementation: + +- Config: providers parsed, bare `base_url` synthesizing `default`, explicit + `default` winning over it, filter matching case-insensitively, empty + filter meaning all, `default` never filtered. +- Key resolution: each prefix form, lazy resolution not firing for local + requests, caching after first resolution, timeout and failure paths + reported as errors. +- Model ids: split on first colon, unknown provider falling back to + `default`, `default` models rendering bare. +- Metadata resolution: the three layers in order, unknown values degrading + as tabulated, `configured = false` suppressing the dialog. +- `models.ini` round trip: written, reread, values preserved. +- Cost: summing per message by that message's model, NULL rows counting + zero, unpriced yielding `?` for cloud and blank for local, projection + using the input rate. +- Migration: an existing database gaining the three columns without loss, + and old rows reading as zero. +- Failure isolation: an unreachable provider leaving local models listed. |
