OpenProgram Docs

模型目录与 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.jsonproviders.<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.pygoogle(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. 不变式(改代码前先对照)#

  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.jsoncache.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.pytests/unit/test_model_fetch_routing.py 保持绿色。
Last updated · 2026-08-13