# 模型目录与 Provider 配置

> 本文描述模型目录的运行逻辑：数据放哪、文件与代码怎么交互、后端和前端各自怎么消费。
> Thinking effort 的参数细节见 [thinking-effort.md](thinking-effort.zh.md)。

## 1. 一句话架构

**系统只长期记住用户启用的模型。** 设置页的层级是「先 provider、后模型」：第一层展示 provider 列表；点进某个 provider，才实时查询**该 provider** 有哪些模型可选——这个查询不落盘。「启用」的动作 = 把某个模型那一刻的完整规格写进 `config.json`。运行时注册表 `ENABLED_MODELS` 就是 config 里这几十行——`get_model()` 查的、聊天页显示的、用户勾选的，物理上是同一份数据。

**核心不变式：聊天页能选的 = 已启用的 = 后端能解析的。** 不是靠合并管线对齐两份清单，而是根本只有一份。

由此自动获得的性质：

- **没有大文件**：不存全量清单（models.dev 有 151 个 provider、上千个模型且大量重复），config 里只有用户启用的几个到几十个。
- **不会过期**：过期的前提是存储。可选列表实时查询，永远是最新的；已启用模型的规格由设置页「Refresh」按需覆写——只刷新用户真正在用的。
- **git 干净**：程序只写用户目录的 config；仓库里只有人写的 provider.json；安装包目录运行期只读。

## 2. 数据分布（按「谁写」分家）

| 谁写 | 放哪 | 是什么 | 大小 |
|---|---|---|---|
| **人**（进 git） | `providers/<p>/provider.json`（+ 专属协议时的 `<p>.py`） | endpoints、thinking、cache、模型级 override | 每份几行到几十行 |
| **程序**（用户机） | `config.json` → `providers.<p>.models` | 已启用模型的完整规格 + key、enabled 等用户状态 | 几十行 |
| 第三方（网络） | models.dev + 官方 `/v1/models` | 设置页浏览用的实时数据源 | 不落盘 |

```
openprogram/providers/                 ← 全部进 git，运行期只读
├── deepseek/
│   ├── provider.json                  ← 该 provider 全部手写配置（见第 3 节）
│   └── deepseek.py                    ← wire/stream 实现（仅专属协议的 provider 有）
├── enabled_models.py                  ← ENABLED_MODELS：从 config 加载 + endpoints 填充 + thinking 推导
└── models.py                          ← get_model / get_providers / get_models

~/.openprogram/
└── config.json                        ← 唯一的用户侧持久化
```

**命名规则：这里没有任何东西叫「catalog」。** 一词五用正是命名混乱的根源，因此每个模块都按它装的东西命名：注册表是 `enabled_models.py`，配置持久化是 `storage.py`，thinking 声明是 `thinking_spec.py`，webui 的展示层是 `_model_listing/`。`ENABLED_MODELS` 这个名字准确，是因为这个 dict 装的确实只有启用的模型。

**没有目录的 provider**（fireworks、together 等）：models.dev 实时数据里有它们，用户填 key、浏览、启用即可，包里不需要任何文件。

## 3. provider.json：唯一的手写文件

一个 provider 的所有人工配置集中一份，全部字段可省略：

```json
{
  "id": "deepseek",
  "endpoints": {
    "default": {"api": "openai-completions", "base_url": "https://api.deepseek.com/v1"}
  },
  "thinking": {
    "wire_format": "effort_string",
    "effort_map": {"minimal": "minimal", "low": "low", "medium": "medium", "high": "high", "max": "max"},
    "default_effort": "medium"
  },
  "cache": {"mode": "none"},
  "model_overrides": {
    "some-model": {"headers": {"X-Foo": "1"}, "compat": {"no_stream_options": true}}
  },
  "models_from": null
}
```

| 字段 | 作用 | 缺省行为 |
|---|---|---|
| `endpoints` | api/base_url 分组，模型按组名引用（opencode 4 组、copilot 3 组；单 wire 只有 `default`） | models.dev 给的 base_url + OpenAI 兼容协议 |
| `thinking` | wire_format / effort 映射 / 模型级档位（原 thinking.json，详见 thinking-effort.md） | OpenAI 兼容 fallback（low/medium/high） |
| `cache` | prompt-caching 声明（原 cache.json） | 不做显式缓存控制 |
| `model_overrides` | 逐模型的 headers、compat、`endpoint` 引用、`key_prefix` 等机器拿不到的字段，**启用时叠进规格** | 无 override |
| `models_from` | 订阅型 provider 借用浏览数据源（claude-code → anthropic） | 不借用 |

**判断标准：机器拿得到的字段，人不写。** provider.json 里没有模型清单——清单是浏览时实时查的，规格是启用时复制的。

目录名用下划线（`amazon_bedrock/`），`id` 存连字符原名（`amazon-bedrock`）。同服务多协议（百炼的 OpenAI 兼容 + Anthropic 兼容端点）= 同一 provider 两个 endpoint，不拆两个 provider。

## 4. 两个动作：浏览、启用

### 4.1 浏览（实时，不落盘）

浏览分两级。**第一级：provider 列表**（设置页首屏）= 本地有 `provider.json` 的 provider ∪ models.dev 的 provider 索引，只有名字、配置状态等元信息，不含模型。**第二级：模型列表**——用户点进某一个 provider 后，才对这一个 provider 发起查询：

```
list_available_models(provider_id)
  = 该 provider 的官方源（一个 fetcher，见下）
  ⊕ models.dev（补价格/能力；无 key 时的完整兜底）
  → 内存合并，直接返回给前端渲染
```

**fetcher 归位原则：接口偏离标准 OpenAI 格式的 provider，把它自己的 fetcher 放在自己目录里。** 每种源形态不同但**返回同一契约**：成功 → `list[dict]`（每行至少 id/name），失败 → `{"error": ...}`。

| 源形态 | fetcher 位置 | 例子 |
|---|---|---|
| 标准 `/v1/models`（OpenAI 兼容） | 通用 `_model_listing/fetchers/openai_compat.py`（共享兜底，不属于任何单个 provider） | openai、openrouter、groq、自定义网关 |
| Anthropic `GET /v1/models` + 逐模型 capabilities | `providers/anthropic/list_models.py` | anthropic、claude-code、minimax |
| 账户级私有端点（`/v1/models` 被 Cloudflare 挡，改拉订阅账户的模型表） | `providers/openai_codex/list_models.py`（见 fast-tier.md §2.1） | openai-codex |
| 厂商专用列表接口（响应形状 / 鉴权与 OpenAI 兼容不同） | `providers/<name>/list_models.py`：`google`（query-param key + `models/<id>` 前缀）/ `amazon_bedrock`（boto3 SigV4，非 HTTP）/ `github_copilot`（会话 bearer + capabilities 信封）/ `deepseek`（id-only 后补） | 对应 provider |

**约定加载**：接口偏离标准的 provider 在自己目录放一个 `list_models.py`，导出 `fetch(provider_id, timeout)`；分派器 `_load_fetcher` 按目录名 `__import__` 找它——和 `probe_thinking.probe()` 完全一套机制，新增 provider 零中心改动。接口标准的 provider 不放这个文件，走通用 `openai_compat`。**有没有这个文件 = 这个 provider 接口是否偏离标准**，是个自然的、按需的判据。

无论哪种源，`fetch_and_normalize` 是**唯一的归一化收口**：它把 fetcher 千差万别的 key（`context_length`/`context_window`/`contextWindow` 等）统一成一份 entry dict，再叠 models.dev 补全。下游只看归一化后的统一行，看不到源的差异。

普通 provider 的结果只进短 TTL 内存缓存。账户级订阅目录还会在 profile 状态目录保存一份原子写入的 last-known-good 缓存。联网失败时可以把上次成功结果标为 stale 后展示，但不会把它当成一次成功的权威刷新。返回空列表或供应商尚未配置凭据时，保留已有模型选择；只有当前官方接口返回非空目录时才移除目录中不再存在的订阅模型。

### 4.2 启用（复制规格进 config）

用户在浏览列表里勾选一个模型：

```
enable_model(provider_id, row)
  → 规格 = 浏览行 ⊕ provider.json.model_overrides[id] ⊕ endpoints 解析的 api/base_url
  → thinking 档位由 provider.json.thinking 推导后一并写入
  → 追加到 config.json providers.<p>.models
  → ENABLED_MODELS 重载
```

- **取消启用** = 从 config 删除该行；订阅 provider 还记录 id tombstone，自动刷新不会把它重新启用。
- **Refresh** = 对已启用模型重新执行浏览 + 覆写规格（治「规格随时间变旧」，且只刷新用户在用的）。
- **手工添加模型**（provider 没列出的）= 用户在同一张表单里手填一行——和「启用」写的是同一个列表，原 `custom_models` 概念消失。
- **订阅目录自动同步** = 登录后、worker 启动时目录过期、每六小时后台周期以及手动 Refresh 都读取账户官方模型表。新 id 自动启用，能力变化自动更新，官方删除的 id 退出，用户明确关闭的 id 保持关闭。以后 Codex 或 Grok 增加模型无需再改源码名单。
  只有持久化的启用行确实发生变化时，worker 才广播 `provider_models_changed` 失效通知。已连接的网页和桌面客户端收到后重新读取 provider 与启用模型接口；客户端重连时也会使相同查询失效，以补偿离线期间漏掉的通知。

## 5. 后端怎么用

```python
# openprogram/providers/enabled_models.py
ENABLED_MODELS: dict[str, Model]   # key = "<prefix>/<id>"，内容 = config 规格 + 推导字段
```

启动时从 config 加载（几十行，瞬时），config 变更后重载。`get_model` / `get_providers` / `get_models` 三个查询函数接口不变，20+ 个运行时调用方（agent、runtime、failover…）零改动。`get_model` miss 时经 `auth.aliases` 试等价 provider 名。

**约定：系统只认启用的模型。** failover 链、agent 配置引用的模型必须在启用集里；引用未启用的模型 = 配置错误，报错信息提示去设置页启用。旧会话引用已删除的模型时正常显示历史，仅不能继续用该模型发消息。

## 6. 前端怎么用

| 前端位置 | API 路由 | 数据来源 |
|---|---|---|
| 设置页首屏：provider 列表（无模型名） | `GET /api/providers` | provider.json 有的 + models.dev 实时列出的（社区 provider 可直接配置） |
| provider 详情页：浏览/勾选**该 provider** 的模型 | `GET /api/providers/<id>/available` | **实时**：4.1 第二级的浏览结果 + 已启用标记 |
| 聊天页模型选择器 | `GET /api/models/enabled` | **config**：ENABLED_MODELS 原样返回 |
| thinking 档位选择器 | （`_thinking.py`） | ENABLED_MODELS 行里的 thinking_levels |

webui 展示层（`_model_listing/`）不做任何合并推导——浏览合并在 4.1 一个函数里，规格合并发生在启用那一刻。webui import providers，providers 永远不 import webui。

**端到端**：填 key → 浏览（实时列表出现 `deepseek-v4-flash`）→ 勾选（完整规格写进 config，`ENABLED_MODELS["deepseek/deepseek-v4-flash"]` 出现）→ 聊天页选中发消息（`get_model` 命中同一条 config 记录）。任何时刻系统里都只有一份模型数据。

## 7. 不变式（改代码前先对照）

1. **只存启用的**：唯一持久化的模型数据是 config 里的启用规格。出现第二份持久化清单（全量快照、fetch 缓存文件、手写清单）即违约。
2. **浏览不落盘**：可选列表是实时查询 + 内存缓存，永不写文件。
3. **按谁写分家**：人写的进 git（provider.json）；程序写的进用户 config；包目录运行期只读。
4. **手写最小化**：provider.json 只存机器拿不到的字段，且没有模型清单。
5. **分层单向**：`openprogram.providers` 不 import `openprogram.webui`。
6. **key 兼容**：`"<prefix>/<id>"`、alias 回退、`key_prefix`（gemini-subscription 双 key）保留；注册表是同一个可变 dict。
7. **多源一格**：不管来源是官方 `/v1/models`、账户级私有端点（codex）、models.dev 社区目录还是用户手填，都在 `fetch_and_normalize` 一个函数里归一成同一份行结构；启用时经 `_upsert_spec_row` 一个收口写进 config（`_normalize_spec_row` 补全 Model schema 字段）；读取时经 `_build_model_from_row` 一个转换器建成 `Model`。三处收口各只有一个，谁也不许旁路自造格式。

## 8. 实现状态

上面描述的就是代码当前的行为。注册表是 `ENABLED_MODELS`，定义在 `enabled_models.py`；webui 的展示层是 `_model_listing/`；启用一个模型会把它的完整规格复制进 config；浏览是实时的、不落盘；`thinking.json` 与 `cache.json` 已并入 `provider.json`，因此 `_default_api_for` / `_resolve_base_url` 直接读 endpoints，providers 层不再反过来读注册表。

这里的任何改动都必须保住以下性质：

- **存量启用不能丢**：对每个 provider，一条 config 行经 `get_model` 解析出的 `Model` 必须与改动前一致。
- **alias 与双 key**：gemini-subscription 的 `google-gemini-cli/*` 与 `gemini-subscription/*` 共 10 个 key、name 各异。启用行各自携带自己的 key 与 name，alias 回退保持不变。
- **claude-code 借用链**：浏览数据经 `models_from` 借自 anthropic、登录后自动启用 3 个模型，以及它自己的 fetcher。
- **逐字段保真**：`cost` 嵌套对象、`input` 多模态、`headers`（copilot 依赖它）、`compat` 都随启用时写入 config 的规格一起传递。
- **验证粒度**：多 wire provider 按每个 `(api, base_url, headers, compat)` 组合各 exec 一个模型。
- `tests/unit/providers/registry/test_provider_wire_invariants.py` 与 `tests/unit/providers/registry/test_model_fetch_routing.py` 保持绿色。
