数据模型——011 Provider Switching
English: data-model.md
provider 注册表的实体、字段与复用锚点。 依赖 spec 004 的 agent kind 和 spec 001 的 kind-agnostic Resource 框架。
域实体(backend/coffer/domain/provider/)
ProviderConfig(domain/provider/config.py)
Pydantic v2 BaseModel。这是存储在 Resource 行上的同步 config 字典。绝不持有原始 secret。
修订 2026-06-22b(E1–E2):连接是「带凭据的 endpoint」。
model、fast_model、wire_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)。
| 字段 | 类型 | 约束 / 说明 |
|---|---|---|
protocol | Protocol | 探测得出、非用户输入;"anthropic"、"openai"、"ollama" 或 "unknown"。create/edit 时探测 endpoint 得出;只是兼容性提示,不是投射门控。ollama 仅供内部。unknown ⇒ 对所有 agent 显示(用户决定)。 |
base_url | str | 必填;上游 LLM endpoint URL。 |
credential_ref | str | None | 可选;anthropic/openai 必填(Fernet vault ref,格式 ^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*$);ollama 不存在(无 API key)。 |
is_active | bool | 同一 protocol 下最多一条为 True(FR-011);ollama 始终为 False。 |
internal_default | bool | 全局最多一条为 True(FR-021);Coffer 内部引擎使用的 connection(其模型由内部默认选择器选,不存这里)。导入时若 >1,则归一化(保留最近更新的)。 |
模型选择不在连接上。投射给某 agent 的模型来自该 agent 的绑定(Agent 页双槽 → ANTHROPIC_MODEL + ANTHROPIC_SMALL_FAST_MODEL);Codex 的 wire_api 也移到 Codex 绑定。 迁移(方案 A):现有连接丢掉 model/fast_model/wire_api;wire_format 重读为 protocol(或 unknown 待探测);原先配的模型不带走——用户在 Agent 页重选。
所有字段均 JSON 稳定(无 Python 对象),model_dump(mode="json") 可干净序列化,适用于 SQLite 和 sync export。
Protocol(domain/provider/config.py)
单个 StrEnum 与 ProviderConfig 同在 config.py,没有独立 wire.py 模块。旧的 WireApi 枚举已移除——wire_api 离开连接(属 Codex 绑定关注点;投影默认 responses)。
class Protocol(StrEnum):
ANTHROPIC = "anthropic"
OPENAI = "openai"
OLLAMA = "ollama"
UNKNOWN = "unknown"ResolvedConnection(domain/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) -> strapply_codex_provider(config: ProviderConfig, profile_name: str, existing_text: str) -> strProjectionTarget——目标配置文件描述符target_for(wire: Protocol) -> ProjectionTarget | None——对ollama返回None(仅内部;无 agent 投影)- 常量:
CODEX_PROVIDER_ID、CODEX_ENV_KEY、ANTHROPIC_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_defaultconnection 与全局内部引擎模型(见下)配对。无 connection 被标记、或未设模型时返回None(模型独立于 connection,不再回退连接的 model)。build_chat_model(resolved, ...)消费ResolvedConnection,按protocol(anthropic / openai / ollama)分派,构建内部引擎的 chat model——取代已退役ModelConfig注册表的模型选择。
GlobalInternalEngineConfig(domain/internal_engine_config.py)
内部引擎模型是独立的全局单例(一行,固定 SINGLETON_ID = 1),与 connection 解耦:
| 字段 | 类型 | 说明 |
|---|---|---|
model | str | None | 内部引擎运行的模型;未选时为 None。 |
updated_at | datetime | 最后写入时间。 |
InternalEngineConfigService.get()返回该行或未设置默认值(model=None);.update(model=...)去空白、空→None、持久化,并发出internal_engine_model_set审计事件。- 经
SqlAlchemyInternalEngineConfigRepo(表internal_engine_config,alembic0039)存储;经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_URL | profile.base_url |
env.ANTHROPIC_MODEL | profile.model |
env.ANTHROPIC_SMALL_FAST_MODEL | profile.fast_model(为 None 时省略) |
openai 托管键:
| 托管键路径 | 来源 |
|---|---|
model | profile.model |
model_provider | 字面量 "coffer" |
model_providers.coffer.name | f"Coffer ({profile_name})" |
model_providers.coffer.base_url | profile.base_url |
model_providers.coffer.wire_api | profile.wire_api |
model_providers.coffer.env_key | 字面量 "COFFER_PROVIDER_KEY" |
复用锚点
所有实现必须复用以下现有组件,不得重新实现。
Fernet vault(凭证隔离)
| 组件 | 路径 | 用途 |
|---|---|---|
EncryptedCredentialStore | backend/coffer/infrastructure/credentials/encrypted_store.py | get/set/exists/delete——存取原始 secret |
| credential resolver | backend/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_atomic | backend/coffer/infrastructure/agent/config_file_store.py | 原子写 + .bak 备份 |
spec_for / config_files_for | backend/coffer/domain/agent/config_files.py | 为给定 AgentType + key 解析规范路径 |
AgentType descriptors | backend/coffer/domain/agent/descriptor.py | claude_code settings → ~/.claude/settings.json;codex config → ~/.codex/config.toml |
投影模板(MCP 注入)
| 组件 | 路径 | 用途 |
|---|---|---|
apply_install | backend/coffer/domain/agent/mcp_install.py | 返回文本的纯投影函数结构模板 |
mcp_entries.py | backend/coffer/domain/agent/mcp_entries.py | JSON 通过 json.dumps;TOML 通过 tomlkit——均复用 |
| MCP service | backend/coffer/application/agent/mcp_service.py | 驱动模式参考 |
Resource Kind 模式
| 组件 | 路径 | 用途 |
|---|---|---|
Kind dataclass | backend/coffer/domain/resource.py | 定义 provider Kind |
| kind factory | backend/coffer/application/knowledge_base/kind.py | make_provider_kind(...) 的模板 |
wire_kb_kind | backend/coffer/surfaces/http/wiring.py | wire_provider_kind(...) 的模板 |
| composition root | backend/coffer/surfaces/http/app.py | 注册 app.state.kinds["provider"] 的位置 |
单活跃不变量
激活翻转通过顺序的 ResourceService.update_config 调用实现(先清除其他,再设置目标);单进程 daemon 对请求串行化,切换操作不会交错。不存在 ProviderRepo / activate_atomic。
| 组件 | 路径 | 用途 |
|---|---|---|
ModelConfig 域 | backend/coffer/domain/chat/model.py | 每 wire 单活跃模式(镜像,不耦合) |
ResourceService.update_config | 现有 resource service | 由 ProviderService.activate() 直接使用 |
Sync
| 组件 | 路径 | 用途 |
|---|---|---|
ResourceDoc / resource_to_doc | backend/coffer/domain/sync/serialization.py | 序列化 provider 行 |
SyncExporter | backend/coffer/application/sync/exporter.py | 列出所有 kind(自动) |
SyncImporter | backend/coffer/application/sync/importer.py | 按 (kind, name) reconcile(自动) |
审计
| 组件 | 路径 | 用途 |
|---|---|---|
AuditEventType | backend/coffer/domain/audit.py | 添加 PROVIDER_SWITCHED |
AuditEntry | backend/coffer/domain/audit.py | 事件形状 |
AuditService.record | backend/coffer/application/audit_service.py | 从切换操作发出 PROVIDER_SWITCHED |
SQLite schema 变更
provider kind 复用共享的 resources 表(行 kind='provider');ProviderConfig 字典存储在现有 resources.config JSON 列——无新表。瘦身是一次数据迁移:0040 改写每条 provider 行的 config_json(wire_format→protocol;剥 model/fast_model/wire_api)。
无新表;无 SCHEMA_VERSION bump。
新增审计事件
在 backend/coffer/domain/audit.py 的 AuditEventType 中添加:
| 值 | 发出时机 |
|---|---|
provider_switched | POST /providers/{name}/activate 成功;details:{from, to, protocol, agents: [...projected...]} |
provider_internal_default_set | POST /providers/{name}/internal-default 成功;details:{from, to}(先前内部默认名称或 null,及新的) |
RESOURCE_CREATED、RESOURCE_UPDATED、RESOURCE_DELETED 由 ResourceService 在 CRUD 操作时自动发出(无需新增)。
应用服务契约(backend/coffer/application/provider/)
ProviderService(application/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)。 |
ActivateResult(application/provider/service.py)
@dataclass
class ActivateResult:
activated: str
projected: list[str] # 已写入的 agent 名称
skipped: list[str] # 不匹配或未注册的 agent 名称ProviderService._project(application/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_settings、apply_codex_provider)必须为纯函数(无 I/O),返回新的原生配置文本。 - 密钥解析不得将解密值记录到日志。
- 每 wire 单活跃不变量通过顺序的
ResourceService.update_config调用在单进程 daemon 中串行执行。 - 所有 HTTP 路由仅限 loopback,受
X-Coffer-Token门控。