diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index fdaaf3e857..d52e850fa2 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -11144,6 +11144,14 @@ "defaultReasoningEffort": { "$ref": "#/definitions/v2/ReasoningEffort" }, + "defaultServiceTier": { + "default": null, + "description": "Catalog default service tier id for this model, when one is configured.", + "type": [ + "string", + "null" + ] + }, "description": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e413d79234..38adbe55ea 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -7693,6 +7693,14 @@ "defaultReasoningEffort": { "$ref": "#/definitions/ReasoningEffort" }, + "defaultServiceTier": { + "default": null, + "description": "Catalog default service tier id for this model, when one is configured.", + "type": [ + "string", + "null" + ] + }, "description": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json index c0221805eb..32ec2e7679 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json @@ -43,6 +43,14 @@ "defaultReasoningEffort": { "$ref": "#/definitions/ReasoningEffort" }, + "defaultServiceTier": { + "default": null, + "description": "Catalog default service tier id for this model, when one is configured.", + "type": [ + "string", + "null" + ] + }, "description": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts index 2354ffbf9e..e9dbf15b0b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts @@ -12,4 +12,8 @@ export type Model = { id: string, model: string, upgrade: string | null, upgrade /** * Deprecated: use `serviceTiers` instead. */ -additionalSpeedTiers: Array, serviceTiers: Array, isDefault: boolean, }; +additionalSpeedTiers: Array, serviceTiers: Array, +/** + * Catalog default service tier id for this model, when one is configured. + */ +defaultServiceTier: string | null, isDefault: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs index cd139e9c4b..7f97d791a6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/model.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -98,6 +98,9 @@ pub struct Model { pub additional_speed_tiers: Vec, #[serde(default)] pub service_tiers: Vec, + /// Catalog default service tier id for this model, when one is configured. + #[serde(default)] + pub default_service_tier: Option, // Only one model should be marked as default. pub is_default: bool, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4f9cb7c344..6bf8973f3d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -186,7 +186,7 @@ Example with notification opt-out: - `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`. - `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`. - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. -- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. +- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index 4d75a20580..e53f13df84 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -53,6 +53,7 @@ fn model_from_preset(preset: ModelPreset) -> Model { description: service_tier.description, }) .collect(), + default_service_tier: preset.default_service_tier, is_default: preset.is_default, } } diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index be7d5d047f..0a41daa7c6 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -30,6 +30,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { priority, additional_speed_tiers: preset.additional_speed_tiers.clone(), service_tiers: preset.service_tiers.clone(), + default_service_tier: preset.default_service_tier.clone(), upgrade: preset.upgrade.as_ref().map(Into::into), base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index d22bdd2a18..94dcca4377 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -69,6 +69,7 @@ fn model_from_preset(preset: &ModelPreset) -> Model { description: service_tier.description.clone(), }) .collect(), + default_service_tier: preset.default_service_tier.clone(), is_default: preset.is_default, } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 75c124e893..e7db8805b3 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -28,6 +28,7 @@ use codex_core::config::set_project_trust_level; use codex_exec_server::LOCAL_FS; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; @@ -484,7 +485,7 @@ model_reasoning_effort = "high" } #[tokio::test] -async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> { +async fn thread_start_drops_unsupported_service_tier_id() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; @@ -508,7 +509,39 @@ async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> { .await??; let ThreadStartResponse { service_tier, .. } = to_response::(resp)?; - assert_eq!(service_tier, Some(service_tier_id)); + // Unsupported catalog ids are dropped at session config time instead of echoed back. + assert_eq!(service_tier, None); + Ok(()) +} + +#[tokio::test] +async fn thread_start_accepts_default_service_tier() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + service_tier: Some(Some(ServiceTier::Default.request_value().to_string())), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { service_tier, .. } = to_response::(resp)?; + + assert_eq!( + service_tier, + Some(ServiceTier::Default.request_value().to_string()) + ); Ok(()) } diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index d2b31180b9..b1f6f6d4bb 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -76,6 +76,7 @@ async fn models_client_hits_models_endpoint() { priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 76ba338fc0..5180433f67 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -354,7 +354,7 @@ pub struct ConfigToml { pub personality: Option, /// Optional explicit service tier request id for new turns (for example - /// `priority` or `flex`; legacy `fast` also works). + /// `default`, `priority`, or `flex`; legacy `fast` also works). pub service_tier: Option, /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index 6cf35be68e..5f4c8d62f9 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -24,7 +24,7 @@ use codex_protocol::protocol::AskForApproval; pub struct ConfigProfile { pub model: Option, /// Optional explicit service tier request id for new turns (for example - /// `priority` or `flex`; legacy `fast` also works). + /// `default`, `priority`, or `flex`; legacy `fast` also works). pub service_tier: Option, /// The key in the `model_providers` map identifying the /// [`ModelProviderInfo`] to use. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 07f49a3cd6..4725689470 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -667,7 +667,7 @@ "$ref": "#/definitions/SandboxMode" }, "service_tier": { - "description": "Optional explicit service tier request id for new turns (for example `priority` or `flex`; legacy `fast` also works).", + "description": "Optional explicit service tier request id for new turns (for example `default`, `priority`, or `flex`; legacy `fast` also works).", "type": "string" }, "tools": { @@ -4726,7 +4726,7 @@ "description": "Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`." }, "service_tier": { - "description": "Optional explicit service tier request id for new turns (for example `priority` or `flex`; legacy `fast` also works).", + "description": "Optional explicit service tier request id for new turns (for example `default`, `priority`, or `flex`; legacy `fast` also works).", "type": "string" }, "shell_environment_policy": { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f604a63458..8575911d6c 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -741,8 +741,7 @@ impl ModelClient { prompt.output_schema_strict, ); let prompt_cache_key = Some(self.state.thread_id.to_string()); - let service_tier = - service_tier.filter(|service_tier| model_info.supports_service_tier(service_tier)); + let service_tier = model_info.service_tier_for_request(service_tier); let request = ResponsesApiRequest { model: model_info.slug.clone(), instructions: instructions.clone(), diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 21d291b309..10632af7a2 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -7942,7 +7942,7 @@ alpha = "one\ntwo" } #[tokio::test] -async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std::io::Result<()> { +async fn explicit_null_service_tier_override_maps_to_default_service_tier() -> std::io::Result<()> { let fixture = create_test_fixture()?; let config = Config::load_from_base_config_with_overrides( @@ -7956,8 +7956,33 @@ async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std: ) .await?; - assert_eq!(config.service_tier, None); - assert_eq!(config.notices.fast_default_opt_out, Some(true)); + assert_eq!( + config.service_tier, + Some(ServiceTier::Default.request_value().to_string()) + ); + assert_eq!(config.notices.fast_default_opt_out, None); + Ok(()) +} + +#[tokio::test] +async fn default_service_tier_override_uses_default_request_value() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + service_tier: Some(Some("default".to_string())), + ..Default::default() + }, + fixture.codex_home(), + ) + .await?; + + assert_eq!( + config.service_tier, + Some(ServiceTier::Default.request_value().to_string()) + ); Ok(()) } diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 5418d3b268..c9cb1a306e 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -40,8 +40,6 @@ pub enum ConfigEdit { SetNoticeHideFullAccessWarning(bool), /// Toggle the Windows world-writable directories warning acknowledgement flag. SetNoticeHideWorldWritableWarning(bool), - /// Toggle the opt-out marker for Codex-managed fast defaults. - SetNoticeFastDefaultOptOut(bool), /// Toggle the rate limit model nudge acknowledgement flag. SetNoticeHideRateLimitModelNudge(bool), /// Toggle the model migration prompt acknowledgement flag. @@ -553,6 +551,7 @@ impl ConfigDocument { &["service_tier"], service_tier.as_ref().map(|service_tier| { let config_value = match ServiceTier::from_request_value(service_tier) { + Some(ServiceTier::Default) => "default", Some(ServiceTier::Fast) => "fast", Some(ServiceTier::Flex) => "flex", None => service_tier.as_str(), @@ -574,11 +573,6 @@ impl ConfigDocument { &[NOTICE_TABLE_KEY, "hide_world_writable_warning"], value(*acknowledged), )), - ConfigEdit::SetNoticeFastDefaultOptOut(opted_out) => Ok(self.write_value( - Scope::Global, - &[NOTICE_TABLE_KEY, "fast_default_opt_out"], - value(*opted_out), - )), ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value( Scope::Global, &[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"], @@ -1182,12 +1176,6 @@ impl ConfigEditsBuilder { self } - pub fn set_fast_default_opt_out(mut self, opted_out: bool) -> Self { - self.edits - .push(ConfigEdit::SetNoticeFastDefaultOptOut(opted_out)); - self - } - pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self { self.edits .push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged)); diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 751411afdf..18d3657375 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -34,6 +34,20 @@ model_reasoning_effort = "high" assert_eq!(contents, expected); } +#[test] +fn set_service_tier_saves_default_as_default() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .set_service_tier(Some(ServiceTier::Default.request_value().to_string())) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, "service_tier = \"default\"\n"); +} + #[test] fn set_service_tier_saves_priority_as_fast() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 6eb66c4543..2d5513dd98 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -515,6 +515,7 @@ pub struct Config { pub model: Option, /// Effective service tier request id preference for new turns. + /// `default` means the user explicitly selected standard routing. pub service_tier: Option, /// Model used specifically for review sessions. @@ -3091,19 +3092,15 @@ impl Config { let forced_login_method = cfg.forced_login_method; let model = model.or(config_profile.model).or(cfg.model); - let mut notices = cfg.notice.unwrap_or_default(); + let notices = cfg.notice.unwrap_or_default(); let service_tier = match service_tier_override { Some(Some(service_tier)) => Some(service_tier), - Some(None) => { - // Preserve explicit standard/clear intent after the nested override - // collapses into `Config.service_tier = None`. - notices.fast_default_opt_out = Some(true); - None - } + Some(None) => Some(ServiceTier::Default.request_value().to_string()), None => config_profile.service_tier.or(cfg.service_tier), }; let service_tier = service_tier.and_then(|service_tier| { match ServiceTier::from_request_value(&service_tier) { + Some(ServiceTier::Default) => Some(ServiceTier::Default.request_value().to_string()), Some(ServiceTier::Fast) => features .enabled(Feature::FastMode) .then(|| ServiceTier::Fast.request_value().to_string()), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 02d9e75d4d..b9dbedb420 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -74,13 +74,13 @@ use codex_otel::current_span_trace_id; use codex_otel::current_span_w3c_trace_context; use codex_otel::set_parent_from_w3c_trace_context; use codex_protocol::ThreadId; -use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::NetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolResponse; @@ -320,7 +320,6 @@ use codex_otel::TelemetryAuthMode; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; @@ -598,14 +597,11 @@ impl Codex { developer_instructions: None, }, }; - let account_plan_type = auth_manager - .auth_cached() - .and_then(|auth| auth.account_plan_type()); let service_tier = get_service_tier( config.service_tier.clone(), config.notices.fast_default_opt_out.unwrap_or(false), - account_plan_type, config.features.enabled(Feature::FastMode), + &model_info, ); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), @@ -808,16 +804,16 @@ impl Codex { fn get_service_tier( configured_service_tier: Option, fast_default_opt_out: bool, - account_plan_type: Option, fast_mode_enabled: bool, + model_info: &ModelInfo, ) -> Option { - if configured_service_tier.is_some() || fast_default_opt_out || !fast_mode_enabled { - return configured_service_tier; + if !fast_mode_enabled { + return None; } - - account_plan_type - .is_some_and(is_enterprise_default_service_tier_plan) - .then_some(ServiceTier::Fast.request_value().to_string()) + if fast_default_opt_out && configured_service_tier.is_none() { + return Some(ServiceTier::Default.request_value().to_string()); + } + model_info.effective_service_tier(configured_service_tier) } fn session_permission_profile_state_from_config( @@ -826,12 +822,6 @@ fn session_permission_profile_state_from_config( Ok(config.permissions.permission_profile_state().clone()) } -fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool { - plan_type == AccountPlanType::Enterprise - || plan_type.is_business_like() - || plan_type.is_team_like() -} - #[cfg(test)] pub(crate) fn completed_session_loop_termination() -> SessionLoopTermination { futures::future::ready(()).boxed().shared() diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 925ee2df59..6556645c59 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -219,12 +219,15 @@ impl SessionConfiguration { if let Some(service_tier) = updates.service_tier.clone() { // TODO(aibrahim): Remove once v2 clients no longer send the legacy // "fast" service tier value. - next_configuration.service_tier = service_tier.map(|service_tier| { - ServiceTier::from_request_value(&service_tier) - .map_or(service_tier, |service_tier| { - service_tier.request_value().to_string() - }) - }); + next_configuration.service_tier = match service_tier { + Some(service_tier) => Some( + ServiceTier::from_request_value(&service_tier) + .map_or(service_tier, |service_tier| { + service_tier.request_value().to_string() + }), + ), + None => Some(ServiceTier::Default.request_value().to_string()), + }; } if let Some(personality) = updates.personality { next_configuration.personality = Some(personality); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index dbdc8aa8dd..5897634221 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -32,7 +32,6 @@ use codex_models_manager::test_support::get_model_offline_for_tests; use codex_protocol::AgentPath; use codex_protocol::SessionId; use codex_protocol::ThreadId; -use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::exec_output::ExecToolCallOutput; @@ -43,6 +42,7 @@ use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; +use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -3359,92 +3359,169 @@ fn session_telemetry( ) } +fn model_with_default_service_tier(default_service_tier: Option<&str>) -> ModelInfo { + let mut model_info = model_info::model_info_from_slug("gpt-5.4"); + model_info.service_tiers = vec![ModelServiceTier { + id: ServiceTier::Fast.request_value().to_string(), + name: "Fast".to_string(), + description: "Priority processing.".to_string(), + }]; + model_info.default_service_tier = default_service_tier.map(str::to_string); + model_info +} + #[test] -fn get_service_tier_defaults_enterprise_accounts_to_fast() { +fn get_service_tier_uses_model_default_when_absent_and_fast_mode_enabled() { + let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value())); + assert_eq!( get_service_tier( /*configured_service_tier*/ None, /*fast_default_opt_out*/ false, - Some(AccountPlanType::Enterprise), - /*fast_mode_enabled*/ true, - ), - Some(ServiceTier::Fast.request_value().to_string()) - ); - assert_eq!( - get_service_tier( - /*configured_service_tier*/ None, - /*fast_default_opt_out*/ false, - Some(AccountPlanType::EnterpriseCbpUsageBased), - /*fast_mode_enabled*/ true, - ), - Some(ServiceTier::Fast.request_value().to_string()) - ); - assert_eq!( - get_service_tier( - /*configured_service_tier*/ None, - /*fast_default_opt_out*/ false, - Some(AccountPlanType::Business), - /*fast_mode_enabled*/ true, - ), - Some(ServiceTier::Fast.request_value().to_string()) - ); - assert_eq!( - get_service_tier( - /*configured_service_tier*/ None, - /*fast_default_opt_out*/ false, - Some(AccountPlanType::Team), - /*fast_mode_enabled*/ true, - ), - Some(ServiceTier::Fast.request_value().to_string()) - ); - assert_eq!( - get_service_tier( - /*configured_service_tier*/ None, - /*fast_default_opt_out*/ false, - Some(AccountPlanType::SelfServeBusinessUsageBased), /*fast_mode_enabled*/ true, + &model_info, ), Some(ServiceTier::Fast.request_value().to_string()) ); } #[test] -fn get_service_tier_respects_fast_default_opt_out() { +fn get_service_tier_does_not_use_model_default_when_fast_mode_disabled() { + let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value())); + + assert_eq!( + get_service_tier( + /*configured_service_tier*/ None, + /*fast_default_opt_out*/ false, + /*fast_mode_enabled*/ false, + &model_info, + ), + None + ); +} + +#[test] +fn get_service_tier_respects_fast_default_opt_out_when_fast_mode_enabled() { + let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value())); + assert_eq!( get_service_tier( /*configured_service_tier*/ None, /*fast_default_opt_out*/ true, - Some(AccountPlanType::Enterprise), /*fast_mode_enabled*/ true, + &model_info, + ), + Some(ServiceTier::Default.request_value().to_string()) + ); +} + +#[test] +fn get_service_tier_stale_fast_default_opt_out_does_not_override_explicit_tier() { + let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value())); + + assert_eq!( + get_service_tier( + Some(ServiceTier::Fast.request_value().to_string()), + /*fast_default_opt_out*/ true, + /*fast_mode_enabled*/ true, + &model_info, + ), + Some(ServiceTier::Fast.request_value().to_string()) + ); +} + +#[test] +fn get_service_tier_does_not_default_when_model_has_no_default() { + let model_info = model_with_default_service_tier(/*default_service_tier*/ None); + + assert_eq!( + get_service_tier( + /*configured_service_tier*/ None, + /*fast_default_opt_out*/ false, + /*fast_mode_enabled*/ true, + &model_info, ), None ); } #[test] -fn get_service_tier_does_not_default_non_enterprise_or_disabled_fast_mode() { +fn get_service_tier_drops_unsupported_configured_tier_when_fast_mode_enabled() { + let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value())); + assert_eq!( get_service_tier( - /*configured_service_tier*/ None, + Some("unsupported".to_string()), /*fast_default_opt_out*/ false, - Some(AccountPlanType::Pro), /*fast_mode_enabled*/ true, + &model_info, + ), + None + ); + assert_eq!( + get_service_tier( + Some(ServiceTier::Flex.request_value().to_string()), + /*fast_default_opt_out*/ false, + /*fast_mode_enabled*/ true, + &model_info, + ), + None + ); + assert_eq!( + get_service_tier( + Some(ServiceTier::Default.request_value().to_string()), + /*fast_default_opt_out*/ false, + /*fast_mode_enabled*/ true, + &model_info, + ), + Some(ServiceTier::Default.request_value().to_string()) + ); +} + +#[test] +fn get_service_tier_ignores_configured_tier_when_fast_mode_disabled() { + let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value())); + + assert_eq!( + get_service_tier( + Some(ServiceTier::Fast.request_value().to_string()), + /*fast_default_opt_out*/ false, + /*fast_mode_enabled*/ false, + &model_info, + ), + None + ); + assert_eq!( + get_service_tier( + Some(ServiceTier::Default.request_value().to_string()), + /*fast_default_opt_out*/ false, + /*fast_mode_enabled*/ false, + &model_info, + ), + None + ); + assert_eq!( + get_service_tier( + Some("unsupported".to_string()), + /*fast_default_opt_out*/ false, + /*fast_mode_enabled*/ false, + &model_info, ), None ); assert_eq!( get_service_tier( /*configured_service_tier*/ None, - /*fast_default_opt_out*/ false, - Some(AccountPlanType::Enterprise), + /*fast_default_opt_out*/ true, /*fast_mode_enabled*/ false, + &model_info, ), None ); } #[tokio::test] -async fn session_settings_null_service_tier_update_clears_service_tier() { +async fn session_settings_null_service_tier_update_uses_default_service_tier() { let session_configuration = make_session_configuration_for_tests().await; let updated = session_configuration @@ -3454,7 +3531,10 @@ async fn session_settings_null_service_tier_update_clears_service_tier() { }) .expect("null service tier update should apply"); - assert_eq!(updated.service_tier, None); + assert_eq!( + updated.service_tier, + Some(ServiceTier::Default.request_value().to_string()) + ); } #[tokio::test] diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 8f0ce2d5fd..fa65dd159e 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -576,9 +576,12 @@ impl Session { )); let mut per_turn_config = per_turn_config; - per_turn_config.service_tier = per_turn_config - .service_tier - .filter(|service_tier| model_info.supports_service_tier(service_tier)); + per_turn_config.service_tier = get_service_tier( + per_turn_config.service_tier, + /*fast_default_opt_out*/ false, + per_turn_config.features.enabled(Feature::FastMode), + &model_info, + ); let per_turn_config = Arc::new(per_turn_config); let turn_metadata_state = Arc::new(TurnMetadataState::new( session_id.to_string(), diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 711db2fd11..2f5c1f4b5f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -26,6 +26,7 @@ fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset { name: "Fast".to_string(), description: "1.5x speed, increased usage".to_string(), }], + default_service_tier: None, is_default: false, upgrade: None, show_in_picker, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index adce6e36eb..4009dcb768 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -108,6 +108,7 @@ fn test_model_info( priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, @@ -387,6 +388,84 @@ async fn unsupported_service_tier_is_omitted_from_http_turn() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn default_service_tier_override_is_omitted_from_http_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let model_slug = "test-default-tier-model"; + let mut model = test_model_info( + model_slug, + model_slug, + "has catalog default service tier", + default_input_modalities(), + ); + model.service_tiers = vec![ModelServiceTier { + id: ServiceTier::Fast.request_value().to_string(), + name: "fast".to_string(), + description: "Fast processing.".to_string(), + }]; + model.default_service_tier = Some(ServiceTier::Fast.request_value().to_string()); + let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await; + + let mut builder = test_codex() + .with_model(model_slug) + .with_config(move |config| { + config.model_catalog = Some(ModelsResponse { + models: vec![model], + }); + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_service_tier("default turn", Some(ServiceTier::Default)) + .await?; + + let request = resp_mock.single_request(); + let body = request.body_json(); + assert_eq!(body.get("service_tier"), None); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn null_service_tier_override_is_omitted_from_http_turn_with_catalog_default() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let model_slug = "test-null-default-tier-model"; + let mut model = test_model_info( + model_slug, + model_slug, + "has catalog default service tier", + default_input_modalities(), + ); + model.service_tiers = vec![ModelServiceTier { + id: ServiceTier::Fast.request_value().to_string(), + name: "fast".to_string(), + description: "Fast processing.".to_string(), + }]; + model.default_service_tier = Some(ServiceTier::Fast.request_value().to_string()); + let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await; + + let mut builder = test_codex() + .with_model(model_slug) + .with_config(move |config| { + config.model_catalog = Some(ModelsResponse { + models: vec![model], + }); + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_service_tier("standard turn", /*service_tier*/ None) + .await?; + + let request = resp_mock.single_request(); + let body = request.body_json(); + assert_eq!(body.get("service_tier"), None); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<()> { skip_if_no_network!(Ok(())); @@ -874,6 +953,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 8463ee1cf1..d4c286aeda 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -342,6 +342,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { priority, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 09eb61fa69..7185caac1b 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -580,6 +580,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, upgrade: None, base_instructions: "base instructions".to_string(), model_messages: Some(ModelMessages { @@ -689,6 +690,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, upgrade: None, base_instructions: "base instructions".to_string(), model_messages: Some(ModelMessages { diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index d1caf24836..85fff95576 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -534,6 +534,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, @@ -791,6 +792,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, upgrade: None, base_instructions: remote_base.to_string(), model_messages: None, @@ -1282,6 +1284,7 @@ fn test_remote_model_with_policy( priority, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 7ead0364dc..6198d93089 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1270,6 +1270,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index c70f9bce37..c08b869211 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -70,6 +70,7 @@ fn test_model_info( priority: 1, additional_speed_tiers: Vec::new(), service_tiers, + default_service_tier: None, upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index d78e8761fd..d730768c94 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1352,6 +1352,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs index d6fe1de095..8fec7f835e 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs @@ -55,6 +55,7 @@ fn gpt_5_4_cmb_bedrock_model(priority: i32) -> ModelInfo { name: SPEED_TIER_FAST.to_string(), description: "Fastest inference with increased plan usage".to_string(), }], + default_service_tier: None, availability_nux: None, upgrade: None, base_instructions: BASE_INSTRUCTIONS.to_string(), @@ -96,6 +97,7 @@ fn bedrock_oss_model(slug: &str, display_name: &str, priority: i32) -> ModelInfo priority, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, availability_nux: None, upgrade: None, base_instructions: BASE_INSTRUCTIONS.to_string(), diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 774dd3eaca..17b843440a 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -77,6 +77,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { priority: 99, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, availability_nux: None, upgrade: None, base_instructions: BASE_INSTRUCTIONS.to_string(), diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 22c4f51548..3c0523d2d3 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -413,6 +413,7 @@ impl From for WebSearchConfig { #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum ServiceTier { + Default, Fast, Flex, } @@ -420,6 +421,7 @@ pub enum ServiceTier { impl ServiceTier { pub const fn request_value(self) -> &'static str { match self { + Self::Default => "default", Self::Fast => "priority", Self::Flex => "flex", } @@ -427,6 +429,7 @@ impl ServiceTier { pub fn from_request_value(value: &str) -> Option { match value { + "default" => Some(Self::Default), "fast" | "priority" => Some(Self::Fast), "flex" => Some(Self::Flex), _ => None, diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index d51e70ddf1..ea21aba692 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -147,6 +147,9 @@ pub struct ModelPreset { /// Service tiers this model can run with. #[serde(default)] pub service_tiers: Vec, + /// Catalog default service tier id for this model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_service_tier: Option, /// Whether this is the default model for new users. pub is_default: bool, /// recommended upgrade model @@ -270,6 +273,8 @@ pub struct ModelInfo { pub additional_speed_tiers: Vec, #[serde(default)] pub service_tiers: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_service_tier: Option, pub availability_nux: Option, pub upgrade: Option, pub base_instructions: String, @@ -455,6 +460,7 @@ impl From for ModelPreset { supports_personality, additional_speed_tiers: info.additional_speed_tiers, service_tiers: info.service_tiers, + default_service_tier: info.default_service_tier, is_default: false, // default is the highest priority available model upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade { id: upgrade.model.clone(), @@ -493,6 +499,32 @@ impl ModelInfo { .iter() .any(|tier| tier.id == service_tier) } + + pub fn effective_service_tier( + &self, + configured_service_tier: Option, + ) -> Option { + match configured_service_tier.as_deref() { + Some(service_tier) if service_tier == ServiceTier::Default.request_value() => { + configured_service_tier + } + Some(service_tier) if self.supports_service_tier(service_tier) => { + configured_service_tier + } + Some(_) => None, + None => self + .default_service_tier + .clone() + .filter(|service_tier| self.supports_service_tier(service_tier)), + } + } + + pub fn service_tier_for_request(&self, service_tier: Option) -> Option { + service_tier.filter(|service_tier| { + service_tier != ServiceTier::Default.request_value() + && self.supports_service_tier(service_tier) + }) + } } impl ModelPreset { @@ -576,6 +608,7 @@ mod tests { priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, availability_nux: None, upgrade: None, base_instructions: "base".to_string(), @@ -846,6 +879,7 @@ mod tests { message: "Try Spark.".to_string(), }), additional_speed_tiers: vec![SPEED_TIER_FAST.to_string()], + default_service_tier: Some(ServiceTier::Fast.request_value().to_string()), service_tiers: Vec::new(), ..test_model(/*spec*/ None) }); @@ -857,6 +891,10 @@ mod tests { }) ); assert!(preset.supports_fast_mode()); + assert_eq!( + preset.default_service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); } #[test] @@ -872,4 +910,118 @@ mod tests { assert!(preset.supports_fast_mode()); } + + #[test] + fn service_tier_resolution_uses_catalog_default_when_absent() { + let model = ModelInfo { + default_service_tier: Some(ServiceTier::Fast.request_value().to_string()), + service_tiers: vec![ModelServiceTier { + id: ServiceTier::Fast.request_value().to_string(), + name: "Fast".to_string(), + description: "Priority processing.".to_string(), + }], + ..test_model(/*spec*/ None) + }; + + assert_eq!( + model.effective_service_tier(/*configured_service_tier*/ None), + Some(ServiceTier::Fast.request_value().to_string()) + ); + } + + #[test] + fn service_tier_resolution_uses_supported_explicit_configured_tier() { + let model = ModelInfo { + default_service_tier: Some(ServiceTier::Fast.request_value().to_string()), + service_tiers: vec![ModelServiceTier { + id: ServiceTier::Fast.request_value().to_string(), + name: "Fast".to_string(), + description: "Priority processing.".to_string(), + }], + ..test_model(/*spec*/ None) + }; + + assert_eq!( + model.effective_service_tier(Some(ServiceTier::Fast.request_value().to_string())), + Some(ServiceTier::Fast.request_value().to_string()) + ); + } + + #[test] + fn service_tier_resolution_drops_unsupported_configured_tier() { + let model = ModelInfo { + default_service_tier: Some(ServiceTier::Fast.request_value().to_string()), + service_tiers: vec![ModelServiceTier { + id: ServiceTier::Fast.request_value().to_string(), + name: "Fast".to_string(), + description: "Priority processing.".to_string(), + }], + ..test_model(/*spec*/ None) + }; + + assert_eq!( + model.effective_service_tier(Some("unsupported".to_string())), + None + ); + } + + #[test] + fn service_tier_resolution_preserves_explicit_default_tier() { + let model = ModelInfo { + default_service_tier: Some(ServiceTier::Fast.request_value().to_string()), + service_tiers: vec![ModelServiceTier { + id: ServiceTier::Fast.request_value().to_string(), + name: "Fast".to_string(), + description: "Priority processing.".to_string(), + }], + ..test_model(/*spec*/ None) + }; + + assert_eq!( + model.effective_service_tier(Some(ServiceTier::Default.request_value().to_string())), + Some(ServiceTier::Default.request_value().to_string()) + ); + assert_eq!( + model.service_tier_for_request(Some(ServiceTier::Default.request_value().to_string())), + None + ); + } + + #[test] + fn service_tier_for_request_filters_unsupported_tiers() { + let model = ModelInfo { + default_service_tier: Some(ServiceTier::Fast.request_value().to_string()), + service_tiers: vec![ModelServiceTier { + id: ServiceTier::Fast.request_value().to_string(), + name: "Fast".to_string(), + description: "Priority processing.".to_string(), + }], + ..test_model(/*spec*/ None) + }; + + assert_eq!( + model.service_tier_for_request(Some(ServiceTier::Fast.request_value().to_string())), + Some(ServiceTier::Fast.request_value().to_string()) + ); + assert_eq!( + model.service_tier_for_request(Some("unsupported".to_string())), + None + ); + assert_eq!(model.service_tier_for_request(/*service_tier*/ None), None); + } + + #[test] + fn service_tier_resolution_ignores_unavailable_catalog_default() { + let model = ModelInfo { + default_service_tier: Some(ServiceTier::Fast.request_value().to_string()), + service_tiers: Vec::new(), + ..test_model(/*spec*/ None) + }; + + assert_eq!( + model.effective_service_tier(/*configured_service_tier*/ None), + None + ); + assert_eq!(model.service_tier_for_request(/*service_tier*/ None), None); + } } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ddb11c4520..f57b3bcbbe 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1315,9 +1315,6 @@ impl App { profile, service_tier.as_deref(), ); - if service_tier.is_none() { - self.config.notices.fast_default_opt_out = Some(true); - } match crate::config_update::write_config_batch(app_server.request_handle(), edits) .await { diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 2620f80d53..a83452638e 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -106,6 +106,7 @@ use codex_app_server_protocol::UserInput; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::approvals::GuardianAssessmentEvent; +use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; @@ -1090,6 +1091,7 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset { description: service_tier.description, }) .collect(), + default_service_tier: model.default_service_tier, is_default: model.is_default, upgrade, show_in_picker: !model.hidden, @@ -1150,11 +1152,10 @@ fn config_request_overrides_from_config( } fn service_tier_override_from_config(config: &Config) -> Option> { - config - .service_tier - .clone() - .map(Some) - .or_else(|| (config.notices.fast_default_opt_out == Some(true)).then_some(None)) + config.service_tier.clone().map(Some).or_else(|| { + (config.notices.fast_default_opt_out == Some(true)) + .then(|| Some(ServiceTier::Default.request_value().to_string())) + }) } fn sandbox_mode_from_permission_profile( diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 2fd376f139..703123beb0 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -1,6 +1,7 @@ //! User-message and shell-prompt submission behavior for `ChatWidget`. use super::*; +use codex_protocol::config_types::ServiceTier; impl ChatWidget { pub(super) fn user_message_from_submission( @@ -333,7 +334,9 @@ impl ChatWidget { .filter(|_| self.current_model_supports_personality()); let service_tier = match self.config.service_tier.clone() { Some(service_tier) => Some(Some(service_tier)), - None if self.config.notices.fast_default_opt_out == Some(true) => Some(None), + None if self.config.notices.fast_default_opt_out == Some(true) => { + Some(Some(ServiceTier::Default.request_value().to_string())) + } None => None, }; let active_permission_profile = self.config.permissions.active_permission_profile(); diff --git a/codex-rs/tui/src/chatwidget/service_tiers.rs b/codex-rs/tui/src/chatwidget/service_tiers.rs index a60af0a69e..915a734dbd 100644 --- a/codex-rs/tui/src/chatwidget/service_tiers.rs +++ b/codex-rs/tui/src/chatwidget/service_tiers.rs @@ -50,7 +50,7 @@ impl ChatWidget { return; }; let next_tier = if self.current_service_tier() == Some(fast_tier.id.as_str()) { - None + Some(ServiceTier::Default.request_value().to_string()) } else { Some(fast_tier.id) }; @@ -59,7 +59,7 @@ impl ChatWidget { pub(crate) fn toggle_service_tier_from_ui(&mut self, command: ServiceTierCommand) { let next_tier = if self.current_service_tier() == Some(command.id.as_str()) { - None + Some(ServiceTier::Default.request_value().to_string()) } else { Some(command.id) }; @@ -98,9 +98,6 @@ impl ChatWidget { } fn set_service_tier_selection(&mut self, service_tier: Option) { - if service_tier.is_none() { - self.config.notices.fast_default_opt_out = Some(true); - } self.set_service_tier(service_tier.clone()); self.app_event_tx .send(AppEvent::CodexOp(AppCommand::override_turn_context( diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 28ed69139b..ee8438b79f 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -247,15 +247,14 @@ pub(crate) fn set_chatgpt_auth(chat: &mut ChatWidget) { } fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> ModelInfo { - let service_tiers = if supports_fast_mode { - vec![json!({ + let mut service_tiers = Vec::new(); + if supports_fast_mode { + service_tiers.push(json!({ "id": ServiceTier::Fast.request_value(), "name": "fast", "description": "Fastest inference with increased plan usage" - })] - } else { - Vec::new() - }; + })); + } serde_json::from_value(json!({ "slug": slug, "display_name": slug, @@ -268,6 +267,7 @@ fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> Model "priority": priority, "additional_speed_tiers": [], "service_tiers": service_tiers, + "default_service_tier": null, "availability_nux": null, "upgrade": null, "base_instructions": "base instructions", diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 379ff7c6de..6007b8c84f 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2472,6 +2472,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() { supports_personality: false, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, is_default: false, upgrade: None, show_in_picker, @@ -2693,6 +2694,7 @@ async fn single_reasoning_option_skips_selection() { supports_personality: false, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, is_default: false, upgrade: None, show_in_picker: true, diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index e4865e64f3..82e9bff17c 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -79,8 +79,19 @@ fn next_add_to_history_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver {} - other => panic!("expected Op::UserTurn with standard service tier override, got {other:?}"), + } if service_tier == ServiceTier::Default.request_value() => {} + other => panic!("expected Op::UserTurn with default service tier override, got {other:?}"), } } diff --git a/codex-rs/tui/src/config_update.rs b/codex-rs/tui/src/config_update.rs index bcdbe29a0d..f9b90d5106 100644 --- a/codex-rs/tui/src/config_update.rs +++ b/codex-rs/tui/src/config_update.rs @@ -69,6 +69,7 @@ pub(crate) fn build_service_tier_selection_edits( |service_tier| { let config_value = match codex_protocol::config_types::ServiceTier::from_request_value(service_tier) { + Some(codex_protocol::config_types::ServiceTier::Default) => "default", Some(codex_protocol::config_types::ServiceTier::Fast) => "fast", Some(codex_protocol::config_types::ServiceTier::Flex) => "flex", None => service_tier, @@ -79,14 +80,7 @@ pub(crate) fn build_service_tier_selection_edits( ) }, ); - let mut edits = vec![service_tier_edit]; - if service_tier.is_none() { - edits.push(replace_config_value( - "notice.fast_default_opt_out", - serde_json::json!(true), - )); - } - edits + vec![service_tier_edit] } pub(crate) async fn write_config_batch(