青雲的博客
拆开 Codex 第一部:先建立系统地图 第 06 章

模型不是一个字符串:Provider、目录与 ModelInfo

从 provider 配置、运行时模型提供者和模型目录追到 ModelInfo,解释缓存、fallback、能力元数据与 turn context 如何汇合。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

配置里写下 model = "gpt-5.4" 之后,很多文章会顺手说“Codex 选择了 gpt-5.4”。这句话缺了几个对象:请求要发给哪个 provider,用什么认证和 wire protocol,目录里有没有这个模型的元数据,模型能不能接收图片或 hosted search,以及这一轮到底能用多少 context。它们不会从一个字符串里自动长出来。

我第一次读到这段时,也把模型选择想成一个 map lookup:slug -> endpoint。固定版本的源码把这条路拆得更开。配置先决定 model_provider_idModelProviderInfo;运行时把后者包装成 ModelProviderModelsManager 维护模型目录并解析 ModelInfo;创建 turn 时,两个结果才分别放进 TurnContext.providerTurnContext.model_info

本章只解决“配置到 turn context”的这一段。认证凭据的生命周期在第 5 章已经拆开;这里只说明 provider 如何提供认证入口,不重复登录流程。工具执行和 turn ownership 留给后面的章节。

先看一个 turn 最终拿到什么

TurnContext 里有两个容易被放在一起讲的字段:provider: SharedModelProvidermodel_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_urlenv_key、命令认证和 AWS 配置、wire_api、query/header、重试和超时,以及 requires_openai_authsupports_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 ModelPresetget_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 时,OfflineOnlineIfUncached 仍可尝试读 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 路径还可覆盖 HeadersAgentIdentity,后者的 has_chatgpt_account() 只覆盖 ChatgptChatgptAuthTokensPersonalAccessToken。于是“有资格刷新”和“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_tools capability。
  • standalone web.run 走另一套 gate:provider 要支持 namespace tools,Responses Lite 或 StandaloneWebSearch feature 要打开,web search mode 不能是 disabled,而且对应 executor 必须存在。
  • image generation 还会检查 provider auth 路径、provider 的 image_generation capability,以及 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_remainingcontext_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 lookupprovider id 一定是 custom,或 provider/auth 已存在
used_fallback_model_metadata = false找到了一个候选 ModelInfo远端刚刚验证了模型,或请求一定成功
models_cache.json cache hitclient version 和 TTL 通过cache 一定属于当前 provider
ModelInfo.supports_search_tool = true通用 deferred tool search 的模型侧 gate 通过provider 一定允许 namespace tools,或具体 executor 已存在
provider.capabilities().web_search = trueprovider 没关闭 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 边界的组合:

交接项当前章节负责的结果后续章节继续问什么
providerSharedModelProvider:API provider、auth 入口、provider capability这次 turn 谁拥有提交和执行权
model metadataModelInfo:reasoning、tools、modalities、service tier、fallback marker第 10–12 章追它们怎样变成 request 与 action
context windowTurnContext::model_context_window() 的有效窗口,以及 scope-aware token status第 9、26 章分别处理 prompt view 与 compaction
capability boundaryprovider capability 与 ModelInfo capability 共同约束,再叠加 config/features第 14–19 章追 tool、approval 与 execution 边界

第 7 章不继续展开 SharedModelProvider。启动前提到这里已经就绪,控制流回到第 1 章留下的 ownership 主线:真正按下回车后,谁创建 turn、谁持有它、谁决定它何时结束?下一章:你按下回车后,谁接管这次任务