模型不是一个字符串:Provider、目录与 ModelInfo
从 provider 配置、运行时模型提供者和模型目录追到 ModelInfo,解释缓存、fallback、能力元数据与 turn context 如何汇合。
配置里写下 model = "gpt-5.4" 之后,很多文章会顺手说“Codex 选择了 gpt-5.4”。这句话缺了几个对象:请求要发给哪个 provider,用什么认证和 wire protocol,目录里有没有这个模型的元数据,模型能不能接收图片或 hosted search,以及这一轮到底能用多少 context。它们不会从一个字符串里自动长出来。
我第一次读到这段时,也把模型选择想成一个 map lookup:slug -> endpoint。固定版本的源码把这条路拆得更开。配置先决定 model_provider_id 和 ModelProviderInfo;运行时把后者包装成 ModelProvider;ModelsManager 维护模型目录并解析 ModelInfo;创建 turn 时,两个结果才分别放进 TurnContext.provider 和 TurnContext.model_info。
本章只解决“配置到 turn context”的这一段。认证凭据的生命周期在第 5 章已经拆开;这里只说明 provider 如何提供认证入口,不重复登录流程。工具执行和 turn ownership 留给后面的章节。
先看一个 turn 最终拿到什么
TurnContext 里有两个容易被放在一起讲的字段:provider: SharedModelProvider 和 model_info: ModelInfo。前者是运行时行为对象,后者是这一轮已经解析完成的模型描述。它们还和 config.model、session source、权限等状态一起存在,但没有一个字段能替代另外一个。
创建 turn context 时,session configuration 里的模型先交给 models_manager.get_model_info(...),随后 make_turn_context 用 provider 配置调用 create_model_provider,再把两者分别写入结构体。这个调用顺序说明了一个很实用的边界:目录解析不会替代 provider 构造,provider 构造也不会替代模型元数据解析。
先记住这张职责表,后面所有“模型能力”的句子都要能落到其中一列:
| 对象 | 它回答的问题 | 它不回答的问题 |
|---|---|---|
ModelProviderInfo | 请求发到哪里、用哪种 wire API、是否需要登录、是否支持 WebSocket | 这个具体 slug 的 context window 和 reasoning levels |
ModelProvider | 运行时如何取得 auth、构造 API provider、创建 ModelsManager、给出 provider capability 上限 | 远端目录是否列出了某个 slug |
ModelsManager | 目录从哪里来、何时刷新、如何缓存和匹配 | 当前请求最终能否被服务端接受 |
ModelInfo | 模型元数据、工具/模态能力、context 相关参数 | provider 的 base URL、auth 和 transport |
TurnContext | 把本轮 provider、ModelInfo、配置和 session 状态交给执行链 | 远端服务的最终业务授权结果 |
Provider 配置是“怎么连”
ModelProviderInfo 是可序列化的配置对象
model-provider-info crate 的 ModelProviderInfo 不是抽象的“模型厂商标签”。它包含 base_url、env_key、命令认证和 AWS 配置、wire_api、query/header、重试和超时,以及 requires_openai_auth、supports_websockets 等连接边界。这里已经有 auth 和 transport 信息,但没有 ModelInfo 的 context window、reasoning levels 或 input modalities。
validate() 还会阻止互相冲突的认证配置。例如 AWS SigV4 不能和 supports_websockets 同时出现,命令认证不能再和 env_key、静态 bearer token 或 requires_openai_auth 混用。to_api_provider() 则根据 auth mode 选择默认 base URL,并把 headers、query 参数和 retry 配置适配给 API client。
provider id 和 model slug 是两次查表
普通配置加载完成后,Codex 先把 built-in provider 和用户配置合并,再用 model_provider(或默认的 openai)从 provider map 取出 ModelProviderInfo。固定版本内置了 OpenAI、Amazon Bedrock、Ollama 和 LM Studio;自定义 provider 是新增 map entry,不会因为一个同名 model slug 自动成为 active provider。Amazon Bedrock 还保留了特例:用户只能修改其 AWS profile/region,不能替换整份内置 provider。
这也是为什么“模型目录里出现了 foo”不能推出“foo 会发到 provider foo”。provider id 和 model slug 各有自己的入口和错误边界。
ModelProvider 才是运行时行为对象
ModelProvider trait 把配置对象包成执行时的能力:info() 返回配置,auth() 和 api_auth() 取得请求凭据,api_provider() 生成 API client 所需的 provider,runtime_base_url() 允许特殊 provider 在请求时决定地址,models_manager() 创建目录管理器。它还暴露 capabilities(),源码明确把它描述成 provider-owned upper bound:调用方可以再关闭能力,却不应该暴露 provider 已标记为不支持的功能。
默认实现 ConfiguredModelProvider 用配置对象和 provider-scoped auth manager;如果配置了静态 model_catalog,它创建 StaticModelsManager,否则创建带 endpoint 和磁盘缓存的 OpenAiModelsManager。Amazon Bedrock 走专门实现,使用静态目录而不是 OpenAI-compatible 远端刷新路径。
Bedrock 的 capability 例子很有说明力:它保留 namespace tools,但关闭 image generation 和 web search。这个结论来自 provider 实现,不来自某个模型 slug。
ModelsManager 管目录,不替请求做决定
三种刷新策略
ModelsManager 把“列出可选模型”和“查一个模型的元数据”拆成两个 API。list_models() 会先得到 raw catalog,再按 priority、auth mode 和 visibility 生成 picker-ready ModelPreset;get_model_info() 只从当前内存中的 remote catalog 构造一个 ModelInfo。后者本身不刷新网络,也不读磁盘缓存。
OpenAI-compatible manager 的 refresh 路径只有三种策略:
RefreshStrategy | 行为 | 网络请求 |
|---|---|---|
Offline | 只尝试读取新鲜缓存 | 不发起 |
OnlineIfUncached | 先读新鲜缓存,miss 后 fetch | 通过 refresh gate 后可能发起 |
Online | 忽略缓存,进入 fetch 分支 | 通过 refresh gate 后发起 |
读这张表要把三个判断分开,它们不共享一个 auth predicate。
第一层决定 session 是否显式调用 list_models()。root session 使用 OnlineIfUncached,因此总会调用;非 root agent 使用 Offline,只有未显式配置 model 时才调用。随后 get_default_model() 在 model 缺失时还会通过 list_models() 选择默认值,最后 get_model_info() 才从当前内存目录解析 metadata。调用 list_models() 本身不等于联网。
第二层才是 OpenAiModelsManager::should_refresh_models()。它检查 endpoint 是否使用 Codex backend,或者 provider 是否配置了 command auth;结果为 false 时,Offline 与 OnlineIfUncached 仍可尝试读 cache,Online 则直接结束。只有通过这个 gate,refresh strategy 才会进一步决定读 cache 还是请求 /models。因此普通 provider 即使选择 Online,也不能从策略名推出必然联网。
换句话说,should_refresh_models() 既不能替代 session 是否调用 list_models() 的条件,也不能替代 snapshot 到手后的 remote-only catalog predicate。
第三层发生在网络或 cache 已经给出 snapshot 之后。只有 snapshot 非空、至少有一个 visibility = List 的模型,并且当前 auth 满足 AuthMode::has_chatgpt_account(),这份 snapshot 才整体取代 bundled catalog;否则从 bundled catalog 起步,按完全相同的 slug 替换或追加。cache hit 也会经过同一个 apply_remote_models,所以这不是“只针对远端响应”的判断。
should_refresh_models() 与 remote-only catalog 条件的范围确实不同:前者的 Codex-backend 路径还可覆盖 Headers 和 AgentIdentity,后者的 has_chatgpt_account() 只覆盖 Chatgpt、ChatgptAuthTokens 和 PersonalAccessToken。于是“有资格刷新”和“snapshot 可以独占目录”不能互相替代;同样,“bundled 文件里有这个 slug”也不保证当前内存目录仍包含它。
缓存命中不等于 provider 命中
默认缓存文件是 CODEX_HOME/models_cache.json,默认 time to live(TTL,缓存有效期)是 300 秒。缓存条目保存 fetched_at、ETag、client version 和 Vec<ModelInfo>;load_fresh() 只检查 client version 匹配和 TTL 未过期。
这里有一条源码自己承认的边界:cache eligibility 还没有纳入 provider identity。切换 provider 后,只要 client version 和 TTL 都满足,models_cache.json 仍可能被复用。它是固定版本的已知 TODO,不应被文章包装成“缓存已经按 provider 隔离”。
ModelInfo 的解析有三种 fallback
这里需要把三个都叫 fallback 的东西分开,否则实验结果会被读错。
1. provider model fallback:选择另一个模型
StaticModelsManager::get_default_model() 在 allow_provider_model_fallback 开启时,会检查请求模型是否在可用 presets 中;不可用就选 provider default。关闭这个策略时,它会保留用户传入的模型字符串。这个 fallback 改变的是要请求哪个模型。
2. refresh/cache fallback:目录准备失败后的旧状态
OpenAiModelsManager 刷新失败时记录错误,raw catalog 仍返回当前内存目录;OnlineIfUncached 在 cache miss 后才尝试远端,远端失败并不会自动把一个未知 slug 变成“已验证模型”。这类 fallback 讨论的是目录数据从哪里来,不是 ModelInfo 的字段。
3. metadata fallback:保留 slug,补一份最小描述
get_model_info() 的核心函数先做最长前缀匹配;如果失败,再只剥掉一个看起来像 provider id 的 namespace,例如 custom/gpt-5.3-codex。匹配成功时,结果保留本次请求的原始 slug,但把候选的 metadata 带过来,并把 used_fallback_model_metadata 置为 false。
完全找不到候选时,model_info_from_slug() 构造一个最小 descriptor:display name 就是 slug、priority 设为 99、部分能力关闭,context window 使用本地 fallback 值,并标记 used_fallback_model_metadata: true。这个 descriptor 仍保留 requested slug;标记是 core 内部字段,序列化时跳过。它说明 metadata 来源是 fallback,不说明 provider、远端授权或模型真的存在。
随后 with_config_overrides() 还会应用 model_context_window、auto-compact、tool output limit 和 instructions 等配置覆盖;当配置提供 context-window 覆盖值时,它会按 max_context_window clamp。因而 get_model_info 的返回值也不是未经配置修改的远端原样。
ModelInfo 记录能力,仍不拥有 provider
协议里的 ModelInfo 字段很宽:slug、展示信息、reasoning levels、visibility、service tiers、tool 类型、输入模态、context window、并行工具调用、search tool、Responses Lite 等都在这里。但字段列表里没有 provider id、base URL、auth mode 或 wire transport。
运行时工具暴露确实会同时读取 provider capability 与 ModelInfo capability,但每类工具的 gate 不一样,不能把所有 search 字段揉成一个布尔值:
- hosted web search 先排除
ModelInfo.use_responses_lite,再由 provider 的web_search上限、当前 config mode 和 standalone executor 是否存在决定是否生成;ModelInfo.web_search_tool_type只选择 hosted tool 的 schema,不负责单独启用它。 - 通用
tool_search/ deferred-tool discovery 使用ModelInfo.supports_search_tool,并同时要求 provider 的namespace_toolscapability。 - standalone
web.run走另一套 gate:provider 要支持 namespace tools,Responses Lite 或StandaloneWebSearchfeature 要打开,web search mode 不能是 disabled,而且对应 executor 必须存在。 - image generation 还会检查 provider auth 路径、provider 的
image_generationcapability,以及 ModelInfo 的 image input modality。
因此,provider capability 与 ModelInfo capability 会共同约束整体 tool exposure,具体一项能力还要叠加 config、auth 或 executor 状态;单看任意一个字段都不够。
Context window 还要经过一次运行时换算
ModelInfo::resolved_context_window() 优先取 context_window,没有时才退到 max_context_window。这只是模型描述里的原始有效窗口。TurnContext::model_context_window() 再把它乘以 effective_context_window_percent;固定版本默认这个百分比为 95。于是执行链看到的是 turn-level 的有效窗口,不是把字段原样抄出来。
get_context_remaining 是 context_window_token_status() 的下游消费者,不是 model_context_window() 的直接别名,也不是模型解析器。handler 返回的是 tokens_until_compaction:Total scope 使用 ModelInfo 派生的 auto-compact limit;BodyAfterPrefix 才会另外取 TurnContext::model_context_window() 作为 full-context limit,并与 scope remaining 取较小值。“剩余 token”不能直接当成 raw context window。
一张图:两条解析线在 turn 汇合
flowchart TB
accTitle: Provider 与模型元数据进入一个 turn
accDescr: 配置选择 provider,模型目录经过刷新和缓存后解析 ModelInfo;provider 与模型能力共同约束工具,scope-aware token status 再交给下游消费者。
CFG["Config: model_provider_id + model_provider"] --> P["ModelProvider\n auth / transport / provider capability"]
CFG --> MM["ModelsManager"]
P --> MM
REF["RefreshStrategy\n caller-selected"] --> MM
MM --> CAT["in-memory model catalog\n bundled + remote + cache"]
CAT --> RES["get_model_info(slug)"]
RES --> MI["ModelInfo\n metadata + fallback marker"]
P --> TP["TurnContext.provider"]
MI --> TM["TurnContext.model_info"]
TP --> CAP["provider capability"]
TM --> CAP2["ModelInfo capability"]
CAP --> TOOLS["tool exposure\n provider + model + config"]
CAP2 --> TOOLS
TM --> STATUS["context_window_token_status()"]
TM --> CW["model_context_window()\n BodyAfterPrefix"]
CW --> STATUS
STATUS --> REM["get_context_remaining\n downstream token status"]
图中的 bundled + remote + cache 不是三个永远同时存在的来源:静态 manager 可能只有 in-process catalog,OpenAI-compatible manager 才按策略读缓存或远端。图只表达所有权交接,不表达一次调用必然经过全部节点。
固定 checkout 的可运行实验
本章唯一的验证实验只测 metadata fallback,不把它冒充成网络、provider 或 context 测试。固定 checkout 的 commit 先校验,再运行 manager 的命名测试:
: "${ARCHIVE_CODEX_RS:?先执行第一部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-models-manager get_model_info_tracks_fallback_usage
这次验证得到:
PASS codex-models-manager manager::tests::get_model_info_tracks_fallback_usage
Summary: 1 test run, 1 passed
测试里 known 来自 manager 当前的 bundled models;unknown 使用一个合成的不存在 slug。它证明的是 get_model_info() 的 flag 和 slug 保留行为,不证明远端 API 可用、不证明 cache 归属于当前 provider,也不证明 server 会接受这个模型。若要验证 refresh 或 provider auth,需要另写 endpoint/auth 测试,不能从这个 1-test 结果外推。
失败边界:不要从 slug 猜出整条链
把下面几条反例留在脑子里,比记住一堆类型名更有用:
| 看到的现象 | 源码允许的结论 | 不能推出的结论 |
|---|---|---|
model = "custom/gpt-image" | 可尝试一次 namespace suffix metadata lookup | provider id 一定是 custom,或 provider/auth 已存在 |
used_fallback_model_metadata = false | 找到了一个候选 ModelInfo | 远端刚刚验证了模型,或请求一定成功 |
models_cache.json cache hit | client version 和 TTL 通过 | cache 一定属于当前 provider |
ModelInfo.supports_search_tool = true | 通用 deferred tool search 的模型侧 gate 通过 | provider 一定允许 namespace tools,或具体 executor 已存在 |
provider.capabilities().web_search = true | provider 没关闭 hosted-search 上限 | config mode 已启用,或 Responses Lite 未禁用 hosted tool |
get_context_remaining 返回数字 | 当前 turn 算出了 compaction 前的 token 状态 | 这个数字就是 raw context window |
最短的负边界可以直接写成一句话:模型 slug 不能证明 provider、auth、transport 或 capability 支持。 ModelInfo 是目录元数据,ModelProvider 是请求运行时对象;两者要在 turn 中同时成立,后续配置和服务端仍可能拒绝请求。
resolved contract 接下来怎样被消费
到这里,下一章接手的不是一个裸字符串,而是一个已经解析到 turn 边界的组合:
| 交接项 | 当前章节负责的结果 | 后续章节继续问什么 |
|---|---|---|
| provider | SharedModelProvider:API provider、auth 入口、provider capability | 这次 turn 谁拥有提交和执行权 |
| model metadata | ModelInfo:reasoning、tools、modalities、service tier、fallback marker | 第 10–12 章追它们怎样变成 request 与 action |
| context window | TurnContext::model_context_window() 的有效窗口,以及 scope-aware token status | 第 9、26 章分别处理 prompt view 与 compaction |
| capability boundary | provider capability 与 ModelInfo capability 共同约束,再叠加 config/features | 第 14–19 章追 tool、approval 与 execution 边界 |
第 7 章不继续展开 SharedModelProvider。启动前提到这里已经就绪,控制流回到第 1 章留下的 ownership 主线:真正按下回车后,谁创建 turn、谁持有它、谁决定它何时结束?下一章:你按下回车后,谁接管这次任务