Skip to content

模型目录加载

从启动快照、缓存资格和远端刷新追踪 Codex 如何维护模型目录,并解释目录替换、合并与降级边界。

基于rust-v0.150.0
CodexRustModelCache

模型目录加载 ​

本文承接 模型子系统架构总览、Provider解析流程 和 ModelInfo能力模型。前两篇解释 provider 从哪里来,后一篇解释单个 ModelInfo 如何被消费;本文只追踪模型目录快照本身的生命周期:启动时为什么已经有模型、何时读缓存、什么条件允许网络刷新、远端目录什么时候替换 bundled 目录,以及失败后谁继续提供结果。

读者需要能阅读 Rust trait、异步函数、Arc、RwLock 和测试断言。本文不展开 /models 响应字段的能力含义,也不讨论 Responses 请求构造。读完后,你应能从 ModelsManager::list_models 走到 raw_model_catalog、刷新策略、ModelsCache 和最终 picker preset;面对“网络不可用但仍能列出模型”或“远端模型消失后仍出现在列表”这类现象,也能定位到对应分支。

1. 启动快照 ​

模型目录不是第一次联网后才存在。OpenAiModelsManager 构造时立即读取编译期 bundled models.json,并把结果放进 remote_models。因此“远端刷新失败”首先意味着不能更新快照,而不是目录从此为空。

这张图的关键不是“有一个默认列表”,而是所有权:管理器拥有可变的内存快照,bundled 文件只是构造期输入;后续缓存和远端数据都要经过 apply_remote_models 才能改变快照。

源码位置:codex-rs/models-manager/src/manager.rs :: OpenAiModelsManager

rust
pub struct OpenAiModelsManager {
    remote_models: RwLock<Vec<ModelInfo>>,
    etag: RwLock<Option<String>>,
    cache: Option<Arc<dyn ModelsCache>>,
    endpoint_client: SharedModelsEndpointClient,
    auth_manager: Option<Arc<AuthManager>>,
}

源码位置:codex-rs/models-manager/src/manager.rs :: new_with_optional_cache

rust
fn new_with_optional_cache(
    cache: Option<Arc<dyn ModelsCache>>,
    endpoint_client: SharedModelsEndpointClient,
    auth_manager: Option<Arc<AuthManager>>,
) -> Self {
    let remote_models = load_remote_models_from_file().unwrap_or_default();
    Self {
        remote_models: RwLock::new(remote_models),
        etag: RwLock::new(None),
        cache,
        endpoint_client,
        auth_manager,
    }
}

RwLock 允许多个读取者拿到快照,刷新时才写入;etag 与目录分开保存,因为 ETag 是重新验证远端内容的元数据,不是 picker 要展示的模型。构造函数把缓存设为 Option,所以 new_without_cache 的语义不是“缓存永远为空”,而是每次需要刷新时都没有缓存层可尝试。

StaticModelsManager 是另一条启动路径:调用方直接注入 ModelsResponse,它不读 bundled 文件、不读磁盘、不访问 endpoint。它仍复用 trait 的 build_available_models,因此“目录来源”与“picker 派生”是两个层次。

源码位置:codex-rs/models-manager/src/manager.rs :: StaticModelsManager

rust
pub struct StaticModelsManager {
    remote_models: Vec<ModelInfo>,
    auth_manager: Option<Arc<AuthManager>>,
}

源码位置:codex-rs/models-manager/src/manager.rs :: StaticModelsManager::new

rust
pub fn new(auth_manager: Option<Arc<AuthManager>>, model_catalog: ModelsResponse) -> Self {
    Self {
        remote_models: model_catalog.models,
        auth_manager,
    }
}

源码位置:codex-rs/models-manager/src/manager.rs :: StaticModelsManager::raw_model_catalog

rust
fn raw_model_catalog(
    &self,
    _refresh_strategy: RefreshStrategy,
    _http_client_factory: HttpClientFactory,
) -> ModelsManagerFuture<'_, ModelsResponse> {
    Box::pin(async move {
        ModelsResponse {
            models: self.get_remote_models().await,
        }
    })
}

2. 目录分层 ​

阅读本模块时要区分三种“目录”:bundled 目录是启动基线,remote_models 是管理器当前拥有的 raw catalog,ModelPreset 是排序、认证过滤和默认值标记之后给 picker 或协议消费者的投影。缓存保存的是 ModelInfo 列表,不保存最终 preset。

ModelsManager::list_models 先请求 raw catalog,再调用默认的 build_available_models。因此一个模型“在 raw catalog 中存在”不等于“会出现在 picker”:排序、ModelPreset::filter_by_auth 和 mark_default_by_picker_visibility 还会继续改变结果。

当前 ChatGPT auth 还有一条特殊目录语义:远端 catalog 可以成为唯一 source of truth;当远端返回空目录时, manager 会保留 bundled catalog,避免一次空响应直接清空 picker。API auth 则继续合并远端与 bundled 条目。 这类 source-of-truth 分流发生在 ModelsManager,不是 ModelPreset 转换阶段。

源码位置:codex-rs/models-manager/src/manager.rs :: ModelsManager::list_models 与 build_available_models

rust
fn list_models(
    &self,
    refresh_strategy: RefreshStrategy,
    http_client_factory: HttpClientFactory,
) -> ModelsManagerFuture<'_, Vec<ModelPreset>> {
    Box::pin(
        async move {
            let catalog = self
                .raw_model_catalog(refresh_strategy, http_client_factory)
                .await;
            self.build_available_models(catalog.models)
        }
        .instrument(tracing::info_span!(
            "list_models",
            refresh_strategy = %refresh_strategy
        )),
    )
}

fn build_available_models(&self, mut remote_models: Vec<ModelInfo>) -> Vec<ModelPreset> {
    remote_models.sort_by_key(|model| model.priority);
    let mut presets: Vec<ModelPreset> = remote_models.into_iter().map(Into::into).collect();
    let uses_codex_backend = self
        .auth_manager()
        .is_some_and(AuthManager::current_auth_uses_codex_backend);
    presets = ModelPreset::filter_by_auth(presets, uses_codex_backend);
    ModelPreset::mark_default_by_picker_visibility(&mut presets);
    presets
}

这里的 owner/consumer 关系很具体:OpenAiModelsManager owner 是 raw snapshot 和刷新元数据;ModelsCache owner 是持久化新鲜度;ModelsEndpointClient owner 是 provider-specific 认证与 HTTP;picker 只消费 ModelPreset,不应该反向决定缓存内容。

3. 刷新资格 ​

RefreshStrategy 只描述“允许怎样刷新”,还不是“肯定会联网”。管理器先检查 should_refresh_models:当前 endpoint 要么能使用 Codex backend,要么 provider 具备 command auth。两者都不满足时,网络刷新被跳过。

源码位置:codex-rs/models-manager/src/manager.rs :: refresh_available_models 与 should_refresh_models

rust
async fn refresh_available_models(
    &self,
    refresh_strategy: RefreshStrategy,
    http_client_factory: &HttpClientFactory,
) -> CoreResult<()> {
    if !self.should_refresh_models().await {
        if matches!(
            refresh_strategy,
            RefreshStrategy::Offline | RefreshStrategy::OnlineIfUncached
        ) {
            self.try_load_cache().await;
        }
        return Ok(());
    }

    match refresh_strategy {
        RefreshStrategy::Offline => {
            // Only try to load from cache, never fetch
            self.try_load_cache().await;
            Ok(())
        }
        RefreshStrategy::OnlineIfUncached => {
            // Try cache first, fall back to online if unavailable
            if self.try_load_cache().await {
                info!("models cache: using cached models for OnlineIfUncached");
                return Ok(());
            }
            info!("models cache: cache miss, fetching remote models");
            self.fetch_and_update_models(http_client_factory).await
        }
        RefreshStrategy::Online => {
            // Always fetch from network
            self.fetch_and_update_models(http_client_factory).await
        }
    }
}

async fn should_refresh_models(&self) -> bool {
    self.endpoint_client.uses_codex_backend().await
        || self.endpoint_client.has_command_auth()
}

值得注意的是 Online 在无刷新资格时也不会强行请求网络;它只能在资格检查通过后变成“直接远端”。测试 refresh_available_models_skips_network_without_chatgpt_auth 以不能刷新且无认证的 endpoint 作为输入,断言动态模型没有进入内存目录、fetch 次数为 0。它证明的是资格门控,不证明所有 provider 都必须使用 ChatGPT 认证。

4. 缓存命中 ​

文件缓存路径是 $CODEX_HOME/models_cache.json,默认 TTL 为 300 秒。ModelsCache::load 的契约把缺失、过期和 client version 不匹配统一视为 Ok(None);后端错误则返回 Err,但 manager 仍把它当作 miss,继续远端刷新。

源码位置:codex-rs/models-manager/src/manager.rs :: try_load_cache 与 fetch_and_update_models

rust
async fn try_load_cache(&self) -> bool {
    let Some(cache) = self.cache.as_ref() else {
        return false;
    };
    let _timer =
        codex_otel::start_global_timer("codex.remote_models.load_cache.duration_ms", &[]);
    let client_version = crate::client_version_to_whole();
    info!(client_version, "models cache: evaluating cache eligibility");
    // TODO(celia-oai): Include provider identity in cache eligibility so switching
    // providers does not reuse a fresh models_cache.json entry from another provider.
    let cache_entry = match cache.load(&client_version).await {
        Ok(Some(cache_entry)) => cache_entry,
        Ok(None) => {
            info!("models cache: no usable cache entry");
            return false;
        }
        Err(err) => {
            error!("failed to load models cache: {err}");
            return false;
        }
    };
    if cache_entry.client_version.as_deref() != Some(client_version.as_str()) {
        info!(
            expected_version = client_version,
            cached_version = ?cache_entry.client_version,
            "models cache: cache version mismatch"
        );
        return false;
    }
    let models = cache_entry.models.clone();
    *self.etag.write().await = cache_entry.etag.clone();
    self.apply_remote_models(models.clone()).await;
    info!(
        models_count = models.len(),
        etag = ?cache_entry.etag,
        "models cache: cache entry applied"
    );
    true
}

async fn fetch_and_update_models(
    &self,
    http_client_factory: &HttpClientFactory,
) -> CoreResult<()> {
    let client_version = crate::client_version_to_whole();
    let (models, etag) = self
        .endpoint_client
        .list_models(&client_version, http_client_factory.clone())
        .await?;
    self.apply_remote_models(models.clone()).await;
    *self.etag.write().await = etag.clone();
    if let Some(cache) = self.cache.as_ref() {
        let entry = ModelsCacheEntry {
            fetched_at: Utc::now(),
            etag,
            client_version: Some(client_version),
            models,
        };
        if let Err(err) = cache.store(&entry).await {
            error!("failed to write models cache: {err}");
        }
    }
    Ok(())
}

提交顺序很重要:先应用内存目录,再更新 ETag,最后尝试写缓存。写盘失败不会撤销已经成功的内存刷新。injected_cache_hit_avoids_remote_fetch 断言 fresh entry 直接成为目录且 endpoint fetch 次数为 0;injected_cache_read_error_falls_back_and_persists_remote_models 则让 load 返回错误,断言仍会请求远端并尝试保存结果;injected_cache_write_error_does_not_fail_remote_refresh 证明写失败不向 list_models 调用方传播。

文件实现还有一个容易被忽略的边界:refresh_ttl 不能通过 load 完成,因为 load 会拒绝过期条目。它直接读取原始条目,只更新时间;若条目在半个 TTL 内仍新鲜,则不重复写盘。测试 file_cache_refresh_ttl_renews_expired_entry_without_serving_it_stale 先确认过期条目不会被 load 返回,再调用 refresh_ttl,最后确认 payload、ETag 和版本保持不变。

5. 远端入口 ​

manager 不拥有认证和 HTTP 细节。ModelsEndpointClient 只暴露三个能力:是否有 command auth、当前 auth 是否使用 Codex backend、以及带 client version 的列表请求。具体 provider endpoint 在 model-provider crate 中解析 auth、构造 API provider、选择 route-aware transport,并把 /models 请求限制在 5 秒内。

源码位置:codex-rs/model-provider/src/models_endpoint.rs :: MODELS_REFRESH_TIMEOUT 与 MODELS_ENDPOINT

rust
const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
const MODELS_ENDPOINT: &str = "/models";

源码位置:codex-rs/model-provider/src/models_endpoint.rs :: OpenAiModelsEndpoint::list_models

rust
async fn list_models(
    &self,
    client_version: &str,
    http_client_factory: HttpClientFactory,
) -> CoreResult<(Vec<ModelInfo>, Option<String>)> {
    let _timer =
        codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]);
    let auth = self.auth().await;
    let auth_mode = auth.as_ref().map(CodexAuth::auth_mode);
    let api_provider = self.provider_info.to_api_provider(auth_mode)?;
    let api_auth = resolve_provider_auth(auth.as_ref(), &self.provider_info)?;
    let request_url =
        ModelsClient::<ReqwestTransport>::request_url(&api_provider, client_version);
    let auth_telemetry = auth_header_telemetry(api_auth.as_ref());
    let agent_identity_telemetry = if let Some(CodexAuth::AgentIdentity(auth)) = auth.as_ref() {
        Some(agent_identity_telemetry(auth))
    } else {
        None
    };
    let request_telemetry: Arc<dyn RequestTelemetry> = Arc::new(ModelsRequestTelemetry {
        auth_mode: auth_mode.map(|mode| TelemetryAuthMode::from(mode).to_string()),
        auth_header_attached: auth_telemetry.attached,
        auth_header_name: auth_telemetry.name,
        agent_identity_telemetry,
        auth_env: self.auth_env(),
    });
    timeout(MODELS_REFRESH_TIMEOUT, async {
        let transport = self
            .transport_builder
            .build(http_client_factory, request_url.clone())
            .await?;
        let client = ModelsClient::new(transport, api_provider, api_auth)
            .with_telemetry(Some(request_telemetry));
        client
            .list_models(request_url, HeaderMap::new())
            .await
            .map_err(map_api_error)
    })
    .await
    .map_err(|_| CodexErr::Timeout)?
}

这段代码解释了两个 owner 边界:provider 决定请求发往哪里、使用什么 auth,manager 决定什么时候值得发请求以及返回结果如何提交。5 秒超时会让 fetch_and_update_models 返回错误;但 raw_model_catalog 会记录错误并读取当前内存快照,所以超时不会清空已有目录。

6. 目录提交 ​

远端结果不是总会采用同一种合并规则。ChatGPT auth 下,只要远端非空且至少有一个 visibility == List 的模型,远端列表就是本次 raw catalog 的唯一真源;否则从 bundled 目录重新开始,按 slug 替换或追加。API key auth 始终走后者。

源码位置:codex-rs/models-manager/src/manager.rs :: apply_remote_models

rust
async fn apply_remote_models(&self, models: Vec<ModelInfo>) {
    let should_use_remote_models_only = !models.is_empty()
        && models
            .iter()
            .any(|model| model.visibility == ModelVisibility::List)
        && self.auth_manager.as_ref().is_some_and(|auth_manager| {
            auth_manager
                .auth_mode()
                .is_some_and(AuthMode::has_chatgpt_account)
        });
    if should_use_remote_models_only {
        *self.remote_models.write().await = models;
        return;
    }

    let mut existing_models = load_remote_models_from_file().unwrap_or_default();
    for model in models {
        if let Some(existing_index) = existing_models
            .iter()
            .position(|existing| existing.slug == model.slug)
        {
            existing_models[existing_index] = model;
        } else {
            existing_models.push(model);
        }
    }
    *self.remote_models.write().await = existing_models;
}

这条规则解释了三个看似矛盾的测试:空远端不会清空 bundled;只有 hidden 模型的 ChatGPT 响应仍与 bundled 合并;可见 ChatGPT 响应会移除上一次远端中已消失的模型。API auth 的可见远端也不会独占,因此自定义 API provider 可以保留 bundled 能力并追加自己的 slug。

7. ETag续期 ​

ETag 的作用是避免重复下载相同目录,而不是替代 TTL。refresh_if_new_etag 只在当前 ETag 非空且相同的时候调用 cache.refresh_ttl;不同或当前没有 ETag 时,使用 Online 强制刷新。

源码位置:codex-rs/models-manager/src/manager.rs :: refresh_if_new_etag

rust
async fn refresh_if_new_etag(&self, etag: String, http_client_factory: HttpClientFactory) {
    let current_etag = self.get_etag().await;
    if current_etag.clone().is_some() && current_etag.as_deref() == Some(etag.as_str()) {
        if let Some(cache) = self.cache.as_ref()
            && let Err(err) = cache.refresh_ttl(&crate::client_version_to_whole()).await
        {
            error!("failed to renew cache TTL: {err}");
        }
        return;
    }
    if let Err(err) = self
        .refresh_available_models(RefreshStrategy::Online, &http_client_factory)
        .await
    {
        error!("failed to refresh available models: {err}");
    }
}

injected_cache_ttl_refresh_preserves_cached_payload 的输入是带 ETag 和模型 payload 的缓存条目,动作是先加载目录再用相同 ETag 触发续期,断言写回条目只改变 fetched_at。它证明 ETag 相同时不会替换模型 payload;TTL 写入失败只记录日志,也不会让当前目录失效。

8. 失败保底 ​

刷新函数返回 CoreResult<()>,但 raw catalog 不把这个错误继续抛给 picker。它记录错误,然后读取 remote_models 当前快照。这个设计让错误路径的消费者仍能获得可用目录:初始时是 bundled,成功刷新后可能是缓存或远端快照。

源码位置:codex-rs/models-manager/src/manager.rs :: OpenAiModelsManager::raw_model_catalog

rust
async fn raw_model_catalog(
    &self,
    refresh_strategy: RefreshStrategy,
    http_client_factory: HttpClientFactory,
) -> ModelsResponse {
    if let Err(err) = self
        .refresh_available_models(refresh_strategy, &http_client_factory)
        .await
    {
        error!("failed to refresh available models: {err}");
    }
    ModelsResponse {
        models: self.get_remote_models().await,
    }
}

这里的“恢复”不是重新构造 manager,也不是自动重试任意次数:失败请求结束后,恢复点是已有内存快照;下一次带 Online 或 cache miss 的调用才可能再次刷新。没有缓存时,manager_without_cache_fetches_on_every_refresh 证明连续两次 OnlineIfUncached 都会请求 endpoint,但第二次仍能返回上一次内存目录。

缓存还有一个当前版本边界:ModelsCache trait 要求共享实现按 provider/tenant 分区,但 OpenAiModelsManager::try_load_cache 的 TODO 表明文件缓存资格目前只检查 client version,没有把 provider identity 纳入判断。切换 provider 时,fresh 的同一路径文件存在被复用的风险;这是源码明确留下的限制,不能写成已经解决的隔离保证。

refresh_if_new_etag 对相同 ETag 只更新缓存时间并保留 payload;不同 ETag 才替换 models。缓存条目同时保存 client version、fetched_at、ETag 和 models,ETag 是重新验证元数据,不是 picker 字段。

9. 测试推演 ​

下面按“输入 → 动作 → 断言 → 覆盖边界”读测试,而不是只把测试名当目录。

场景输入与动作关键断言覆盖范围
缓存命中fresh、版本匹配的 injected entry;OnlineIfUncachedendpoint fetch 为 0,raw catalog 等于缓存manager 的缓存优先顺序
缓存读错load 返回错误;endpoint 返回 remoteremote 成为目录且写入一次 cacheread error 是 miss;不证明磁盘损坏修复
缓存写错endpoint 成功;store 返回错误catalog 仍成功返回写盘失败不破坏内存刷新
过期/版本错文件 entry 分别改为旧时间或错误版本两次都再次 fetchfreshness 与 client version 门控
ChatGPT 空/hiddenremote 为空或全 hiddenbundled 保留并合并remote-only 需要可见模型
ChatGPT visibleremote 至少一个 Listraw catalog 等于 remote远端可成为唯一真源
API authcommand auth 为真、auth mode 为 API keybundled 与 remote 合并API auth 不走 remote-only
无刷新资格endpoint 两项资格都为假fetch 为 0网络资格门控

这些测试覆盖缓存命中与失效、远端目录合并、认证模式和 ETag 分支;它们没有证明真实服务端返回内容、真实 OAuth token 有效性、不同 provider 共享文件缓存时是否实际发生碰撞,也没有证明 picker UI 一定展示所有 raw model。

10. 复现路径 ​

在本版本源码对应的 codex-rs workspace 中执行以下命令,可以从最小分支开始复核本文结论:

bash
RUST_MIN_STACK=16777216 cargo test -p codex-models-manager injected_cache
RUST_MIN_STACK=16777216 cargo test -p codex-models-manager refresh_available_models_uses_
RUST_MIN_STACK=16777216 cargo test -p codex-models-manager refresh_available_models_refetches_
RUST_MIN_STACK=16777216 cargo test -p codex-models-manager file_cache_
RUST_MIN_STACK=16777216 cargo test -p codex-models-manager refresh_available_models_drops_removed_remote_models

源码练习可以从 refresh_available_models_preserves_bundled_catalog_for_empty_chatgpt_remote 开始:把远端列表改成一个可见模型,观察断言从“保留 bundled”变为“远端独占”;再把认证改成 API key,观察同一个远端输入重新走 merge。调试真实“模型列表不更新”时,先沿 raw_model_catalog → refresh_available_models → should_refresh_models/try_load_cache → fetch_and_update_models → apply_remote_models 检查日志和快照,最后才检查 build_available_models 的认证过滤。

11. 源码导航 ​

如果要继续深入,先读 ModelsCache trait 与 FileModelsCache,因为它们定义 fresh、version 和 TTL 续期契约;再读 ModelsEndpointClient 的 provider 实现,确认认证和 route 如何进入 /models 请求;最后回到 build_available_models,观察 raw catalog 怎样变成 picker 能见的 ModelPreset。这三层分别回答“保存什么”“从哪里取”“谁最终看到”。