Feature Specification: Provider Switching
中文版: spec.zh.md
Feature Branch: feature/G9-provider-switchingCreated: 2026-06-21 Status: Draft
One-line
A unified LLM connection lets users configure a key once (name, wire format, base URL, encrypted credential, model) and use it BOTH ways: project it into the matching agent's native config file AND let Coffer's own internal LLM engine run on it. One connection retires the separate ModelConfig/chat_models registry, folding internal-engine model selection into the same record. Coffer's differentiator over claude switch or equivalent per-tool scripting: a unified registry with governance — Fernet-encrypted credentials, full audit trail, and git-sync — not per-tool silos.
Why
Claude Code and Codex each require their own native config files (~/.claude/settings.json, ~/.codex/config.toml) with provider-specific keys and base URLs, and Coffer's internal engine used to keep a SECOND, parallel model registry (chat_models). Switching providers today means editing multiple files by hand, storing keys in plaintext, and losing the audit trail — and a key configured for an agent could not be reused by the internal engine. Coffer centralises connections: configure once, project it to the matching agent (switch), mark one as the internal engine's default, audit everything.
Confirmed Decisions
Three decisions were locked before spec was written; do not relitigate them.
Decision A — single-wire connection, per-agent activation, one internal default
One connection holds {name, wire_format, base_url, credential_ref, model, fast_model, wire_api, is_active, internal_default}. A connection projects ONLY into agents whose native protocol matches its wire_format:
anthropic→ Claude Code (~/.claude/settings.json)openai→ Codex (~/.codex/config.toml)ollama→ internal-only; never projected to any agent
At most one active connection per wire format exists at any time (per-wire single-active invariant, analogous to the ModelConfig.is_default pattern in the retired chat-model registry). Claude Code and Codex "share" the registry — a credential ref may be reused across connections — but NOT via one record driving both agents.
In addition to per-agent activation, a connection MAY carry a single GLOBAL internal_default flag (≤1 across all connections) marking the connection Coffer's own internal LLM engine uses (memory organizer, reorg, distill, coffer__ask). The third wire ollama is internal-only: it never projects to any agent, and because it has no API key its credential_ref is absent — it needs only a base_url. credential_ref is therefore OPTIONAL: required for anthropic/openai, absent for ollama. One connection may be BOTH active (projected to its wire's agent) AND internal_default (used internally) — one key, two uses.
Decision B — credential isolation; key never plaintext in native config
Consistent with the existing MCP credential_refs pattern and the project's "credential isolation" principle. The raw key stays in the Fernet vault and is materialised on demand:
- Claude Code:
apiKeyHelper = "coffer provider key --wire anthropic"insettings.json. Claude invokes this command to fetch the key. Because Claude Code re-invokesapiKeyHelperperiodically, this design makes a future hot-switch nearly free — forward-looking only. - Codex:
env_key = "COFFER_PROVIDER_KEY"in the[model_providers.coffer]TOML table. Codex reads the key from that env var at runtime. This PR does NOT modify Codex spawning; the user must export the key manually (see Quickstart). State plainly: Codex standalone requiresCOFFER_PROVIDER_KEYto be set in the shell; auto env-injection into Coffer-spawned Codex is deferred with hot-switch. This is the accepted cost of Decision B.
The raw key is NEVER written to settings.json, config.toml, or any other native config file.
Decision C — phased; hot-switch is OUT OF SCOPE for this PR
This PR ships: registry + projection + switch op + audit + sync wiring.
Hot-switch (mid-session reload of a running Claude Code or Codex process) is a separate, later PR and is explicitly out of scope here.
Amendment 2026-06-22 — Connections are optional overrides; multi-protocol; chat built-in fallback
Status: Draft. Supersedes the gating in Decision A and the two non-goals noted below; recorded after reviewing the shipped UI (PR #187). Cross-ref ADR-032 Amendment.
Why. Chat shells out to each agent's OWN runtime — Claude Code via the Claude Agent SDK, Codex via a codex app-server subprocess — and the backend never requires a Coffer connection (turn_orchestrator resolves no credential; the "no connection" block is a frontend-only guard in DraftThread.tsx). A connection is therefore an OPTIONAL OVERRIDE projected into the agent's native config, not a prerequisite. The shipped UI wrongly treated it as required (claude_code showed "还没有连接"; chat blocked with "尚未配置连接"), and codex chat broke because projection writes wire_api = "chat", which codex-cli 0.130 rejects (config fails to load → every turn errors).
- D1 — Connection is an optional override; built-in is the default. With no connection projected for an agent, it runs on its own built-in model/login. The chat surface MUST NOT block on "no connection"; it runs on the built-in model. (Removes the
DraftThreadhard guard.) - D2 — A connection declares the protocols it supports (multi-select). Replaces single-
wire_formatgating:protocols ⊆ {anthropic, openai}. A connection is offered to EVERY agent whose native protocol it declares (anthropic → Claude Code, openai → Codex), so one key can serve both.ollama/ internal-only remains a separate concern for the internal engine. The per-wire single-active invariant becomes per-protocol single-active: at most one connection is the active override per protocol. Coffer still does NOT translate protocols — a connection reaches an agent only if it declares that agent's protocol (the endpoint must actually speak it). - D3 — Per-agent override selection is the source of truth (Agent page). Each agent picks, on its detail page, which connection (among those declaring its protocol) overrides it — or "built-in". This REPLACES the former non-goal "no manual per-agent binding". Selecting projects it; "built-in" clears Coffer's projection so the agent's own auth/model returns.
- D4 — Chat model selection is a FIXED choice, not free-form. The chat surface offers a fixed dropdown, never a free-text id. Its options are state-dependent: when a connection overrides the agent, the connection's introspected models (
POST /models/list-models); otherwise the agent's curated BUILT-IN list (Claude Code: opus / sonnet / haiku; Codex: gpt-5 / o-series). The picker MUST NOT read the connection's storedmodel/fast_modelfields (they leave the connection in the E1 amendment); the current per-conversation value is always shown so it stays selectable. No free-form per-conversation model id (Conversation.model_idstays vestigial); non-chat models (image / video) are never offered. Changing the model/connection happens on the Agent page, not per conversation. - D5 — Internal-engine selection is a separate control. "Which connection Coffer's internal engine uses" (
internal_default) moves OUT of the connection card into its own dropdown selector on the LLM connections page. Connection cards drop the internal-engine badge/star and gain an edit action (add / edit / delete via one dialog). - D6 — Connection dialog gains «测试连接» + «拉取模型». The add/edit dialog surfaces test-connection (
POST /models/test-connection) and list-models (POST /models/list-models), reusing the existing introspection service. Both MUST accept an inline (not-yet-saved) secret so the user can test/fetch before saving; fetched models populate a selectable dropdown. - D7 — Codex
wire_apiis selectable; defaultresponses. The connection exposeswire_api ∈ {chat, responses}in the dialog, defaulting toresponses(codex-cli 0.130 droppedchat). Fixes the broken-codex-chat bug.
Now in scope (were non-goals): per-agent override selection (D3); chat built-in fallback (D1); one connection serving multiple agents via declared protocols (D2).
Still out of scope: hot-switch; proxy / failover / format conversion; anthropic↔openai protocol translation.
Amendment 2026-06-22b — A connection is a credentialed endpoint; model & protocol leave the connection
Status: Draft. Supersedes Decision A's "model on the connection" and the earlier amendment's D2 (multi-protocol set). Recorded after researching cc-switch and a design pass with the user. Cross-ref ADR-032 amendment D8/D9.
Why. A connection answers "which gateway account" — an endpoint + a key. Which model and which protocol an agent speaks are properties of the USE, not of the account: Claude Code always speaks the Anthropic Messages wire and Codex always speaks OpenAI, so the agent determines the protocol at projection time; and the model is picked per agent slot / internal-engine / chat turn. Carrying model and a manually-chosen wire_format on the connection forced the user to answer those questions too early and conflated the account with its uses.
- E1 — Connection entity slims to
{name, base_url, credential_ref, protocol}.model,fast_model, and the manualwire_formatselector are REMOVED from the connection.wire_api(Codex chat/responses) likewise moves to the Codex binding, not the connection. - E2 —
protocolis DETECTED, not user-entered. On create/edit Coffer probes the endpoint (reusing the introspection path) to classify it asanthropic-wire,openai-wire, orunknown. The add dialog therefore shows only name + base_url + key + «测试连接» — no type selector, no model field. - E3 — Model is chosen at the point of use. Per-agent binding (Agent page dual slots →
ANTHROPIC_MODEL+ANTHROPIC_SMALL_FAST_MODEL), the internal-engine default selector, and the chat surface each pick a model from the chosen connection's fetched models. No model lives on the connection. The internal-engine model is its own global singleton (one row), read/written viaGET/PUT /api/v1/internal-engine-configand audited asinternal_engine_model_set.resolve_internal_connection()overlays that model onto the resolvedinternal_defaultconnection before the engine builds its chat model; while the connection still carries amodel(until E1 lands), an empty internal-engine model falls back to the connection's model. - E4 — Projection input = connection (endpoint + key + protocol) + the binding (model). Activating/projecting a connection for an agent reads the endpoint/key/protocol from the connection and the model(s) from that agent's binding. A connection with no binding for an agent projects nothing.
- E5 — Compatibility filter, with an honest fallback. The Agent page offers only connections whose detected
protocolmatches the agent's wire. When a connection's protocol isunknown(probe inconclusive), it is shown to ALL agents and the user decides — Coffer does not silently hide a possibly-valid connection. - Migration (option A — discard): existing connections drop
model/fast_model; theirwire_formatis re-derived as a detectedprotocol(orunknownpending the next probe). Any model a user had configured is NOT carried into a binding — after upgrade the user re-selects models on the Agent page. This is the clean break the user chose over a best-effort carry-over.
Supersedes: Decision A ("one connection holds … model, fast_model … " and "a connection projects ONLY into agents whose native protocol matches its wire_format" — now the agent's wire drives projection, and the connection's protocol is a detected compatibility hint); amendment D2 (multi-protocol set — already dropped, see ADR-032 D8: one gateway for two agents = two connections).
Still NOT in scope (unchanged): proxy / hot-switch / protocol conversion.
Amendment 2026-06-23 — Per-connection compatible agents; per-connection key resolution; agent-keyed activation
Status: Draft. Supersedes E5's protocol-based compatibility filter and the per-protocol single-active invariant. Recorded after a design pass with the user. Cross-ref ADR-032.
Why. Tying projection to the connection's detected protocol cannot express "this openai-compatible gateway (e.g. agnes) should drive Claude Code." Worse, key resolution was keyed by wire + is_active (apiKeyHelper = coffer provider key --wire anthropic, Codex injected resolve_active_key(OPENAI)), so routing such a connection to the "wrong" wire's agent would silently resolve a DIFFERENT connection's key — a credential mismatch. The fix decouples which agents a connection projects into from the wire its endpoint speaks, and resolves keys per CONNECTION.
- F1 —
compatible_agentson the connection. A connection carries an explicitcompatible_agents ⊆ {claude_code, codex}(null⇒ the wire default: anthropic →[claude_code], openai →[codex], ollama →[], unknown → both). The add/edit dialog pre-fills checkboxes from the wire but the user may override (route an openai gateway to Claude Code). JSON payload — no DB migration. - F2 — Projection writer chosen by AGENT type, not protocol. A connection compatible with
claude_codewrites Claude'ssettings.json(anthropic shape); withcodex, Codex'sconfig.toml.protocolnow drives only model introspection and whether a key is required. - F3 — Per-connection key resolution. Claude Code's projected
apiKeyHelperiscoffer provider key --connection <name>→GET /providers/{name}/key, so the agent always reads exactly the activated connection's key. Codex'sCOFFER_PROVIDER_KEYis the key of the connection active for Codex (resolve_active_key_for_agent(codex)). The legacy--wirehelper //active-key/{wire}stay for back-compat, resolving by the wire's agent. - F4 — Activation is per AGENT TYPE. Invariant: at most one active connection per agent type. Activating a connection projects it into all its compatible agents and takes those agents over from any previously-active connection (de-projecting it from agents the new one does not cover).
use-builtin/{wire}reverts the agent behind that wire; a connection compatible with multiple agents is reverted as a unit (the singleis_activeflag is all-or-nothing). - F5 — The connections page drops the per-row "switch." Activation is per-agent and lives on the Agent detail → Overview tab (which filters connections by
compatible_agents). The connections page is the library: add / edit / delete + show each connection's compatible agents.
Supersedes: E5 (protocol-match compatibility filter — now the explicit compatible_agents set); Decision A / FR-011's per-protocol single-active (now per-agent-type). Still NOT in scope: proxy / hot-switch / protocol conversion.
Amendment 2026-06-23c — Picking a connection is a draft; test then confirm to switch
Status: Draft. Recorded after the agnes connection was activated for Claude Code with no model bound. Cross-ref ADR-032.
Why. Under E3/E4 the model lives on the per-agent binding and activation only projects the endpoint + key. The Agent page activated a connection the instant it was picked, with no model bound — so a connection whose endpoint serves its own model ids (the agnes case: an openai gateway routed to Claude Code) left ANTHROPIC_MODEL unset, Claude Code kept sending its built-in claude-* ids to that endpoint, and every model — in both Coffer's slots and Claude Code's own /model picker — failed with "model may not exist or you may not have access." The user had no chance to choose a model the endpoint serves, and no signal that the switch (not their account) was the problem. Activating on a mere dropdown change also made an unverified endpoint the live config with one click.
- G1 — Picking a connection / model on the Agent page is a DRAFT. Selecting a connection or a model no longer activates or PATCHes anything; it stages a choice. Picking a non-built-in connection introspects its endpoint and stages a default model — Claude Code's primary + fast and Codex's single slot all default to the endpoint's FIRST returned model — so the user has something to test.
- G2 — «测试连接» before «确认切换». A custom connection must pass a test-connection probe (
POST /models/test-connectionwith the staged model) before it can be confirmed. Confirm is disabled until the test for the CURRENT draft passes; changing the connection or model resets the test result. «确认切换» PATCHes the per-agent binding (model + fast_model) then activates the connection — the only step that writes native config. Switching to the built-in login needs no test (no endpoint to reach) and confirms straight away.
Supersedes: E4's implicit "picking activates immediately" — activation is now gated behind an explicit test + confirm, and the binding is never left empty. Still NOT in scope: proxy / hot-switch / protocol conversion.
Scope
In scope
- Backend
providerresource Kind (CRUD via ResourceService → automatic audit- automatic sync); credential handling (store secret to Fernet vault, keep only ref); projection service (write native config for the matching agent); switch / activate operation;
PROVIDER_SWITCHEDaudit event; sync wiring (register the kind); key-resolution used by Claude'sapiKeyHelper.
- automatic sync); credential handling (store secret to Fernet vault, keep only ref); projection service (write native config for the matching agent); switch / activate operation;
- Internal-engine connection selection: the global
internal_defaultflag,set_internal_default(name)+resolve_internal_connection(), theprovider_internal_default_setaudit event, consumed by Coffer's internal LLM engine (memory organizer / reorg / distill /coffer__ask). - Retire the standalone
ModelConfigregistry (model CRUD REST +coffer modelCLI), folding internal-engine model selection into the connection. The provider introspection routes (list-models,test-connection) are KEPT. - CLI:
coffer provider list|add|show|edit|remove|switch|key|internal-default - HTTP API:
/api/v1/providers(list / create / get / patch / delete) plus/api/v1/providers/{name}/activateand/api/v1/providers/{name}/internal-default - Frontend: a minimal Providers resource page —
DataTable(name, wire format, base URL, model, active) with create / switch / delete actions, mirroring the Skills and MCP resource-page pattern. - Tests across all tiers; acceptance markers tying to the scenarios below; zh companion docs for every doc file in this spec bundle.
Out of scope (explicit non-goals)
- Hot-switch / running-process reload — deferred to a later PR.
- Explicit deactivate / native-config restore — no "revert to default" op; switching overwrites the relevant keys; restoration is a future concern.
- Provider drift-verify — spec item 4.9; separate spec.
- Per-agent provider override beyond wire matching — a profile whose
wire_formatdoes not match an agent is simply not projected to it; no manual per-agent binding. - Proxy / failover / format conversion — no proxying; no fallback chains; no anthropic↔openai protocol translation. Wire format is fixed per profile.
- Auto env-injection of
COFFER_PROVIDER_KEYinto Coffer-spawned Codex — deferred with hot-switch.
Entity — ProviderProfile (Kind = "provider")
Resource name = the profile name (unique within kind; validated by validate_name).
Config fields (synced config dict; deterministic, no machine-local ids)
| Field | Type | Notes |
|---|---|---|
wire_format | "anthropic" | "openai" | "ollama" | Required. Gates which agent this connection projects to. ollama is internal-only — projected to NO agent (target_for returns None). |
base_url | str | Required (all wires). The upstream LLM endpoint. |
credential_ref | str | None | Optional. Required for anthropic/openai (Fernet vault ref; pattern ^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*$; conventionally provider/<name>/key; multiple connections MAY share one ref). MUST be absent for ollama (no API key). |
model | str | Required. Primary model ID → ANTHROPIC_MODEL (Claude) / model (Codex); the model Coffer's internal engine runs when this is the internal default. |
fast_model | str | None | Optional. ANTHROPIC_SMALL_FAST_MODEL (anthropic wire only); ignored for openai. |
wire_api | "chat" | "responses" | Optional, default "chat". openai/Codex only ([model_providers.*].wire_api). |
is_active | bool | At most one active per wire_format. ollama is never projected, so an ollama connection is always inactive. On import, if >1 active for a wire, normalise deterministically (keep most-recently-updated). |
internal_default | bool | At most one connection globally is the internal-engine default. On import, if >1, normalise (keep most-recently-updated). |
audit_redactor: config holds NO secret (onlycredential_ref); audit shows config as-is. Double-check that no secret leaks viaconfigordetails.
Projection — Writing Native Config
Analogous to McpInjectionSpec in mcp_injection.py; encode as a small explicit table.
anthropic → Claude Code
File: ~/.claude/settings.json (JSON); resolved via spec_for(AgentType.CLAUDE_CODE, "settings", cfg_dir).
Coffer MANAGES exactly these keys by MERGING into the existing JSON (never full replace) and writing via ConfigFileStore.write_text_atomic (atomic + .bak):
| Key path | Value |
|---|---|
apiKeyHelper | "coffer provider key --wire anthropic" |
env.ANTHROPIC_BASE_URL | profile.base_url |
env.ANTHROPIC_MODEL | profile.model |
env.ANTHROPIC_SMALL_FAST_MODEL | profile.fast_model (omit / remove key when None) |
ANTHROPIC_API_KEY MUST NOT be written (it would override the helper). Everything else in settings.json is preserved; serialised via json.dumps(indent=2) like the MCP JSON path in mcp_entries.py.
openai → Codex
File: ~/.codex/config.toml (TOML); resolved via spec_for(AgentType.CODEX, "config", cfg_dir).
Coffer MANAGES via tomlkit (comment/order-preserving, like the MCP TOML path):
| Key path | Value |
|---|---|
model | profile.model |
model_provider | "coffer" |
[model_providers.coffer].name | "Coffer (<profile name>)" |
[model_providers.coffer].base_url | profile.base_url |
[model_providers.coffer].wire_api | profile.wire_api (default "chat") |
[model_providers.coffer].env_key | "COFFER_PROVIDER_KEY" |
Everything else preserved.
ollama → (internal only)
An ollama connection projects into NO agent config: target_for(WireFormat.ollama) returns None, so activation writes no native config and the connection is never is_active. It is used solely by Coffer's internal engine, reached via resolve_internal_connection when it is the internal_default.
Switch / Activate Operation
POST /api/v1/providers/{name}/activate / coffer provider switch <name>:
- Profile must exist; return 404 otherwise.
- Clear
is_activeon all other profiles of the samewire_formatviaResourceService.update_config, then set the target'sis_active=truevia a second call. The single-process daemon serialises requests so switches never interleave. - For each ENABLED registered agent whose
AgentTypenative wire matchesprofile.wire_format, project (write native config). If no matching agent is registered, record active but project nothing — not an error (report as skipped). - Emit
provider_switchedaudit event with details{from: <prev_name|null>, to: <name>, wire_format, agents: [...projected...]}. - Return
{activated: <name>, projected: [agent...], skipped: [agent...]}.
NOTE: projection (_project) runs BEFORE the activation flip; a native-config write failure aborts the switch with the registry unchanged.
Internal engine (Coffer's own LLM)
Separate from per-agent activation, the global internal_default flag (≤1 across all connections) selects the connection Coffer's own internal LLM engine uses — the memory organizer, reorg, distill, and coffer__ask.
set_internal_default(name): clearsinternal_defaulton all other connections, then sets it on the target (sequential clear-then-set, serialised by the single-process daemon so the global single-internal-default invariant holds), and emits aprovider_internal_default_setaudit event.resolve_internal_connection() -> ProviderConfig | None: returns theinternal_defaultconnection's config, orNonewhen no connection is marked. WhenNone, the internal engine (memory organizer / reorg / distill /coffer__ask) is a clean no-op rather than an error.build_chat_model(connection, ...): the internal engine builds its chat model from the resolved connection, dispatched bywire_format(anthropic / openai / ollama). This replaces the retiredModelConfigregistry's model selection.
A connection may be BOTH is_active (projected to its wire's agent) AND internal_default (used internally) — one key, two uses.
Key Resolution (apiKeyHelper + Codex env)
coffer provider key --wire <wire_format>:
- Find the active profile for the given wire format.
- Read
credential_ref→ decrypt viaEncryptedCredentialStore.get(ref). - Print the raw key to stdout ONLY. Do NOT log the value.
This is the command Claude Code's apiKeyHelper invokes (--wire anthropic). For Codex, the user exports: export COFFER_PROVIDER_KEY="$(coffer provider key --wire openai)".
Sync (reuse, ~zero engine change)
Modeling provider as a ResourceService Kind makes it sync automatically:
SyncExporterlists all kinds → serialises each row toresources/provider/<name>.yamlviaresource_to_doc.SyncImporterreconciles by(kind, name).- Credentials already sync as Fernet ciphertext at
credentials/<ref>.enc.
Touch points: define the Kind, add a wire_provider_kind(...) helper (mirror wire_kb_kind in surfaces/http/wiring.py), register into app.state.kinds in surfaces/http/app.py. No new migration, no manifest SCHEMA_VERSION bump.
Audit (reuse)
ResourceService create / update already emit RESOURCE_* events with kind-redacted config. Add PROVIDER_SWITCHED = "provider_switched" to AuditEventType (backend/coffer/domain/audit.py) and emit it from the switch operation via AuditService.record(AuditEventType.PROVIDER_SWITCHED.value, ref=ResourceRef( kind="provider", name=<name>), actor=..., details={...}). Likewise add PROVIDER_INTERNAL_DEFAULT_SET = "provider_internal_default_set" and emit it from set_internal_default.
HTTP API
Hand-written OpenAPI; 005-style — not contract-test-gated; sync manually. Full spec in contracts/api.openapi.yaml.
GET /api/v1/providers→ list profiles ({ "providers": [ ProviderOut, ... ] })POST /api/v1/providers→ create (see credential-source rules below)GET /api/v1/providers/{name}→ one profilePATCH /api/v1/providers/{name}→ update mutable fields (base_url,model,fast_model,wire_api,secret_value);wire_formatandcredential_refare immutable;secret_valuerotates the stored secretDELETE /api/v1/providers/{name}→ delete; guard viafind_credential_citationsbefore removing an owned secretPOST /api/v1/providers/{name}/activate→ switch; returns{activated, projected:[agent...], skipped:[agent...]}POST /api/v1/providers/{name}/internal-default→ set the internal-engine default; returns the updatedProviderOut
wire_format accepts anthropic, openai, or ollama on request and response.
Credential source rule: For anthropic/openai, exactly one of secret_value (stored to vault under provider/<name>/key, kept as ref) or credential_ref (reuse existing) must be supplied on create; reject if both or neither are present. For wire_format=ollama the credential is OPTIONAL — supply NEITHER secret_value nor credential_ref (an ollama connection has no key).
ProviderOut NEVER includes the secret; includes credential_ref, is_active, and internal_default.
CLI
coffer provider list|add|show|edit|remove|switch|key|internal-default with --json.
addprompts for / accepts the secret (skipped for an ollama connection, which has no key).keyprints the resolved secret (forapiKeyHelper); requires--wire <wire_format>; resolves by the active profile for that wire.internal-default <name>marks a connection as Coffer's internal-engine default (clears any previous one).
Frontend (minimal)
frontend/src/lib/api/providers.ts— hand-written client + TS types (types.tscodegen covers only the 001 gateway spec; do NOT expect generated types here).- A unified Settings → LLM Connections page (route
/settings/llm-connections) is the connection library: aDataTable(reuse the shared component; see SkillsPage / MCP page) with columns name / wire_format / base_url / model / active / internal, header action create, and row actions switch / set-internal-default / delete, PLUS the Embedding card. The page shows ONLY connection (provider + model) info — no agent names, no presets, no modality split. Editing a connection is available via the CLI (coffer provider edit) and the PATCH API, not the desktop page. - Per-agent connection + model selection lives on the agent detail page (Overview tab), filtered to that agent's wire, reusing the activate API. The LLM Connections page does not bind agents.
- The old
/settings/modelsand/settings/providersroutes redirect to/settings/llm-connections. - Add
vi.mockfor any new hook in page + table tests.
Amendment 2026-06-23 (provider presets). The add-connection form no longer auto-detects the
protocolfrom base_url + key. Instead it offers a provider preset picker (OpenAI / Anthropic / Google Gemini / DeepSeek / Moonshot / OpenRouter / Ollama) that fills the endpoint + protocol, plus a Custom option that reveals a manual protocol selector for any other OpenAI-/Anthropic- compatible endpoint. The stored data model is unchanged (protocolis still aProviderConfigfield); only how it is chosen at create time changed. Thedetect-protocolprobe endpoint stays for other callers but is no longer used by the form.
Acceptance Scenarios
Per agents/sdd.md, every scenario in this section is referenced by at least one test marked @pytest.mark.acceptance(spec="011-provider-switching", scenario="…") (Python) or acceptance("011-provider-switching", "…", …) (TypeScript).
Scenario: create an anthropic provider profile with an inline secret
- Given no provider named
my-providerexists, - When the user creates a profile with
wire_format="anthropic", abase_url,model, andsecret_value(the raw API key), - Then the profile is persisted with a
credential_refofprovider/my-provider/key, the raw key is stored in the Fernet vault under that ref,ProviderOutis returned with no secret field, andRESOURCE_CREATEDis audited.
Scenario: create a profile that reuses an existing credential ref
- Given a credential already exists under ref
shared/key, - When the user creates a profile supplying
credential_ref="shared/key"(nosecret_value), - Then the profile is persisted pointing to the existing ref, no new vault entry is created, and
ProviderOutreflects the suppliedcredential_ref.
Scenario: reject a profile with an unknown wire format
- Given the daemon is running,
- When the user attempts to create a profile with
wire_format="grpc", - Then the request is rejected with
422 Unprocessable Entityand no profile row is created.
Scenario: reject a profile that supplies neither a secret nor a credential ref
- Given the daemon is running,
- When the user attempts to create an anthropic connection (the neither-rule applies to anthropic/openai; ollama legitimately supplies neither) without supplying either
secret_valueorcredential_ref, - Then the request is rejected with
422 Unprocessable Entityand no profile row or vault entry is created.
Scenario: update a provider profile
- Given a provider profile exists,
- When the user patches
base_urlandmodel(nosecret_value), - Then only those fields are updated,
credential_refis unchanged, andRESOURCE_UPDATEDis audited.
Scenario: list provider profiles
- Given two provider profiles exist (one anthropic, one openai),
- When the user lists all providers,
- Then both appear in
ProviderOut[], none includes the raw secret, and each carries the correctis_activeflag.
Scenario: delete a provider profile cleans up its owned credential
- Given a profile whose
credential_refisprovider/my-provider/key(owned; no other profile shares it), - When the user deletes the profile,
- Then the vault entry at that ref is deleted and
RESOURCE_DELETEDis audited.
Scenario: activate an anthropic profile writes Claude Code settings
- Given a Claude Code agent is registered and an anthropic profile exists,
- When the user activates the profile,
- Then
~/.claude/settings.jsoncontainsapiKeyHelper,env.ANTHROPIC_BASE_URL, andenv.ANTHROPIC_MODEL; iffast_modelis set,env.ANTHROPIC_SMALL_FAST_MODELis present;ANTHROPIC_API_KEYis absent; and the profile'sis_activebecomestrue.
Scenario: an agent's model binding drives the projected model
- Given a Claude Code agent is registered with a per-agent model binding (
model+fast_model) and an anthropic connection exists, - When the user activates the connection,
- Then the projected
env.ANTHROPIC_MODEL/env.ANTHROPIC_SMALL_FAST_MODELcome from the AGENT's binding — the model lives at the point of use, not on the connection (amendment 2026-06-22b E1/E3/E4). An unbound agent gets no model env written, so it runs on its OWN default model.
Scenario: activate an openai profile writes Codex config
- Given a Codex agent is registered and an openai profile exists,
- When the user activates the profile,
- Then
~/.codex/config.tomlcontainsmodel,model_provider = "coffer", and a[model_providers.coffer]table withbase_url,wire_api, andenv_key = "COFFER_PROVIDER_KEY"; the profile'sis_activebecomestrue.
Scenario: activating a profile deactivates the previous active profile of the same wire format
- Given anthropic profile A is active and anthropic profile B exists,
- When the user activates profile B,
- Then profile B becomes active and profile A becomes inactive (the single-process daemon serialises the clear-then-set so switches never interleave).
Scenario: switch a wire back to the agent built-in login
- Given an anthropic connection is active and projected into Claude Code,
- When the user switches that wire back to built-in (
POST /providers/use-builtin/{wire}), - Then Coffer's managed keys are removed from the agent's native config so it falls back to its own login, and the connection is no longer active; the operation is idempotent (a no-op when nothing is active). See the ADR-032 amendment D1/D3 (connections are optional overrides).
Scenario: activate a profile whose wire matches no registered agent records active but projects nothing
- Given no Codex agent is registered and an openai profile exists,
- When the user activates the openai profile,
- Then the profile's
is_activebecomestrue, no config file is written, and the response carriesskipped: ["codex"](or emptyprojected).
Scenario: switching preserves unrelated native-config keys and writes a .bak backup
- Given
~/.claude/settings.jsoncontains keys that Coffer does not manage (e.g.theme,mcpServers), - When the user activates an anthropic profile,
- Then those keys are preserved byte-for-byte in the updated file, a
.bakfile is written before the update, and only the Coffer-managed keys are changed.
Scenario: a provider switch is recorded in the audit log
- Given an anthropic profile is activated,
- When the user queries the audit log,
- Then a
provider_switchedentry appears with details{from, to, wire_format, agents}, timestamp, and actor.
Scenario: resolve the active provider key for the apiKeyHelper
- Given an anthropic profile is active with a known secret stored in the vault,
- When
coffer provider key --wire anthropicis executed, - Then the raw key is printed to stdout and the vault key is NOT logged.
Scenario: a provider profile round-trips through sync export and import
- Given a provider profile with a credential ref exists,
- When the sync exporter runs followed by the sync importer on a clean DB,
- Then the profile row is restored with identical
configfields, the credential ciphertext is present atcredentials/<ref>.enc, and no secret is exposed in the sync workspace plaintext.
Scenario: the command line covers create, list, and switch
- Given the daemon is running,
- When the user runs
coffer provider add,coffer provider list --json, andcoffer provider switchfrom the CLI, - Then each operation succeeds with the same effect as the HTTP API and
list --jsonreturns machine-readable output.
Scenario: the connections page lists profiles and their compatible agents
- Given the connections page is rendered with two mock connections (each with a
compatible_agentsset), - When the page renders,
- Then it lists both connections with their compatible-agent chips and shows NO per-row "Switch" action — activation is per-agent on the Agent Overview tab (TypeScript acceptance test).
Scenario: route an openai-compatible connection to Claude Code via compatible_agents
- Given a Claude Code agent is registered and an
openai-wire connection is created withcompatible_agents = ["claude_code"](the agnes case), - When the user activates that connection,
- Then it projects into Claude Code's
settings.json(anthropic shape) withapiKeyHelper = "coffer provider key --connection <name>", andGET /providers/{name}/keyreturns exactly that connection's key.
Scenario: test or fetch models with an inline unsaved secret
- Given the connection dialog is open and no connection (nor its credential ref) has been saved yet,
- When the user types a raw API key and triggers «测试连接» or «拉取模型» (
POST /models/test-connection/POST /models/list-modelscarryingsecret_valueand nocredential_ref), - Then the introspection service passes the inline key straight to the provider without consulting the credential vault, the probe succeeds, and the fetched models populate the selectable dropdown (per ADR-032 amendment D6).
Scenario: create an ollama connection without a credential
- Given no connection named
local-llmexists, - When the user creates a connection with
wire_format="ollama", abase_url,model, and neithersecret_valuenorcredential_ref, - Then the connection persists with
credential_refnull, no vault entry is created, andProviderOutshowsinternal_default=false.
Scenario: set a connection as the internal engine default
- Given two connections exist and none is the internal default,
- When the user sets the second as the internal default,
- Then its
internal_defaultbecomes true, the other stays false, and aprovider_internal_default_setaudit entry is recorded.
Scenario: setting a new internal default clears the previous one
- Given connection A is the internal default,
- When the user sets connection B as the internal default,
- Then B's
internal_defaultbecomes true and A's becomes false (global single-internal-default invariant).
Scenario: choose the model the internal engine runs on
- Given a connection is the internal default,
- When the operator sets a model on the global internal-engine config (
PUT /api/v1/internal-engine-config), - Then
GET /api/v1/internal-engine-configreturns that model, aninternal_engine_model_setaudit entry is recorded, andresolve_internal_connection()overlays the chosen model onto the resolved internal-default connection (the model lives apart from the connection, per the amendment below).
Scenario: the chat model picker offers a fixed list without free-form entry
- Given a conversation bound to an agent that has no overriding connection,
- When the model picker is opened,
- Then it offers a fixed dropdown of the agent's built-in models (no free-text "Custom…" entry); and when a connection overrides the agent the dropdown instead lists that connection's introspected models, never reading the connection's stored
modelfield (TypeScript acceptance test).
Requirements
Functional Requirements
Resource model
- FR-001: System MUST register each managed provider as a Resource of kind
provider, identified byprovider:<name>. - FR-002: System MUST validate provider config against a kind-specific schema (fields:
wire_format,base_url,credential_ref,model,fast_model?,wire_api?,is_active). - FR-003:
ProviderOutMUST NEVER include the raw secret.credential_refandis_activeMUST be included.
Credential handling
- FR-004: On create with
secret_value, System MUST store the raw key underprovider/<name>/keyin the Fernet vault and persist only the ref. Exactly one ofsecret_valueorcredential_refmust be supplied; both or neither must be rejected422. - FR-005: On
PATCHwithsecret_value, System MUST rotate the stored secret (overwrite the vault entry) without changing the ref. - FR-006: On delete, if the profile owns its credential ref (no other profile cites it), System MUST delete the vault entry via
find_credential_citationsguard.
Projection
- FR-007: System MUST project an activated anthropic profile into
~/.claude/settings.jsonviaConfigFileStore.write_text_atomic(atomic +.bak), merging only the specified keys, preserving everything else.ANTHROPIC_API_KEYMUST NOT be written. - FR-008: System MUST project an activated openai profile into
~/.codex/config.tomlviatomlkit(comment/order-preserving), merging only the specified keys, preserving everything else. - FR-009: If
fast_modelisNone, the keyenv.ANTHROPIC_SMALL_FAST_MODELMUST be omitted or removed fromsettings.json. - FR-010: Domain projection logic MUST be pure (no I/O). The pure functions
apply_anthropic_settings(...)andapply_codex_provider(...)indomain/provider/projection.pyreturn the new native-config TEXT directly;ProviderService._project(...)calls them and performs the file write.
Single-active invariant
- FR-011: At most one profile per
wire_formatmay haveis_active=true. Activating a profile MUST clearis_activeon all others of the same wire via sequentialResourceService.update_configcalls, then set the target'sis_active=true. The single-process daemon serialises requests so switches never interleave. On import with >1 active for a wire, normalise: keep most-recently-updated, set the rest to inactive.
Switch operation
- FR-012:
POST /api/v1/providers/{name}/activateMUST apply FR-011, then project to all ENABLED registered agents whose native wire matcheswire_format. If no matching agent is registered, record active and return a non-emptyskippedlist — NOT an error. - FR-013: System MUST emit audit event with value
"provider_switched"and details{from, to, wire_format, agents: [...projected...]}.
Key resolution
- FR-014:
coffer provider key --wire <wire_format>MUST find the active profile for that wire, decrypt viaEncryptedCredentialStore.get(ref), and print to stdout only. The raw key MUST NOT be logged. Resolution by profile<name>is NOT supported on this subcommand; use--wireonly.
Sync
- FR-015: The
providerkind MUST be registered intoapp.state.kindssoSyncExporter/SyncImporterhandle it automatically. No new migration or SCHEMA_VERSION bump is needed.
Audit
- FR-016:
PROVIDER_SWITCHED(value"provider_switched") MUST be added toAuditEventTypeand emitted on every successful switch with{from, to, wire_format, agents}in details.RESOURCE_CREATED,RESOURCE_UPDATED,RESOURCE_DELETEDare emitted automatically viaResourceService.
Surfaces
- FR-017: Create, switch, and delete operations MUST be available via (a) the REST API, (b)
coffer provider ...CLI with--json, and (c) the desktop Providers page. Editing a profile (PATCH) is available via the REST API and the CLI (coffer provider edit) only; the desktop page does NOT require an inline edit affordance. - FR-018: The CLI
keysubcommand MUST support--wire <wire_format>to resolve by the active profile for that wire. Resolution by positional<name>is NOT supported;--wireis the only accepted form.
Internal engine connection
- FR-019: The
ollamawire is internal-only and projects to NO agent:target_for(WireFormat.ollama)MUST returnNone, an ollama connection is neveris_active, and activating it writes no native config. - FR-020:
credential_refMUST be optional — required for anthropic/openai, absent for ollama. On create, supplying neithersecret_valuenorcredential_refis valid ONLY forwire_format=ollama; for anthropic/openai the exactly-one rule of FR-004 stands. - FR-021: At most one connection globally MUST have
internal_default=true.set_internal_defaultMUST clearinternal_defaulton all others, then set the target (sequential clear-then-set serialised by the single-process daemon). On import with >1 internal default, normalise: keep most-recently-updated, clear the rest. - FR-022:
POST /api/v1/providers/{name}/internal-defaultMUST set the named connection as the internal-engine default (applying FR-021), emit aprovider_internal_default_setaudit event, and return the updatedProviderOut. - FR-023:
resolve_internal_connection()MUST return theinternal_defaultconnection'sProviderConfig, orNonewhen no connection is marked. WhenNone, the internal engine (memory organizer / reorg / distill /coffer__ask) MUST be a clean no-op rather than an error. - FR-024: The standalone
ModelConfigregistry (model CRUD REST +coffer modelCLI) MUST be retired. The internal engine MUST build its chat model from the internal-default connection viabuild_chat_model(connection, ...)(dispatched bywire_format). The provider introspection routes (POST /api/v1/models/list-models,/api/v1/models/test-connection) MUST be retained.
Key Entities
- ProviderProfile: A Resource of kind
provider, identified byprovider:<name>. Holds wire format, base URL, optional credential ref (absent for ollama), model(s), per-wireis_activestate, and the globalinternal_defaultflag. Never holds the raw secret. apply_anthropic_settings/apply_codex_provider: Pure functions indomain/provider/projection.pythat return the new native-config TEXT directly. Analogous todomain/agent/mcp_install.py'sapply_install. NoProjectionPatchdataclass; nobuild_patch()function.ProviderService._project: Private method inapplication/provider/service.pythat calls the pure projection functions and performs the file write.ProjectionTarget/target_for(wire): Helper indomain/provider/projection.pymappingwire_formatto the target config file descriptor; returnsNoneforollama(internal-only, no projection).ProviderService.resolve_active_key(wire): Takes awire_formatstring only; no by-name resolution on this method.ProviderService.set_internal_default(name): Clearsinternal_defaulton all other connections then sets the target; emitsprovider_internal_default_set.ProviderService.resolve_internal_connection(): Returns the internal-default connection'sProviderConfig, orNone(→ the internal engine is a clean no-op).build_chat_model(connection, ...)builds the internal engine's chat model from it, dispatched bywire_format.
Success Criteria
- SC-001: From a fresh install, a user can add an anthropic provider profile, activate it, and have Claude Code pick up the new endpoint within one
coffer provider switchcommand. - SC-002: No raw key ever appears in
settings.json,config.toml, or the sync workspace (resources/provider/*.yaml) — verified by an automated scan in integration tests. - SC-003: Every Acceptance Scenario is covered by at least one test marked
acceptance(spec="011-provider-switching", scenario="…"), andmake verify-acceptancereports zero uncovered scenarios. - SC-004:
make verifypasses locally and in CI. - SC-005: Activating a profile writes the target native-config key set and does NOT touch any key outside the defined managed set.
- SC-006: With an
internal_defaultconnection configured, Coffer's internal engine (memory organize / reorg / distill /coffer__ask) runs on it; with no connection markedinternal_default, the internal engine is a clean no-op.
Assumptions
- Spec 004-agent-registry (PR #25) is merged;
AgentType,AgentConfig, and the agent CRUD +on_deletehook are available. EncryptedCredentialStore(Fernet vault) andConfigFileStore.write_text_atomicare available (spec 001).tomlkitis already in the backend's Python dependencies (added by MCP TOML path support).- Coffer runs as a single-user personal tool; no multi-user access control is needed beyond the existing
X-Coffer-Tokengate. - The user's
~/.claude/settings.jsonand~/.codex/config.tomlare writable by Coffer. If the file does not exist, Coffer creates it with only the managed keys. - Provider drift-verify (checking whether the live native config matches the active profile) is deferred to spec 4.9.