Skip to content

Data Model — 011 Provider Switching

Entities, fields, and reuse anchors for the provider registry. Depends on the agent kind from spec 004 and the kind-agnostic Resource framework from spec 001.

Domain entities (backend/coffer/domain/provider/)

ProviderConfig (domain/provider/config.py)

Pydantic v2 BaseModel. This is the synced config dict stored on the Resource row. It MUST NOT hold the raw secret.

Amendment 2026-06-22b (E1–E2): the connection is a credentialed endpoint. model, fast_model, and wire_api are REMOVED; the manual wire_format becomes a DETECTED protocol. The model lives at the point of use (Agent binding / internal-default / chat), not on the connection.

Amendment 2026-06-23 (F1, F4): a compatible_agents: list[str] | None field (AgentType values; null ⇒ the wire default) decides which agents the connection projects into, decoupled from protocol. is_active is now scoped PER AGENT TYPE (≤1 active connection per agent), not per protocol. The field is held as plain strings so this domain module stays free of the agent kind (the application layer hydrates them into AgentType at the projection seam).

FieldTypeConstraints / Notes
protocolProtocolDETECTED, not user-entered; "anthropic", "openai", "ollama", or "unknown". Probed from the endpoint on create/edit; a compatibility hint, not a projection gate. ollama is internal-only. unknown ⇒ offered to all agents (user decides).
base_urlstrRequired; upstream LLM endpoint URL.
credential_refstr | NoneOptional; required for anthropic/openai (Fernet vault ref matching ^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*$); absent for ollama (no API key).
is_activeboolAt most one True per protocol at any time (FR-011); always False for ollama.
internal_defaultboolAt most one True globally (FR-021); the connection Coffer's internal engine uses (its MODEL is chosen by the internal-default selector, not stored here). On import, if >1, normalise (keep most-recently-updated).

Model selection is NOT on the connection. The model(s) projected for an agent come from that agent's binding (Agent page dual slots → ANTHROPIC_MODEL + ANTHROPIC_SMALL_FAST_MODEL); the Codex wire_api likewise moves to the Codex binding. Migration (option A): existing connections drop model/fast_model/ wire_api; wire_format is re-read as protocol (or unknown pending a probe); previously-configured models are NOT carried — users re-select on the Agent page.

All fields are JSON-stable (no Python objects) so model_dump(mode="json") serialises cleanly for SQLite and sync export.

Protocol (domain/provider/config.py)

A single StrEnum lives in config.py alongside ProviderConfig. There is no separate wire.py module. The old WireApi enum is gone — wire_api left the connection (it is a Codex-binding concern; projection defaults it to responses).

python
class Protocol(StrEnum):
    ANTHROPIC = "anthropic"
    OPENAI = "openai"
    OLLAMA = "ollama"
    UNKNOWN = "unknown"

ResolvedConnection (domain/provider/config.py)

A frozen dataclass pairing a ProviderConfig with the model to run on it — since the model lives apart from the connection (E3), the two travel together when the internal engine builds a chat model. resolve_internal_connection() returns it (or None); build_chat_model(resolved, resolver) consumes it.

Projection functions (domain/provider/projection.py)

Pure (I/O-free) functions that return the new native-config TEXT directly, analogous to domain/agent/mcp_install.py's apply_install. There is NO ProjectionPatch dataclass and NO build_patch() function.

  • apply_anthropic_settings(config: ProviderConfig, existing_text: str) -> str
  • apply_codex_provider(config: ProviderConfig, profile_name: str, existing_text: str) -> str
  • ProjectionTarget — descriptor for the target config file
  • target_for(wire: Protocol) -> ProjectionTarget | None — returns None for ollama (internal-only; no agent projection)
  • Constants: CODEX_PROVIDER_ID, CODEX_ENV_KEY, ANTHROPIC_API_KEY_HELPER

Internal engine (application/provider/service.py + infrastructure/chat/)

The internal-engine connection is selected by the global internal_default flag:

  • ProviderService.set_internal_default(name) — clears internal_default on all other connections, then sets the target (sequential clear-then-set, serialised by the single-process daemon); emits provider_internal_default_set.
  • ProviderService.resolve_internal_connection() -> ResolvedConnection | None — pairs the internal_default connection with the global internal-engine model (below). None when no connection is marked OR no model is set (the model lives apart from the connection — there is no fallback to a connection model).
  • build_chat_model(resolved, ...) consumes the ResolvedConnection, dispatched by protocol (anthropic / openai / ollama), to build the internal engine's chat model — replacing the retired ModelConfig registry's model selection.

GlobalInternalEngineConfig (domain/internal_engine_config.py)

The internal-engine MODEL is a separate global singleton (one row, fixed SINGLETON_ID = 1), decoupled from the connection:

FieldTypeNotes
modelstr | NoneThe model the internal engine runs on; None until chosen.
updated_atdatetimeLast write.
  • InternalEngineConfigService.get() returns the row or an unset default (model=None); .update(model=...) trims/normalises empty → None, persists, and emits an internal_engine_model_set audit event.
  • Stored via SqlAlchemyInternalEngineConfigRepo (table internal_engine_config, alembic 0039); exposed over GET/PUT /api/v1/internal-engine-config.

Managed native-config keys per wire_format

The pure functions in domain/provider/projection.py (apply_anthropic_settings / apply_codex_provider) write exactly the keys below into the agent's native config; ProjectionTarget + target_for(wire) map each wire_format to its agent, allowlist key, and file format. The projection tests assert on these managed keys so the spec and implementation stay consistent.

anthropic keys managed:

Managed key pathSource
apiKeyHelperliteral "coffer provider key --wire anthropic"
env.ANTHROPIC_BASE_URLprofile.base_url
env.ANTHROPIC_MODELprofile.model
env.ANTHROPIC_SMALL_FAST_MODELprofile.fast_model (omit when None)

openai keys managed:

Managed key pathSource
modelprofile.model
model_providerliteral "coffer"
model_providers.coffer.namef"Coffer ({profile_name})"
model_providers.coffer.base_urlprofile.base_url
model_providers.coffer.wire_apiprofile.wire_api
model_providers.coffer.env_keyliteral "COFFER_PROVIDER_KEY"

Reuse anchors

All implementation MUST reuse these existing components; do not re-implement.

Fernet vault (credential isolation)

ComponentPathUsed for
EncryptedCredentialStorebackend/coffer/infrastructure/credentials/encrypted_store.pyget/set/exists/delete — store and retrieve raw secrets
credential resolverbackend/coffer/application/credentials/resolver.pyresolve a ref to plaintext (key resolution)
HTTP adopt patternbackend/coffer/application/agent/mcp_entry_service.py:303model for "store secret, keep only ref" on create
citation guardResourceService.find_credential_citationsguard before deleting an owned secret

Config-file store (native config write)

ComponentPathUsed for
ConfigFileStore.write_text_atomicbackend/coffer/infrastructure/agent/config_file_store.pyatomic write + .bak backup
spec_for / config_files_forbackend/coffer/domain/agent/config_files.pyresolve the canonical path for a given AgentType + key
AgentType descriptorsbackend/coffer/domain/agent/descriptor.pyclaude_code settings~/.claude/settings.json; codex config~/.codex/config.toml

Projection template (MCP injection)

ComponentPathUsed for
apply_installbackend/coffer/domain/agent/mcp_install.pystructural template for pure projection functions that return TEXT
mcp_entries.pybackend/coffer/domain/agent/mcp_entries.pyJSON via json.dumps; TOML via tomlkit — reuse both
MCP servicebackend/coffer/application/agent/mcp_service.pydriver pattern to mirror

Resource Kind pattern

ComponentPathUsed for
Kind dataclassbackend/coffer/domain/resource.pydefine the provider Kind
kind factorybackend/coffer/application/knowledge_base/kind.pymodel for make_provider_kind(...)
wire_kb_kindbackend/coffer/surfaces/http/wiring.pymodel for wire_provider_kind(...)
composition rootbackend/coffer/surfaces/http/app.pywhere to register app.state.kinds["provider"]

Single-active invariant

The activation flip uses sequential ResourceService.update_config calls (clear others, then set target); the single-process daemon serialises requests so switches never interleave. There is NO ProviderRepo / activate_atomic.

ComponentPathUsed for
ModelConfig domainbackend/coffer/domain/chat/model.pyper-wire single-active pattern (mirror, do NOT couple)
ResourceService.update_configexisting resource serviceused directly by ProviderService.activate()

Sync

ComponentPathUsed for
ResourceDoc / resource_to_docbackend/coffer/domain/sync/serialization.pyserialise provider rows
SyncExporterbackend/coffer/application/sync/exporter.pylists all kinds (automatic)
SyncImporterbackend/coffer/application/sync/importer.pyreconciles by (kind, name) (automatic)

Audit

ComponentPathUsed for
AuditEventTypebackend/coffer/domain/audit.pyadd PROVIDER_SWITCHED
AuditEntrybackend/coffer/domain/audit.pyevent shape
AuditService.recordbackend/coffer/application/audit_service.pyemit PROVIDER_SWITCHED from switch op

SQLite schema changes

The provider kind reuses the shared resources table (rows with kind='provider'); the ProviderConfig dict is stored in the existing resources.config JSON column — no new tables. The slim-down is a DATA migration: 0040 rewrites each provider row's config_json (rename wire_formatprotocol; strip model/fast_model/wire_api).

Audit events added

Add to AuditEventType in backend/coffer/domain/audit.py:

ValueWhen emitted
provider_switchedSuccessful POST /providers/{name}/activate; details: {from, to, protocol, agents: [...projected...]}
provider_internal_default_setSuccessful POST /providers/{name}/internal-default; details: {from, to} (previous internal-default name or null, and the new one)

RESOURCE_CREATED, RESOURCE_UPDATED, RESOURCE_DELETED are emitted automatically by ResourceService on CRUD operations (no new event types needed for those).

Application service contracts (backend/coffer/application/provider/)

ProviderService (application/provider/service.py)

MethodPurpose
create(name, config, secret_value?, credential_ref?, actor) -> ResourceValidate; store secret if secret_value supplied; register Resource. Reject if both/neither credential source given.
update(name, patch, secret_value?, actor) -> ResourcePartial update; rotate vault entry if secret_value supplied.
delete(name, actor) -> NoneGuard owned credential via find_credential_citations; remove vault entry if owned; delete Resource.
activate(name, actor) -> ActivateResultSequential clear-then-set for single-active invariant; project to matching registered agents; emit PROVIDER_SWITCHED.
resolve_active_key(wire: WireFormat) -> strFind active profile for the given wire format; decrypt and return key (stdout only; caller must not log). No by-name resolution.
set_internal_default(name, actor) -> ResourceClear internal_default on all other connections then set the target (global single-internal-default invariant, FR-021); emit PROVIDER_INTERNAL_DEFAULT_SET.
resolve_internal_connection() -> ProviderConfig | NoneReturn the internal_default connection's config, or None (→ the internal engine — memory organizer / reorg / distill / coffer__ask — is a clean no-op).

ActivateResult (application/provider/service.py)

python
@dataclass
class ActivateResult:
    activated: str
    projected: list[str]   # agent names written
    skipped: list[str]     # agent names not matching or not registered

ProviderService._project (application/provider/service.py)

Projection is an inlined private method on ProviderService; there is NO separate application/provider/projector.py / ProviderProjector class.

_project(profile_name, config, agent_config_dir) calls apply_anthropic_settings(...) or apply_codex_provider(...) (pure domain functions returning TEXT), then writes via ConfigFileStore.write_text_atomic.

There is NO infrastructure/provider/persistence.py and NO ProviderRepo. A provider profile is a plain resource row managed by the existing ResourceService (CRUD + audit + sync come for free).

On-disk / sync layout

No new directories. Provider profiles land in the existing sync workspace:

~/.coffer/sync/
  resources/
    provider/
      <name>.yaml      # one deterministic YAML per profile (no secret)
  credentials/
    provider/
      <name>/
        key.enc        # Fernet ciphertext of the raw API key

No new ~/.coffer/ subdirectory for providers (unlike skills which have a master content store). The only on-disk side-effects are the native config files written by projection (~/.claude/settings.json, ~/.codex/config.toml) and their .bak backups.

Constraints summary

  • ProviderConfig MUST NOT include the raw secret at any time.
  • Domain projection functions (apply_anthropic_settings, apply_codex_provider) MUST be pure (no I/O); they return the new native-config TEXT.
  • Key resolution MUST NOT log the decrypted value.
  • The per-wire single-active invariant is enforced via sequential ResourceService.update_config calls serialised by the single-process daemon.
  • All HTTP routes are loopback-only, gated by X-Coffer-Token.