模型目录与 Provider 配置#
本文描述模型目录的运行逻辑:数据放哪、文件与代码怎么交互、后端和前端各自怎么消费。 Thinking effort 的参数细节见 thinking-effort.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 的所有人工配置集中一份,全部字段可省略:
{
"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 补全。下游只看归一化后的统一行,看不到源的差异。
结果只进内存(可带一个短 TTL 缓存避免反复请求),关掉页面就没了。断网时浏览不可用——发现新模型本来就需要网络,这不是缺陷是事实。
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 删除该行。
- Refresh = 对已启用模型重新执行浏览 + 覆写规格(治「规格随时间变旧」,且只刷新用户在用的)。
- 手工添加模型(provider 没列出的)= 用户在同一张表单里手填一行——和「启用」写的是同一个列表,原
custom_models概念消失。 - 订阅 provider 的动态注册(如 claude-code 登录后自动出现 3 个模型)= 程序代替用户执行一次 enable,写的还是同一个列表。
5. 后端怎么用#
# 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. 不变式(改代码前先对照)#
- 只存启用的:唯一持久化的模型数据是 config 里的启用规格。出现第二份持久化清单(全量快照、fetch 缓存文件、手写清单)即违约。
- 浏览不落盘:可选列表是实时查询 + 内存缓存,永不写文件。
- 按谁写分家:人写的进 git(provider.json);程序写的进用户 config;包目录运行期只读。
- 手写最小化:provider.json 只存机器拿不到的字段,且没有模型清单。
- 分层单向:
openprogram.providers不 importopenprogram.webui。 - key 兼容:
"<prefix>/<id>"、alias 回退、key_prefix(gemini-subscription 双 key)保留;注册表是同一个可变 dict。 - 多源一格:不管来源是官方
/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/test_provider_wire_invariants.py与tests/unit/test_model_fetch_routing.py保持绿色。