Skip to content

模型选择迁移

从请求模型到 provider 默认值、未知模型元数据和升级提示,追踪 Codex 的模型选择与迁移边界。

基于rust-v0.150.0
CodexRustModelFallback

模型选择迁移 ​

本文承接 模型目录加载 和 ModelInfo能力模型。前文说明目录从哪里来、怎样刷新;本文继续回答目录被消费时最容易混淆的三个问题:请求的模型什么时候会被 provider 默认值替换,未知 slug 为什么仍能创建 ModelInfo,目录中的 upgrade 为什么只是迁移提示而不是自动改名。

读者需要能阅读 Rust trait、Option、异步调用和 TUI 事件。本文不讨论模型能力字段本身,也不讨论 Responses 请求失败后的传输 fallback。读完后,你应能沿 ThreadStartParams → Session::spawn_internal → ModelsManager::get_default_model → get_model_info 走通选择主线,并区分以下四个对象:显式请求模型、provider fallback、metadata fallback、model migration。

1. 四种回退 ​

“fallback”在当前源码中不是一个统一机制。

名称触发位置结果是否改写请求 slug
显式模型get_default_model直接保留调用方传入值否
provider fallbackStaticModelsManager从权威静态目录选择默认值是,且需显式允许
metadata fallbackget_model_info为未知 slug 构造保守能力描述否
model migrationTUI 启动提示用户接受后更新配置与推理强度接受后改写

这张图先把两个层次分开:provider fallback 决定“请求哪个模型”,metadata fallback 只决定“没有目录元数据时怎样描述这个请求模型”。后者不会把 unknown-model 改成另一个 slug。

2. 选择入口 ​

公开的 app-server 请求入口是 ThreadStartParams.model 和实验字段 allow_provider_model_fallback。这个布尔值的注释已经限定了适用范围:只有拥有权威静态目录的 provider 才能用它替换不可用模型。

源码位置:codex-rs/app-server-protocol/src/protocol/v2/thread.rs :: ThreadStartParams

rust
pub struct ThreadStartParams {
    #[ts(optional = nullable)]
    pub model: Option<String>,
    #[ts(optional = nullable)]
    pub model_provider: Option<String>,
    /// Allow a provider with an authoritative static model catalog to replace an unavailable
    /// requested model with its default.
    #[experimental("thread/start.allowProviderModelFallback")]
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub allow_provider_model_fallback: bool,
}

这个字段不会直接执行替换。app-server 把它传入 session,session 再把它传给 models manager;因此排查“请求模型为何改变”时,应该先检查调用方是否设置了该实验字段,再看 manager 实现,而不是从 TUI 的迁移提示开始查。

3. 会话选模 ​

Session::spawn_internal 是从请求到模型快照的真实主线。它先按 session source 选择刷新策略,必要时预热目录,然后调用 get_default_model,最后用得到的 slug 调 get_model_info。

源码位置:codex-rs/core/src/session/mod.rs :: Session::spawn_internal

rust
let refresh_strategy = if session_source.is_non_root_agent() {
    codex_models_manager::manager::RefreshStrategy::Offline
} else {
    codex_models_manager::manager::RefreshStrategy::OnlineIfUncached
};
if config.model.is_none()
    || !matches!(
        refresh_strategy,
        codex_models_manager::manager::RefreshStrategy::Offline
    )
{
    let _ = models_manager
        .list_models(refresh_strategy, config.http_client_factory())
        .await;
}
let model = models_manager
    .get_default_model(
        &config.model,
        allow_provider_model_fallback,
        refresh_strategy,
        config.http_client_factory(),
    )
    .await;
if allow_provider_model_fallback
    && let Some(requested_model) = config.model.as_ref()
    && model != *requested_model
{
    info!(
        model_provider = %config.model_provider_id,
        requested_model,
        fallback_model = %model,
        "replaced unavailable requested model with provider default"
    );
}
let model_info = models_manager
    .get_model_info(model.as_str(), &config.to_models_manager_config())
    .await;

生效时机在 session 创建期:一旦 model 被选出,后续 TurnContext 使用的是 get_model_info 返回的快照。日志只在实际替换时写入;如果显式模型被保留,不能从“没有 fallback 日志”推断模型一定出现在 picker 中。

4. 默认模型 ​

动态 OpenAiModelsManager 的 trait 默认实现对显式模型非常保守:只要 Option<String> 有值,就直接返回它。allow_provider_model_fallback 只记录在 span 中,不在这条实现里改写值。

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

rust
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 {
            if let Some(model) = model.as_ref() {
                return model.to_string();
            }
            default_model_from_available(
                self.list_models(refresh_strategy, http_client_factory)
                    .await,
            )
        }
        .instrument(tracing::info_span!(
            "get_default_model",
            model.provided = model.is_some(),
            allow_provider_model_fallback,
            refresh_strategy = %refresh_strategy
        )),
    )
}

没有显式模型时,默认值来自已经派生好的 ModelPreset。build_available_models 会按 priority 排序、按认证过滤,再由 mark_default_by_picker_visibility 标记第一个 picker-visible 模型;因此“默认模型”不是直接取 bundled JSON 的第一行。

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

rust
fn default_model_from_available(available: Vec<ModelPreset>) -> String {
    available
        .iter()
        .find(|model| model.is_default)
        .or_else(|| available.first())
        .map(|model| model.model.clone())
        .unwrap_or_default()
}

空目录时这里返回空字符串。这不是一个隐藏的“自动选择某个安全模型”分支;静态 manager 的测试明确把空目录和允许 fallback 组合起来,断言结果就是 ""。

5. 静态回退 ​

provider fallback 只在 StaticModelsManager 覆盖的 get_default_model 中真正执行。它先构造可用 preset,再根据 allow_provider_model_fallback 分成两种语义:允许时,只有请求模型出现在目录中才保留;不允许时,原字符串无条件保留。

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

rust
let available_models = self
    .list_models(refresh_strategy, http_client_factory)
    .await;
let requested_model = model.as_deref();

if allow_provider_model_fallback {
    if requested_model_is_available(requested_model, &available_models)
        && let Some(requested_model) = requested_model
    {
        return requested_model.to_string();
    }
    return default_model_from_available(available_models);
}

model
    .clone()
    .unwrap_or_else(|| default_model_from_available(available_models))

这里的目录是调用方注入的权威目录,不能与“模型目录加载”一文讨论的动态远端目录混为一谈。测试 static_manager_falls_back_from_unsupported_requested_model_when_allowed 输入两个静态模型和一个未列出的请求,断言返回 priority 更高的 provider-default;static_manager_preserves_unsupported_requested_model_when_fallback_is_disabled 则断言关闭开关后仍返回原字符串。

6. 未知元数据 ​

请求 slug 被保留后,manager 还要为它构造 ModelInfo。候选匹配会尝试最长前缀和单层 namespace;全部失败才调用 model_info_from_slug。这条路径提供的是能力描述,不是 provider 选择。

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

rust
pub fn model_info_from_slug(slug: &str) -> ModelInfo {
    warn!("Unknown model {slug} is used. This will use fallback model metadata.");
    ModelInfo {
        slug: slug.to_string(),
        display_name: slug.to_string(),
        description: None,
        default_reasoning_level: None,
        supported_reasoning_levels: Vec::new(),
        shell_type: ConfigShellToolType::Default,
        visibility: ModelVisibility::None,
        supported_in_api: true,
        priority: 99,
        service_tiers: Vec::new(),
        availability_nux: None,
        upgrade: None,
        model_messages: Some(local_model_messages_for_slug(slug)),
        web_search_tool_type: WebSearchToolType::Text,
        truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000),
        support_verbosity: false,
        default_verbosity: None,
        supports_reasoning_summary_parameter: true,
        context_window: Some(272_000),
        max_context_window: Some(272_000),
        auto_compact_token_limit: None,
        effective_context_window_percent: 95,
        input_modalities: default_input_modalities(),
        used_fallback_model_metadata: true,
        supports_search_tool: false,
        use_responses_lite: false,
        ..Default::default()
    }
}

这段代码的设计取舍是“允许请求继续走,但关闭未经目录证明的能力”:可见性为 None,并行工具和搜索关闭,窗口与截断使用保守默认值。get_model_info_tracks_fallback_usage 以一个 bundled 已知 slug 和一个未知 slug 为输入,分别断言标志为 false/true;它证明的是本地元数据归一化,不证明未知服务端模型真的支持这些默认能力。

7. 升级投影 ​

目录中的 ModelInfo.upgrade 经过 From<ModelInfo> for ModelPreset 投影为 ModelUpgrade。投影只携带目标模型和迁移文案;migration_config_key 使用当前 slug,供 TUI 的隐藏开关和已读记录索引。

源码位置:codex-rs/protocol/src/openai_models.rs :: From<ModelInfo> for ModelPreset

rust
upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade {
    id: upgrade.model.clone(),
    // todo(aibrahim): add the model link here.
    model_link: None,
    upgrade_copy: None,
    migration_config_key: info.slug.clone(),
    migration_markdown: Some(upgrade.migration_markdown.clone()),
}),
show_in_picker: info.visibility == ModelVisibility::List,

ModelInfo 的升级字段在两个消费者之间传递:TUI 需要 migration_config_key、文案和目标可见性;app-server 则把它转换成公开协议里的 upgrade_info。这解释了为什么一个目录字段的修改必须同时核对 TUI 测试和 app-server model list。

固定 models.json 中,隐藏的 GPT-5.4 目录条目带有目标 gpt-5.6-terra 和完整 migration_markdown;这不会在 manager 刷新时自动把 gpt-5.4 改成 Terra。只有 TUI 读取这个 upgrade 后,才会决定是否显示迁移对话框。

8. 提示门控 ​

TUI 不会因为 upgrade 字段存在就显示提示。should_show_model_migration_prompt 至少检查:目标不能等于当前模型;同一 from→to 不能已经记录;目标必须是 picker-visible;当前模型本身带 upgrade,或某个 preset 指向该目标。

源码位置:codex-rs/tui/src/app/startup_prompts.rs :: should_show_model_migration_prompt

rust
pub(super) fn should_show_model_migration_prompt(
    current_model: &str,
    target_model: &str,
    seen_migrations: &BTreeMap<String, String>,
    available_models: &[ModelPreset],
) -> bool {
    if target_model == current_model {
        return false;
    }

    if let Some(seen_target) = seen_migrations.get(current_model)
        && seen_target == target_model
    {
        return false;
    }

    if !available_models
        .iter()
        .any(|preset| preset.model == target_model && preset.show_in_picker)
    {
        return false;
    }

    if available_models
        .iter()
        .any(|preset| preset.model == current_model && preset.upgrade.is_some())
    {
        return true;
    }

    available_models
        .iter()
        .any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model))
}

隐藏模型也可能触发迁移提示:当前模型不需要 picker-visible,但目标必须可见。相反,目标缺失或被隐藏时,提示直接跳过。测试 model_migration_prompt_skips_when_target_missing_or_hidden 同时覆盖这两个否决条件。

9. 接受迁移 ​

用户接受迁移时,TUI 不只改 config.model。它先记录 from→to 已处理,再更新内存配置、发送模型和推理强度事件,最后持久化模型选择。目标默认推理强度来自目标 preset,而不是继续沿用旧模型的 effort。

源码位置:codex-rs/tui/src/app/startup_prompts.rs :: apply_accepted_model_migration

rust
pub(super) fn apply_accepted_model_migration(
    config: &mut Config,
    app_event_tx: &AppEventSender,
    from_model: String,
    target_model: String,
    target_default_effort: ReasoningEffortConfig,
) {
    app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
        from_model,
        to_model: target_model.clone(),
    });

    config.model = Some(target_model.clone());
    config.model_reasoning_effort = Some(target_default_effort.clone());
    app_event_tx.send(AppEvent::UpdateModel(target_model.clone()));
    app_event_tx.send(AppEvent::UpdateReasoningEffort(Some(
        target_default_effort.clone(),
    )));
    app_event_tx.send(AppEvent::PersistModelSelection {
        model: target_model,
        effort: Some(target_default_effort),
    });
}

持久化事件由 event dispatch 层分别处理:模型选择写入配置,迁移确认写入 [notice.model_migrations] 下的 from→to 映射。接受测试的输入是旧模型、目标模型和 Medium 默认强度,断言四个事件顺序及内存配置都已更新;它没有证明真实终端按键循环,因为该测试直接调用了提交函数。

10. 取消路径 ​

迁移提示有三种结果:接受、拒绝和退出。拒绝只记录该 from→to 已处理,不更改当前模型;Ctrl-C/Ctrl-D 或退出路径返回 AppExitInfo,不会提交模型选择。TUI 使用 alternate screen,Drop 时离开该屏幕,避免退出后污染终端 scrollback。

源码位置:codex-rs/tui/src/model_migration.rs :: ModelMigrationOutcome 与 run_model_migration_prompt

rust
pub(crate) enum ModelMigrationOutcome {
    Accepted,
    Rejected,
    Exit,
}

pub(crate) async fn run_model_migration_prompt(
    tui: &mut Tui,
    copy: ModelMigrationCopy,
) -> ModelMigrationOutcome {
    let alt = AltScreenGuard::enter(tui);
    let mut screen = ModelMigrationScreen::new(alt.tui.frame_requester(), copy);
    let _ = alt.tui.draw(u16::MAX, |frame| {
        frame.render_widget_ref(&screen, frame.area());
    });
    let events = alt.tui.event_stream();
    tokio::pin!(events);

    while !screen.is_done() {
        if let Some(event) = events.next().await {
            match event {
                TuiEvent::Key(key_event) => screen.handle_key(key_event),
                TuiEvent::Paste(_) => {}
                TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) => {
                    let _ = alt.tui.draw(u16::MAX, |frame| {
                        frame.render_widget_ref(&screen, frame.area());
                    });
                }
            }
        } else {
            screen.accept();
            break;
        }
    }
    screen.outcome()
}

源码位置:codex-rs/tui/src/app/startup_prompts.rs :: handle_model_migration_prompt_if_needed

rust
match run_model_migration_prompt(tui, prompt_copy).await {
    ModelMigrationOutcome::Accepted => {
        apply_accepted_model_migration(
            config,
            app_event_tx,
            model.to_string(),
            target_model.clone(),
            target_preset.default_reasoning_effort.clone(),
        );
    }
    ModelMigrationOutcome::Rejected => {
        app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
            from_model: model.to_string(),
            to_model: target_model.clone(),
        });
    }
    ModelMigrationOutcome::Exit => {
        return Some(AppExitInfo {
            token_usage: TokenUsage::default(),
            thread_id: None,
            resume_hint: None,
            update_action: None,
            exit_reason: ExitReason::UserRequested,
        });
    }
}

状态图强调提交边界:只有 Accepted 才改变当前模型和 reasoning effort;Rejected 与 Exited 都不会切换模型。

11. 测试边界 ​

层次输入与动作断言覆盖范围
manager静态目录、已列出模型、允许回退保留请求 slug静态目录中的可用性判断
manager静态目录、未列出模型、允许回退返回 provider defaultprovider fallback 选择
manager未列出模型、关闭回退保留原 slug开关关闭的边界
manager动态 manager、未列出模型、允许回退保留原 slug,fetch 为 0动态 manager 不执行 provider fallback
manager已知/未知 slug 调 get_model_infofallback 标志 false/truemetadata fallback 标记
TUI两个 deprecated preset提示只对非 self target 显示提示资格
TUItarget 缺失或 hidden不显示提示目标可见性门控
TUI接受迁移并传入 Medium更新配置并发出四个事件提交顺序和 effort 替换
app-serverthread/start fallback 集成测试初始化未在测试时限内完成当前测试没有覆盖初始化完成后的端到端模型回退断言

模型管理器测试覆盖静态回退、动态 manager 保留请求、metadata fallback;TUI 测试覆盖迁移资格和接受迁移后的事件顺序。app-server 的目标测试未完成初始化,因此当前测试不能支持初始化完成后的端到端模型回退结论。

12. 复现路径 ​

在本版本源码对应的 codex-rs workspace 中执行:

bash
RUST_MIN_STACK=16777216 cargo test -p codex-models-manager static_manager_
RUST_MIN_STACK=16777216 cargo test -p codex-models-manager dynamic_manager_preserves_requested_model_when_fallback_is_allowed
RUST_MIN_STACK=16777216 cargo test -p codex-models-manager get_model_info_tracks_fallback_usage
RUST_MIN_STACK=16777216 cargo test -p codex-tui model_migration_prompt_
RUST_MIN_STACK=16777216 cargo test -p codex-tui accepted_model_migration_persists_target_default_reasoning_effort

源码练习:先把静态目录测试中的 allow_provider_model_fallback 改为 false,观察未列出模型不再替换;再把迁移测试的目标 show_in_picker 改为 false,观察提示资格变为 false。调试真实模型变化时,第一步记录请求是否来自 static catalog,第二步检查 allow_provider_model_fallback,第三步区分 get_default_model 的结果和 get_model_info 的 used_fallback_model_metadata。

13. 源码导航 ​

继续阅读时,先回到 模型目录加载 确认 ModelPreset 的来源,再进入 models-manager/src/manager.rs 的匹配函数研究 namespace 和最长前缀,最后阅读 TUI 的 startup_prompts.rs 与 model_migration.rs,观察目录元数据如何变成用户决策和持久化事件。这样能保持“选择模型”“描述模型”“迁移模型”三个层次不混淆。