Skip to content

数据模型——011 Provider Switching

English: data-model.md

provider 注册表的实体、字段与复用锚点。 依赖 spec 004 的 agent kind 和 spec 001 的 kind-agnostic Resource 框架。

域实体(backend/coffer/domain/provider/

ProviderConfigdomain/provider/config.py

Pydantic v2 BaseModel。这是存储在 Resource 行上的同步 config 字典。绝不持有原始 secret。

修订 2026-06-22b(E1–E2):连接是「带凭据的 endpoint」。modelfast_modelwire_api 移除;手动 wire_format 改为探测出的 protocol。模型在使用处 (Agent 绑定 / 内部默认 / 聊天)现选,不在连接上。

修订 2026-06-23(F1、F4):新增 compatible_agents: list[str] | None 字段 (AgentType 值;null ⇒ wire 默认),决定连接投射到哪些 agent,与 protocol 解耦。 is_active 现按 agent 类型作用域(每个 agent 至多一条激活连接),不再按 protocol。该字段 以纯字符串持有,使本领域模块不依赖 agent kind(由应用层在投影处再 hydrate 成 AgentType)。

字段类型约束 / 说明
protocolProtocol探测得出、非用户输入;"anthropic""openai""ollama""unknown"。create/edit 时探测 endpoint 得出;只是兼容性提示,不是投射门控。ollama 仅供内部。unknown ⇒ 对所有 agent 显示(用户决定)。
base_urlstr必填;上游 LLM endpoint URL。
credential_refstr | None可选;anthropic/openai 必填(Fernet vault ref,格式 ^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*$);ollama 不存在(无 API key)。
is_activebool同一 protocol 下最多一条为 True(FR-011);ollama 始终为 False
internal_defaultbool全局最多一条为 True(FR-021);Coffer 内部引擎使用的 connection(其模型由内部默认选择器选,不存这里)。导入时若 >1,则归一化(保留最近更新的)。

模型选择不在连接上。投射给某 agent 的模型来自该 agent 的绑定(Agent 页双槽 → ANTHROPIC_MODEL + ANTHROPIC_SMALL_FAST_MODEL);Codex 的 wire_api 也移到 Codex 绑定。 迁移(方案 A):现有连接丢掉 model/fast_model/wire_apiwire_format 重读为 protocol(或 unknown 待探测);原先配的模型不带走——用户在 Agent 页重选。

所有字段均 JSON 稳定(无 Python 对象),model_dump(mode="json") 可干净序列化,适用于 SQLite 和 sync export。

Protocoldomain/provider/config.py

单个 StrEnumProviderConfig 同在 config.py,没有独立 wire.py 模块。旧的 WireApi 枚举已移除——wire_api 离开连接(属 Codex 绑定关注点;投影默认 responses)。

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

ResolvedConnectiondomain/provider/config.py

一个 frozen dataclass,把 ProviderConfig 与要运行的 model 配对——因为模型独立于 连接(E3),二者在内部引擎构建 chat model 时一起传递。resolve_internal_connection() 返回它(或 None);build_chat_model(resolved, resolver) 消费它。

投影函数(domain/provider/projection.py

纯粹(无 I/O)函数,直接返回新的原生配置文本,类比 domain/agent/mcp_install.py 中的 apply_install不存在 ProjectionPatch 数据类,也不存在 build_patch() 函数。

  • apply_anthropic_settings(config: ProviderConfig, existing_text: str) -> str
  • apply_codex_provider(config: ProviderConfig, profile_name: str, existing_text: str) -> str
  • ProjectionTarget——目标配置文件描述符
  • target_for(wire: Protocol) -> ProjectionTarget | None——对 ollama 返回 None(仅内部;无 agent 投影)
  • 常量:CODEX_PROVIDER_IDCODEX_ENV_KEYANTHROPIC_API_KEY_HELPER

内部引擎(application/provider/service.py + infrastructure/chat/

内部引擎 connection 由全局 internal_default 标志选择:

  • ProviderService.set_internal_default(name)——清除所有其他 connection 的 internal_default,再设置目标(顺序 clear-then-set,由单进程 daemon 串行化);发出 provider_internal_default_set
  • ProviderService.resolve_internal_connection() -> ResolvedConnection | None——把 internal_default connection 与全局内部引擎模型(见下)配对。无 connection 被标记、或未设模型时返回 None(模型独立于 connection,不再回退连接的 model)。
  • build_chat_model(resolved, ...) 消费 ResolvedConnection,按 protocol(anthropic / openai / ollama)分派,构建内部引擎的 chat model——取代已退役 ModelConfig 注册表的模型选择。

GlobalInternalEngineConfigdomain/internal_engine_config.py

内部引擎模型是独立的全局单例(一行,固定 SINGLETON_ID = 1),与 connection 解耦:

字段类型说明
modelstr | None内部引擎运行的模型;未选时为 None
updated_atdatetime最后写入时间。
  • InternalEngineConfigService.get() 返回该行或未设置默认值(model=None);.update(model=...) 去空白、空→None、持久化,并发出 internal_engine_model_set 审计事件。
  • SqlAlchemyInternalEngineConfigRepo(表 internal_engine_config,alembic 0039)存储;经 GET/PUT /api/v1/internal-engine-config 暴露。

每种 protocol 的托管原生配置键

domain/provider/projection.py 中的纯函数(apply_anthropic_settings / apply_codex_provider)将下列键写入 agent 的原生配置;ProjectionTarget + target_for(wire) 将每种 protocol 映射到其 agent、allowlist key 与文件格式。投影测试断言这些托管键,确保规范与实现始终一致。

anthropic 托管键:

托管键路径来源
apiKeyHelper字面量 "coffer provider key --wire anthropic"
env.ANTHROPIC_BASE_URLprofile.base_url
env.ANTHROPIC_MODELprofile.model
env.ANTHROPIC_SMALL_FAST_MODELprofile.fast_model(为 None 时省略)

openai 托管键:

托管键路径来源
modelprofile.model
model_provider字面量 "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_key字面量 "COFFER_PROVIDER_KEY"

复用锚点

所有实现必须复用以下现有组件,不得重新实现。

Fernet vault(凭证隔离)

组件路径用途
EncryptedCredentialStorebackend/coffer/infrastructure/credentials/encrypted_store.pyget/set/exists/delete——存取原始 secret
credential resolverbackend/coffer/application/credentials/resolver.py将 ref 解析为明文(密钥解析)
HTTP adopt 模式backend/coffer/application/agent/mcp_entry_service.py:303"存储 secret,只保留 ref"创建模式
citation 守卫ResourceService.find_credential_citations删除自有 secret 前的守卫

Config-file store(原生配置写入)

组件路径用途
ConfigFileStore.write_text_atomicbackend/coffer/infrastructure/agent/config_file_store.py原子写 + .bak 备份
spec_for / config_files_forbackend/coffer/domain/agent/config_files.py为给定 AgentType + key 解析规范路径
AgentType descriptorsbackend/coffer/domain/agent/descriptor.pyclaude_code settings~/.claude/settings.jsoncodex config~/.codex/config.toml

投影模板(MCP 注入)

组件路径用途
apply_installbackend/coffer/domain/agent/mcp_install.py返回文本的纯投影函数结构模板
mcp_entries.pybackend/coffer/domain/agent/mcp_entries.pyJSON 通过 json.dumps;TOML 通过 tomlkit——均复用
MCP servicebackend/coffer/application/agent/mcp_service.py驱动模式参考

Resource Kind 模式

组件路径用途
Kind dataclassbackend/coffer/domain/resource.py定义 provider Kind
kind factorybackend/coffer/application/knowledge_base/kind.pymake_provider_kind(...) 的模板
wire_kb_kindbackend/coffer/surfaces/http/wiring.pywire_provider_kind(...) 的模板
composition rootbackend/coffer/surfaces/http/app.py注册 app.state.kinds["provider"] 的位置

单活跃不变量

激活翻转通过顺序的 ResourceService.update_config 调用实现(先清除其他,再设置目标);单进程 daemon 对请求串行化,切换操作不会交错。不存在 ProviderRepo / activate_atomic

组件路径用途
ModelConfigbackend/coffer/domain/chat/model.py每 wire 单活跃模式(镜像,不耦合)
ResourceService.update_config现有 resource serviceProviderService.activate() 直接使用

Sync

组件路径用途
ResourceDoc / resource_to_docbackend/coffer/domain/sync/serialization.py序列化 provider 行
SyncExporterbackend/coffer/application/sync/exporter.py列出所有 kind(自动)
SyncImporterbackend/coffer/application/sync/importer.py(kind, name) reconcile(自动)

审计

组件路径用途
AuditEventTypebackend/coffer/domain/audit.py添加 PROVIDER_SWITCHED
AuditEntrybackend/coffer/domain/audit.py事件形状
AuditService.recordbackend/coffer/application/audit_service.py从切换操作发出 PROVIDER_SWITCHED

SQLite schema 变更

provider kind 复用共享的 resources 表(行 kind='provider');ProviderConfig 字典存储在现有 resources.config JSON 列——无新表。瘦身是一次数据迁移0040 改写每条 provider 行的 config_jsonwire_formatprotocol;剥 model/fast_model/wire_api)。

无新表;无 SCHEMA_VERSION bump。

新增审计事件

backend/coffer/domain/audit.pyAuditEventType 中添加:

发出时机
provider_switchedPOST /providers/{name}/activate 成功;details:{from, to, protocol, agents: [...projected...]}
provider_internal_default_setPOST /providers/{name}/internal-default 成功;details:{from, to}(先前内部默认名称或 null,及新的)

RESOURCE_CREATEDRESOURCE_UPDATEDRESOURCE_DELETEDResourceService 在 CRUD 操作时自动发出(无需新增)。

应用服务契约(backend/coffer/application/provider/

ProviderServiceapplication/provider/service.py

方法用途
create(name, config, secret_value?, credential_ref?, actor) -> Resource校验;若提供 secret_value 则存储 secret;注册 Resource。两者都提供或都没有时拒绝。
update(name, patch, secret_value?, actor) -> Resource部分更新;若提供 secret_value 则轮换 vault 条目。
delete(name, actor) -> None通过 find_credential_citations 守卫自有凭证;若自有则删除 vault 条目;删除 Resource。
activate(name, actor) -> ActivateResult顺序 clear-then-set 实现单活跃不变量;向匹配的已注册 agent 投影;发出 PROVIDER_SWITCHED
resolve_active_key(wire: WireFormat) -> str找到给定 wire format 的活跃 profile;解密并返回 key(仅 stdout;调用方不得记录)。不支持按名称解析。
set_internal_default(name, actor) -> Resource清除所有其他 connection 的 internal_default,再设置目标(全局单一内部默认不变量,FR-021);发出 PROVIDER_INTERNAL_DEFAULT_SET
resolve_internal_connection() -> ProviderConfig | None返回 internal_default connection 的 config,或 None(→ 内部引擎——memory organizer / reorg / distill / coffer__ask——是干净的 no-op)。

ActivateResultapplication/provider/service.py

python
@dataclass
class ActivateResult:
    activated: str
    projected: list[str]   # 已写入的 agent 名称
    skipped: list[str]     # 不匹配或未注册的 agent 名称

ProviderService._projectapplication/provider/service.py

投影作为 ProviderService 上的内联私有方法实现;不存在独立的 application/provider/projector.py / ProviderProjector 类。

_project(profile_name, config, agent_config_dir) 调用 apply_anthropic_settings(...)apply_codex_provider(...)(纯域函数,返回文本),然后通过 ConfigFileStore.write_text_atomic 写入。

不存在 infrastructure/provider/persistence.py,也不存在 ProviderRepo。provider profile 是由现有 ResourceService 管理的普通 resource 行(CRUD + 审计 + sync 免费获得)。

磁盘 / sync 布局

无需新目录。provider profile 存入现有 sync workspace:

~/.coffer/sync/
  resources/
    provider/
      <name>.yaml      # 每条 profile 一个确定性 YAML 文件(不含 secret)
  credentials/
    provider/
      <name>/
        key.enc        # 原始 API key 的 Fernet 密文

provider 无新的 ~/.coffer/ 子目录(不同于 skill 有主内容存储)。唯一的磁盘副作用是投影写入的原生配置文件(~/.claude/settings.json~/.codex/config.toml)及其 .bak 备份。

约束摘要

  • ProviderConfig 任何时候都不得包含原始 secret。
  • 域层投影函数(apply_anthropic_settingsapply_codex_provider)必须为纯函数(无 I/O),返回新的原生配置文本。
  • 密钥解析不得将解密值记录到日志。
  • 每 wire 单活跃不变量通过顺序的 ResourceService.update_config 调用在单进程 daemon 中串行执行。
  • 所有 HTTP 路由仅限 loopback,受 X-Coffer-Token 门控。