夜雨聆风学习资料网

ARTICLE · 1095467

Codex 源码-Model Provider 抽象与多后端

Codex 源码-Model Provider 抽象与多后端

Codex 源码解析系列

第 5 讲:Model Provider 抽象与多后端

基于 OpenAI Codex 源码 · 2026-09-29

💡 本讲一句话:Codex 把"连哪个模型后端"抽象成一个 ModelProvider trait:OpenAI/ChatGPT、Amazon Bedrock、Ollama、LM Studio 四种后端的认证方式、模型目录来源、401 恢复策略各不相同,但上层会话代码只面对同一组方法。读完这一讲,你就知道切后端时哪些行为会变、为什么缓存不会串号。

一、一个 trait,四种后端:ModelProvider 长什么样

第 4 讲我们把 config.toml 里的 model_providers 表读成了 ModelProviderInfo——那只是"配置态"。真正在运行时被会话调用的是 codex-model-provider crate(约 3.2K 行)里的 ModelProvider trait,它把"这个后端是什么、能做什么、怎么认证、模型列表从哪来"四件事收进一个接口。先看 trait 本体:

📄 codex-rs/model-provider/src/provider.rs (第 148-309 行,节选)

pub trait ModelProvider: fmt::Debug + Send + Sync { // 运行时 provider 抽象:Send+Sync 才能跨线程共享给会话    fn info(&self) -> &ModelProviderInfo; // 返回配置态元数据(名字/base_url/鉴权方式),是其他默认方法的原料    fn capabilities(&self) -> ProviderCapabilities { // 能力上限声明:命名工具/图像生成/联网搜索/远程压缩        ProviderCapabilities::default() // 默认全开、仅不支持远程压缩——新后端不实现就继承这个保守基线    }    fn auth_manager(&self) -> Option<Arc<AuthManager>>; // provider 级凭证管理器:Bedrock 的 AWS profile 就走这里,没有则返回 None    fn is_recoverable_auth_error(&self, error: &TransportError) -> bool { // 判断某次传输失败是否属于"可恢复认证错误"        matches!(error, TransportError::Http { status, .. } if *status == http::StatusCode::UNAUTHORIZED) // 默认只认 HTTP 401——OpenAI 系后端的通用语义    }    fn recover_from_unauthorized(&self) -> ModelProviderFuture<'_, Result<ProviderUnauthorizedRecovery>> { // provider 自有的凭证恢复钩子(如刷新 AWS STS)        Box::pin(async { Ok(ProviderUnauthorizedRecovery::NotConfigured) }) // 默认"没配置恢复"——调用方收到后走通用重登录流程    }    fn auth(&self) -> ModelProviderFuture<'_, Option<CodexAuth>>; // 取当前凭证快照:异步是因为 Bedrock 可能要现签 STS token    fn api_provider(&self) -> ModelProviderFuture<'_, Result<Provider>> { // 把配置态转成请求态 Provider(base_url+headers)        Box::pin(async move { // 装箱异步闭包:trait 方法要求返回 Pin<Box<dyn Future>>,这是标准写法            let auth = self.auth().await; // 先取凭证快照,auth_mode 决定 base_url 怎么解析            let mut provider = self.info().to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?; // 按认证模式展开成 API 客户端可用的 Provider            enforce_managed_residency(&mut provider); // 注入托管驻留头(如 us)——企业合规要求,调用方无感知            Ok(provider) // 返回组装好的请求态 Provider,调用方直接喂给 API client        })    }    fn models_manager(&self, codex_home: PathBuf, config_model_catalog: Option<ModelsResponse>) -> SharedModelsManager; // 创建模型目录管理器:远程拉取还是静态内置,由后端自己决定}

为什么这样设计:这个 trait 的妙处在于"默认方法 + 必选方法"的分层。必选的只有 info()、auth_manager()、auth()、models_manager() 四个——任何后端都绕不开;而 capabilities()、is_recoverable_auth_error()、recover_from_unauthorized() 全都有默认实现,新后端只需覆盖自己真正不同的部分。注意 capabilities() 的注释写得很直白:它是"provider-owned upper bound"(provider 拥有的上限)——调用方可以在此基础上再关功能,但不允许暴露 provider 标记为不支持的能力。这是把"能力协商"从 if-else 散点判断收敛成一处声明的关键。

再看 is_recoverable_auth_error() + recover_from_unauthorized() 这一对:默认语义是"401 = 可恢复,但不知道怎么恢复(NotConfigured)"。Bedrock 会覆盖这两个方法——因为 AWS STS token 过期产生的错误形态不止 401(还有签名校验失败),而且它知道怎么自己刷新凭证。把"什么算认证失败"和"怎么恢复"都做成 provider 可覆写的方法,上层重试逻辑就完全不用认识任何具体后端。

二、工厂:为什么 Bedrock 要单独分叉

📄 codex-rs/model-provider/src/provider.rs (第 319-346 行)

pub fn create_model_provider( // 运行时 provider 工厂:配置态 ModelProviderInfo → 可执行的 SharedModelProvider    provider_info: ModelProviderInfo, // 参数一:配置态元数据(名字/base_url/鉴权字段)    auth_manager: Option<Arc<AuthManager>>, // 参数二:可选凭证管理器,本地后端传 None) -> SharedModelProvider { // 返回 Arc 动态分发句柄——调用方从此看不到具体类型    if provider_info.is_amazon_bedrock() { // Bedrock 的认证/恢复/目录逻辑与 OpenAI 系差异太大,单独一个实现        Arc::new(AmazonBedrockModelProvider::new(provider_info, auth_manager)) // 返回 AWS 专用 provider(708 行 mod.rs + 606 行 auth.rs)    } else { // 非 Bedrock → 通用配置驱动实现        Arc::new(ConfiguredModelProvider::new(provider_info, auth_manager)) // 其余全部走"配置驱动"的通用实现——Ollama/LM Studio/自定义 OpenAI 兼容端点都在这条路上    }}struct ConfiguredModelProvider { // 通用 provider:行为完全由 ModelProviderInfo 字段决定,没有硬编码后端知识    info: ModelProviderInfo, // 持有配置态元数据,所有默认方法都从它派生行为    auth_manager: Option<Arc<AuthManager>>, // 持有凭证管理器引用——请求时经它取最新快照}impl ConfiguredModelProvider { // 构造逻辑:只需归一化 auth_manager    fn new(provider_info: ModelProviderInfo, auth_manager: Option<Arc<AuthManager>>) -> Self { // 私有构造:外部只能经工厂创建        let auth_manager = auth_manager_for_provider(auth_manager, &provider_info); // 若配置了命令式鉴权(auth.command),换成 external_bearer_only 管理器        Self { info: provider_info, auth_manager } // 两个字段直接落位,无其他初始化    }}

为什么这样设计:工厂只分叉一次,之后所有调用方拿到的都是 Arc<dyn ModelProvider>(SharedModelProvider),彻底抹掉具体类型。分叉条件不是"是不是 OpenAI"而是"是不是 Bedrock"——这透露了架构重心:OpenAI 兼容端点(包括 Ollama、LM Studio、vLLM)共享同一条配置驱动路径,差异全部编码在 ModelProviderInfo 的字段里;只有 Bedrock 因为要处理 AWS SigV4 签名、STS 刷新、credential export 这些完全不同的机制,才值得一个独立实现。另外注意 auth_manager_for_provider():当 provider 配置了命令式鉴权时,会替换成 external_bearer_only 的 AuthManager——凭证生命周期被收进 provider 内部,调用方拿不到原始 token。

三、凭证解析:一条 if 链排定优先级

📄 codex-rs/model-provider/src/auth.rs (第 197-222 行)

pub(crate) fn resolve_provider_auth( // 把"当前凭证 + provider 配置"解析成请求头 AuthProvider——每个出站请求都会走这里    auth: Option<&CodexAuth>, // 当前凭证快照(可能为 None——本地后端)    provider: &ModelProviderInfo, // provider 配置:决定鉴权优先级与校验规则) -> codex_protocol::error::Result<SharedAuthProvider> { // 产出请求头 AuthProvider,可携带错误(如 Bedrock 配错)    if let Some(auth) = bearer_auth_for_provider(provider)? { // 显式配置的 api_key / experimental_bearer_token 优先级最高——用户写配置就是明确意图        return Ok(Arc::new(auth)); // 显式 bearer 命中 → 直接返回,不再看其他凭证    }    if !provider.requires_openai_auth && provider.auth.is_none() { // 本地服务(Ollama/LM Studio)既不需要 OpenAI 鉴权也没配命令式鉴权        return Ok(unauthenticated_auth_provider()); // 直接返回空凭证 provider——请求不带 Authorization 头,fail fast 而不是猜    }    if matches!(auth, Some(CodexAuth::BedrockApiKey(_) | CodexAuth::BedrockAccessKeys(_))) { // Bedrock 专用凭证配到了非 Bedrock provider 上        return Err(CodexErr::UnsupportedOperation(BEDROCK_API_KEY_UNSUPPORTED_MESSAGE.to_string())); // 直接报错:这是配置错误,静默降级只会让用户更困惑    }    Ok(match auth { // 兜底分支:按快照类型构建对应 AuthProvider        Some(auth) => auth_provider_from_auth(auth), // ChatGPT/ApiKey/PAT → BearerAuthProvider;Headers → HeaderAuthProvider(逐请求读最新快照)        None => unauthenticated_auth_provider(), // 兜底:没有凭证也允许发请求——由服务端决定 401,交给恢复流程处理    })}

为什么这样设计:这条 if 链的顺序就是优先级语义:显式 bearer > 本地免鉴权 > 凭证类型校验 > 按快照构建。两个细节值得注意。第一,bearer_auth_for_provider() 放在最前面——即使用户已经登录 ChatGPT,只要 provider 配置里写了 env_key/api_key,就用配置的 key,这保证了"企业统一发 key"的场景不被个人登录态污染。第二,Bedrock 凭证配错时返回的是 UnsupportedOperation 而不是尝试发送:SigV4 签名头发到 OpenAI 端点必然失败,与其让用户看到一串难懂的 401/403,不如在本地就报"Bedrock API key auth is only supported by the Amazon Bedrock model provider"。返回的 SharedAuthProvider 是一个对象而非静态 header——AuthManagerAuthProvider 每次请求都会重新读 AuthManager 的最新快照,token 轮换对调用方完全透明。

四、本地后端:构造时就探测,失败就给出安装指引

📄 codex-rs/ollama/src/client.rs (第 71-95、98-103 行)

pub(crate) async fn try_from_provider( // 从 provider 定义构建 Ollama 客户端,并验证服务器可达    provider: &ModelProviderInfo, // provider 定义里取 base_url 等字段    http_client_factory: HttpClientFactory, // 客户端工厂:统一注入代理/超时策略) -> io::Result<Self> { // 构建并探测,失败返回带指引的 IO 错误    let base_url = provider.base_url.as_ref().expect("oss provider must have a base_url"); // oss 内置 provider 必有 base_url,缺失属于内部错误直接 panic    let uses_openai_compat = is_openai_compatible_base_url(base_url); // 判断用户是否把 Ollama 挂在 OpenAI 兼容端点后面(如 /v1)    let host_root = base_url_to_host_root(base_url); // 归一化出主机根地址,后续拼路径用    let client = RouteAwareClientPool::with_connect_timeout( // 路由感知连接池:按目标 URL 选代理/直连策略        http_client_factory, ClientRouteClass::Other, OLLAMA_CONNECTION_TIMEOUT) // Other 类路由 + 本地服务专用超时(比云端 API 短)        .with_legacy_custom_ca_fallback(); // 兼容旧版自定义 CA 证书配置——企业内网自签证书的逃生门    let client = Self { client, host_root, uses_openai_compat }; // 组装客户端:连接池 + 主机根 + 兼容模式标记    client.probe_server().await?; // 关键:构造时就探测一次,服务器没起直接报错并附安装/启动指引    Ok(client) // 探测通过 → 返回可用客户端}async fn probe_server(&self) -> io::Result<()> { // 健康检查:打一个只读端点确认服务活着    let url = if self.uses_openai_compat { // 按挂载形态选健康检查端点        format!("{}/v1/models", self.host_root.trim_end_matches('/')) // OpenAI 兼容模式 → /v1/models(trim 尾部斜杠防双斜杠)    } else { // 原生 Ollama API 分支        format!("{}/api/tags", self.host_root.trim_end_matches('/')) // 原生 Ollama API → /api/tags,两个端点都能列出模型    };

为什么这样设计:Ollama/LM Studio 是本地服务,最大的失败模式不是"请求出错"而是"根本没启动"。如果不在构造时探测,用户第一次发消息才会收到一个挂起几十秒的超时——体验极差。try_from_oss_provider() 的文档注释写得很清楚:探测失败时返回的错误信息里带安装/运行指引(OLLAMA_CONNECTION_ERROR)。另一个细节是 uses_openai_compat:Ollama 既能原生 API 也能挂 OpenAI 兼容层,同一个客户端要同时支持两种 URL 形态,所以健康检查端点也要二选一。LM Studio 的 check_server()(lmstudio/src/client.rs 第 51-67 行)逻辑相同但更简单——只打 /models 一个端点,因为 LM Studio 没有原生/兼容两种形态之分。

🔍 对比:Ollama 探测 /api/tags(或兼容模式 /v1/models),LM Studio 探测 /models——端点不同但策略一致:构造时 fail fast + 错误信息带修复指引。云端后端(OpenAI/Bedrock)则不做这种探测,因为网络抖动不该阻塞启动。

五、模型目录:三种刷新策略 + 缓存三重校验

📄 codex-rs/models-manager/src/manager.rs (第 79-132、186-210 行)

pub enum RefreshStrategy { // 模型目录刷新策略:调用方显式声明"要不要联网"    Online, // 强制走网络,忽略缓存——用户手动刷新时用    Offline, // 只用本地数据(内置目录+磁盘缓存),绝不发请求——断网/隐私模式用    OnlineIfUncached, // 有新鲜缓存就用缓存,否则才联网——默认路径,兼顾速度与新鲜度}pub trait ModelsManager: fmt::Debug + Send + Sync { // 模型发现协调器:远程目录 + 磁盘缓存 + 内置兜底三合一    fn list_models(&self, refresh_strategy: RefreshStrategy, http_client_factory: HttpClientFactory) -> ModelsManagerFuture<'_, Vec<ModelPreset>> { // 列出可用模型(默认方法:取目录 → 构建 picker 预设)        Box::pin(async move { // 装箱异步闭包:trait 方法要求返回 Pin<Box<dyn Future>>            let catalog = self.raw_model_catalog(refresh_strategy, http_client_factory).await; // 按策略刷新并拿到原始目录            self.build_available_models(catalog.models) // 排序 + 按认证模式过滤可见性 + 标记默认模型        })    }    fn get_default_model<'a>(&'a self, model: &'a Option<String>, allow_provider_model_fallback: bool, refresh_strategy: RefreshStrategy, http_client_factory: HttpClientFactory) -> ModelsManagerFuture<'a, String> { // 解析"该用哪个模型":用户指定优先,否则选默认        Box::pin(async move { // 同上——默认实现里把 async 块装箱成 trait 要求的 Future            if let Some(model) = model.as_ref() { return model.to_string(); } // 用户显式指定的模型直接保留——尊重配置意图            default_model_from_available(self.list_models(refresh_strategy, http_client_factory).await) // 没指定 → 从可用列表里按优先级挑默认        })    }}

为什么这样设计:RefreshStrategy 把"要不要联网"从隐式行为变成显式参数——TUI 启动时可以用 OnlineIfUncached(有缓存秒开),用户手动点刷新时用 Online,断网演示时用 Offline。三种策略对应三种调用场景,而不是在函数里塞一堆 bool。get_default_model() 的默认实现也体现了"配置优先"原则:只要用户在 config.toml 写了 model,就原样保留,provider fallback 只在允许时才发生。

📄 codex-rs/models-manager/src/manager.rs (第 553-590、518-550 行)

async fn try_load_cache(&self) -> bool { // 尝试用磁盘缓存满足本次刷新——三重校验全过才敢用    let Some(cache) = self.cache.as_ref() else { return false; }; // 没配缓存(如托管模式)直接放弃    let client_version = crate::client_version_to_whole(); // 当前客户端版本号——目录结构随版本演进,旧版缓存不可信    let Some(identity) = self.endpoint_client.identity() else { return false; }; // 取"provider+账号"身份哈希(见第七节)    let cache_entry = match cache.load(&client_version).await { // 按客户端版本读缓存条目        Ok(Some(cache_entry)) => cache_entry, // 命中缓存条目 → 进入后续校验        Ok(None) => return false, // 没有可用条目 → 走网络刷新        Err(err) => { error!("failed to load models cache: {err}"); return false; } // 读失败不致命:降级到网络,不让缓存 bug 卡死启动    };    if cache_entry.client_version.as_deref() != Some(client_version.as_str()) { // 校验一:客户端版本必须一致——防止旧版 Codex 写的目录结构被新版误用        return false; // 版本不符 → 弃用缓存(目录 schema 可能已变)    }    if cache_entry.identity.as_ref() != Some(&identity) || self.endpoint_client.identity().as_ref() != Some(&identity) { // 校验二:缓存里的身份哈希必须等于当前 provider+账号——防止切号后复用别人的模型列表        return false; // 身份不符 → 弃用缓存,防止切号后复用别人的模型列表    }    self.apply_remote_models(cache_entry).await // 双重校验通过 → 发布到内存(内部还会做第三次身份校验)}async fn apply_remote_models(&self, mut entry: ModelsCacheEntry) -> bool { // 把目录条目发布为当前内存快照    let mut current = self.remote_models.write().await; // 拿写锁——发布是原子操作,读者永远看到完整快照    if entry.identity != self.endpoint_client.identity() { // 校验三:异步存储期间账号可能已切换,身份不符就丢弃这次结果        return false; // 发布前第三次身份校验失败 → 丢弃这次结果    }    let remote_only = entry.models.iter().any(|model| model.visibility == ModelVisibility::List) && (self.supports_api_key_discovery() || self.auth_manager.as_ref().is_some_and(|auth_manager| auth_manager.auth_mode().is_some_and(AuthMode::has_chatgpt_account))); // 判断目录是否"权威":有可见模型且是 ChatGPT/API-key 发现模式    if !remote_only { // 非权威目录(如本地 provider)→ 与内置默认目录合并,保留兜底模型        let mut models = load_remote_models_from_file().unwrap_or_default(); // 读编译进二进制的 bundled 目录        for model in entry.models { // 逐个远程条目做 upsert            if let Some(index) = models.iter().position(|existing| existing.slug == model.slug) { // slug 命中 → 原地覆盖(远程更新)                models[index] = model; // slug 相同 → 远程条目覆盖内置条目(远程更新)            } else { // slug 未命中 → 追加到列表尾                models.push(model); // slug 不同 → 追加(本地新增模型不丢)            }        }        entry.models = models; // 合并结果写回条目,随后整体发布    }    *current = entry; // 整体替换内存快照——读端无需加锁即可拿到一致视图    true // 发布成功——调用方据此判断缓存是否生效}

为什么这样设计:缓存校验做了三层,每层防一种事故:版本不一致(目录 schema 演进)、身份不一致(用户切了账号/换了 provider)、发布时竞态(异步 IO 期间状态变了)。第三层最微妙——apply_remote_models() 在拿到写锁后再次比对 identity,因为从发起请求到写入缓存之间可能发生了账号切换,此时把 A 账号的目录发布给 B 账号就是串号。另外注意非权威目录的合并逻辑:本地 provider(Ollama/LM Studio)拉到的模型列表只覆盖同 slug 的内置条目、新增条目追加——这样 bundled 兜底模型永远不会因为一次不完整的远程响应而消失。

六、API-key 发现门控:不是所有会话都配拉远程目录

📄 codex-rs/models-manager/src/manager.rs (第 502-515 行)

fn supports_api_key_discovery(&self) -> bool { // 判断当前会话是否允许"API-key 模型发现"(拉远程 /models)    self.endpoint_client.supports_api_key_models() && // 条件一:端点支持 OpenAI 系模型列表 API        !self.endpoint_client.has_command_auth() && // 条件二:没有命令式鉴权——外部 bearer 的 key 可能属于别的账号,目录不可信        && self.auth_manager.as_ref().is_some_and(|auth_manager| auth_manager.auth_mode() == Some(AuthMode::ApiKey)) // 条件三:当前认证模式恰好是 ApiKey}async fn should_refresh_models(&self) -> bool { // 决定要不要走网络刷新目录    self.endpoint_client.uses_codex_backend().await || // Codex 后端(ChatGPT 登录)→ 必须联网拿最新目录        self.endpoint_client.has_command_auth() || // 命令式鉴权 → key 会轮换,目录可能变        self.supports_api_key_discovery() // API-key 发现模式 → 允许拉取    // 三个都不满足(如本地 Ollama)→ false:直接用 bundled 目录 + 本地探测结果,不发任何网络请求}

为什么这样设计:这两个函数是"模型发现权限"的守门人。注意 supports_api_key_discovery() 里排除了命令式鉴权——注释在别处解释过原因:外部命令返回的 bearer token 是不透明的,同一个 key 可能对应不同账号,用它拉到的目录不能当权威数据。should_refresh_models() 则是白名单思维:只有三种明确场景才允许联网拉目录,其余一律走本地。对 Ollama/LM Studio 用户来说这意味着零网络依赖——模型列表完全来自 /api/tags//models 的实时探测 + bundled 兜底。

七、identity:一个哈希防住"缓存串号"

📄 codex-rs/model-provider/src/models_identity.rs (第 14-69 行,节选)

pub(crate) fn identity( // 计算"provider+账号"身份哈希——模型缓存的隔离键    provider_info: &ModelProviderInfo, // provider 配置:参与摘要的静态部分    auth: Option<&CodexAuth>, // 凭证快照:账号身份字段入摘要,token 本身不入) -> CoreResult<String> { // 产出十六进制 identity 字符串(可携带错误)    let mut digest = Sha256::new(); // SHA-256 摘要器    let mut field = |value: &[u8]| { // 长度前缀写入:先写 8 字节长度再写字节——避免相邻字段值拼接产生歧义("ab"+"c" vs "a"+"bc")        digest.update((value.len() as u64).to_le_bytes()); // 先写 8 字节长度前缀——防相邻字段拼接歧义        digest.update(value); // 再写字段内容,长度+内容成对入摘要    };    field(b"models-cache-v1"); // 版本盐:目录 schema 升级后旧缓存自动全部失效    field(provider.name.as_bytes()); // provider 名——openai / ollama / bedrock 天然隔离    field(provider.base_url.as_bytes()); // base_url——同一 provider 不同端点(如代理)也要分开    let mut query: Vec<_> = provider.query_params.iter().flatten().collect(); // 摊平 query 参数为 (name,value) 列表    query.sort(); // query 参数排序后再入摘要——保证相同参数集合得到相同哈希,与配置书写顺序无关    field(&(query.len() as u64).to_le_bytes()); // 先写参数个数——区分空集合与缺失    for (name, value) in query { field(name.as_bytes()); field(value.as_bytes()); } // 逐对写入(已排序,顺序稳定)    field(format!("{:?}", auth.map(CodexAuth::api_auth_mode)).as_bytes()); // 认证模式(ChatGPT/ApiKey/...)——不同模式的目录可见性不同    if let Some(auth) = auth { // 有凭证 → 追加账号身份字段(无凭证则跳过整段)        field(format!("{:?}", auth.get_account_id()).as_bytes()); // 账号 ID——同 provider 下不同账号的目录必须隔离        field(format!("{:?}", auth.get_chatgpt_user_id()).as_bytes()); // ChatGPT user id(进一步区分同一企业下的个人)        field(format!("{:?}", auth.account_plan_type()).as_bytes()); // 套餐类型(Plus/Pro/...)——不同套餐可见模型不同    }    let has_stable_account = auth.is_some_and(|auth| matches!(auth, CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) | CodexAuth::AgentIdentity(_)) && auth.get_account_id().is_some() && (auth.get_chatgpt_user_id().is_some() || auth.get_account_email().is_some())); // 判断凭证是否带"稳定账号元数据"——有则 token 本身不必入摘要    let explicit_bearer = provider_info.api_key()?.is_some() || provider_info.experimental_bearer_token.is_some(); // 显式 bearer key:不透明,可能属于任何账号    if !has_stable_account || explicit_bearer { // 没有稳定账号元数据、或用了显式 key → 把解析出的请求头也纳入摘要        let headers = resolve_provider_auth(auth, provider_info)?.to_auth_headers(); // 解析出实际请求头——不透明 key 靠它参与摘要        provider.headers.extend(headers); // 这样 key 一变哈希就变——不同 key(可能不同账号)的缓存天然隔离    }    Ok(format!("{:x}", digest.finalize())) // 输出十六进制摘要作为 identity}

为什么这样设计:这个函数的文件头注释点破了核心权衡:"Only a digest is persisted; access tokens for ChatGPT are excluded so token rotation does not discard a catalog"——ChatGPT 的 access token 会频繁轮换,如果把它算进缓存键,每次刷新 token 都会让模型目录缓存失效、被迫重新联网。所以摘要里放的是账号身份(account_id + user_id + plan_type)而不是 token 本身:同一账号换 token,缓存继续有效;换了账号,哈希立刻不同,缓存自动隔离。但对显式 bearer key 恰恰相反——key 是不透明的,没有账号元数据可依赖,只能把请求头整体纳入摘要,让"key 变了 = 缓存作废"。长度前缀编码(length-prefix)则是防经典拼接歧义的标准手法:"ab"+"c" 和 "a"+"bc" 无前缀时会产生相同字节流。

八、Bedrock 的 401 恢复:单飞守卫 + 错误分类

📄 codex-rs/model-provider/src/amazon_bedrock/mod.rs (第 258-317 行,节选)

fn is_recoverable_auth_error(&self, error: &TransportError) -> bool { // 覆盖默认实现:Bedrock 的"可恢复认证错误"比 401 更多    matches!(error, TransportError::Http { status, .. } if *status == http::StatusCode::UNAUTHORIZED) || (self.uses_aws_auth_recovery() && error::is_refreshable_auth_error(error)) // 401,或 AWS 签名过期类错误(仅配置了凭证恢复时)}fn recover_from_unauthorized(&self) -> ModelProviderFuture<'_, Result<ProviderUnauthorizedRecovery>> { // provider 自有的凭证刷新流程    Box::pin(async move { // 装箱异步恢复流程:trait 要求返回 Pin<Box<dyn Future>>        if !self.uses_aws_auth_recovery() { return Ok(ProviderUnauthorizedRecovery::NotConfigured); } // 没配 AWS 恢复 → 交回通用重登录流程        let export_refresh = if let Some(exporter) = &self.credential_export { // credential export(外部命令导出凭证)场景:先拿单飞守卫            let refresh = exporter.begin_refresh().await; // begin_refresh 是"单飞"入口——并发调用者共享同一次刷新,避免 N 个请求触发 N 次 STS 调用            if refresh.is_none() { return Ok(ProviderUnauthorizedRecovery::Recovered); } // None = 等待期间别的调用者已完成恢复 → 直接算成功            refresh // Some → 拿到单飞刷新句柄,由本调用者执行刷新        } else { None }; // 无 credential export → 跳过该步,只做 STS 刷新        let result: std::io::Result<()> = async { // 串联两步刷新:STS 会话 + 外部导出命令            if let Some(recovery) = &self.auth_recovery { recovery.refresh().await?; } // 刷新 STS/会话凭证            if let Some(exporter) = export_refresh { exporter.refresh().await?; } // 再执行外部导出命令拿新 key            Ok(()) // 两步都成功 → 凭证已就绪 // 两步都成功 → 凭证已就绪        }.await; // 等待刷新完成,拿到统一结果 // 等待刷新完成,拿到统一结果        result.map_err(|error| { // 错误分类:配置类问题 vs IO 类问题,上层提示语不同            if matches!(error.kind(), std::io::ErrorKind::InvalidData | std::io::ErrorKind::InvalidInput | std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied) { // 配置/权限类错误 → 归为 InvalidRequest(用户可自修)                CodexErr::InvalidRequest(error.to_string()) // 凭证格式错/命令不存在/权限不足 → InvalidRequest(用户能自己修)            } else { // 其余 IO 错误 → 保留 Io 类型(重试可能成功)                CodexErr::Io(error) // 网络/系统错误 → Io(重试可能成功)            }        })?; // 刷新失败 → 把分类后的错误抛给调用方,不假装恢复成功        Ok(ProviderUnauthorizedRecovery::Recovered) // 刷新成功 → 调用方重发原请求即可    })}

为什么这样设计:AWS STS token 的有效期通常只有几分钟到一小时,长会话里必然遇到过期。这段代码解决两个并发问题:第一,begin_refresh() 单飞守卫保证同一时刻只有一个刷新在跑——10 个并发请求同时收到 401,只会触发一次 STS 调用,其余等待者拿到 None 后直接视为"已恢复"。第二,错误分类把"用户能修的"(InvalidData/NotFound/PermissionDenied → InvalidRequest)和"重试可能好的"(Io)分开——上层 UI 对这两类错误的提示策略完全不同。对比 OpenAI 系后端:ChatGPT token 过期走 AuthManager 的通用刷新,Bedrock 则完全自管——这正是把 recover_from_unauthorized() 做成 trait 方法的回报。

九、四种后端行为对照表

OpenAI / ChatGPT

认证方式 ChatGPT token / API key

模型目录来源 远程 /models + 磁盘缓存(identity 隔离)

401 恢复策略 AuthManager 通用刷新,NotConfigured 兜底

Amazon Bedrock

认证方式 SigV4 / STS / credential export(7 种来源)

模型目录来源 本地 catalog + bundled 兜底

401 恢复策略 单飞守卫刷新 STS,错误分 InvalidRequest/Io

Ollama / LM Studio

认证方式 无鉴权(本地服务)

模型目录来源 构造时探测 /api/tags、/models + bundled 合并

401 恢复策略 无恢复——连接失败直接报安装指引

自定义 OpenAI 兼容端点

认证方式 env_key / bearer token(显式优先)

模型目录来源 按 RefreshStrategy:缓存或网络,identity 含 key 头

401 恢复策略 默认只认 401,NotConfigured 兜底

后端
实现路径
关键文件
OpenAI / ChatGPT
ConfiguredModelProvider(配置驱动)
provider.rs / auth.rs
Amazon Bedrock
AmazonBedrockModelProvider(独立实现)
amazon_bedrock/mod.rs + auth.rs
Ollama / LM Studio
ConfiguredModelProvider + 本地探测客户端
ollama/src/client.rs、lmstudio/src/client.rs
模型目录(全部后端)
ModelsManager:缓存 + bundled 兜底
models-manager/src/manager.rs

这组对照卡片浓缩了前八节的细节:认证列对应第三、七、八节(resolve_provider_auth / identity / recover_from_unauthorized),目录列对应第五、六节(RefreshStrategy + 门控),恢复列对应第一、八节(trait 默认方法 vs Bedrock 覆盖)。四种后端共享同一个 trait,但每一列的行为都由 provider 自己的实现决定——这就是"配置驱动 + 有限分叉"的架构收益。

十、数据流:一次请求 + 一次模型发现

请求路径(api_auth_for_scope → API client)

① 取凭证:ModelProvider.auth() → Option<CodexAuth>

异步快照——Bedrock 可能现签 STS,ChatGPT 读 AuthManager。

▼

② 解析凭证:resolve_provider_auth → SharedAuthProvider

显式 bearer > 本地免鉴权 > Bedrock 校验 > 按快照构建。

▼

③ 组装请求:api_provider() → Provider + 驻留头

base_url 按 auth_mode 展开,enforce_managed_residency 注入合规头。

▼

④ 发送:路由感知传输 → API client

🔹 RouteAwareClientPool 按 URL 选代理/直连🔹 AuthProvider 每请求重读快照,token 轮换透明

▼

⑤ 失败恢复:is_recoverable_auth_error → recover_from_unauthorized

🔹 OpenAI 系:只认 401,NotConfigured 走通用重登录🔹 Bedrock:单飞刷新 STS/credential export 后重试

模型发现路径(ModelsManager.list_models)

① 策略选择:RefreshStrategy(Online / Offline / OnlineIfUncached)

调用方显式声明要不要联网,默认走缓存优先。

▼

② 缓存校验:client_version + identity 双重比对

版本不符或账号切换 → 弃用缓存走网络,防串号。

▼

③ 网络拉取:/models(超时保护)→ 写缓存

🔹 API-key 发现需过三重门控,本地后端直接跳过🔹 写缓存失败不致命——只记日志继续用内存结果

▼

④ 发布:apply_remote_models → 写锁内第三次身份校验

🔹 权威目录整体替换;非权威与 bundled 按 slug 合并🔹 build_available_models:排序 + 认证过滤 + 标记默认

两条路径在 ModelProvider trait 上汇合:请求路径用它的 auth()/api_provider()/恢复钩子,发现路径用它创建的 models_manager()。下一讲(第 6 讲)进入第二阶段"认证与身份"——login/auth/manager.rs 里的 AuthManager 到底怎么管理 ChatGPT token、keyring 存储和 agent identity,这些正是本讲反复出现的 CodexAuth 快照的生产者。

📚 系列导航

← 第 4 讲:Config 配置系统与 Feature Flags

→ 第 6 讲:Login/Auth 登录与凭证管理

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料