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 1329684c4611..e2f7fc87bf74 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 @@ -11274,6 +11274,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 0fa156f01686..2c7340bcd8ef 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 @@ -7803,6 +7803,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 c0221805eb08..32ec2e76796d 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 2354ffbf9e38..e9dbf15b0b31 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 cd139e9c4b49..7f97d791a603 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 8ce55410a1a8..ab9b5c4d4e25 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -188,7 +188,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`. - `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index 4d75a2058063..e53f13df84e0 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 be7d5d047f9d..0a41daa7c67e 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 d22bdd2a188a..94dcca4377bb 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_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index 1bc3d742742b..aef2e961c082 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -22,6 +22,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_core::test_support::all_model_presets; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use core_test_support::responses; use pretty_assertions::assert_eq; use serde_json::Value; @@ -136,7 +137,7 @@ async fn thread_settings_update_while_turn_is_active_emits_notification() -> Res } #[tokio::test] -async fn thread_settings_update_clears_service_tier() -> Result<()> { +async fn thread_settings_update_null_service_tier_uses_default() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(vec![ create_final_assistant_message_sse_response("done")?, ]) @@ -181,7 +182,10 @@ async fn thread_settings_update_clears_service_tier() -> Result<()> { let clear_updated = read_thread_settings_updated(&mut mcp).await?; assert_eq!(clear_updated.thread_id, thread.id); assert_eq!(clear_updated.thread_settings.model, model_id); - assert_eq!(clear_updated.thread_settings.service_tier, None); + assert_eq!( + clear_updated.thread_settings.service_tier.as_deref(), + Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE) + ); start_text_turn(&mut mcp, thread.id).await?; timeout( 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 75c124e8931f..ecd8ad8e3d64 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::SERVICE_TIER_DEFAULT_REQUEST_VALUE; 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(SERVICE_TIER_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(SERVICE_TIER_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 d2b31180b907..b1f6f6d4bbf6 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 43f75ffc289e..a4d384b5f39e 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -359,7 +359,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 6cf35be68e2d..5f4c8d62f910 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 c2f6218fd250..a71f84f27779 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -686,7 +686,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": { @@ -4783,7 +4783,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 e3569e6d18a2..cbffab7b33b0 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -748,8 +748,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 19fd762da916..66ca4daa37b4 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -70,6 +70,7 @@ use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; use codex_network_proxy::NetworkMode; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; @@ -8277,7 +8278,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( @@ -8291,8 +8292,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(SERVICE_TIER_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(SERVICE_TIER_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 5418d3b2689e..f467c76cf976 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. @@ -552,6 +550,8 @@ impl ConfigDocument { ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_profile_value( &["service_tier"], service_tier.as_ref().map(|service_tier| { + // Keep the legacy config spelling stable. Runtime values use + // `priority`, but config.toml continues to store it as `fast`. let config_value = match ServiceTier::from_request_value(service_tier) { Some(ServiceTier::Fast) => "fast", Some(ServiceTier::Flex) => "flex", @@ -574,11 +574,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 +1177,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 751411afdf01..30f82db29ba2 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -4,6 +4,7 @@ use codex_config::types::McpServerOAuthConfig; use codex_config::types::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; use codex_config::types::SessionPickerViewMode; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; @@ -34,6 +35,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(SERVICE_TIER_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 ff09f26c2845..305ae3b75c43 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -81,6 +81,7 @@ use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::ShellEnvironmentPolicy; @@ -552,6 +553,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. @@ -3169,15 +3171,10 @@ 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(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()), None => config_profile.service_tier.or(cfg.service_tier), }; let service_tier = service_tier.and_then(|service_tier| { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 55fd5444c6d2..c12c29e7e174 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -72,7 +72,6 @@ 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; @@ -80,6 +79,7 @@ use codex_protocol::approvals::NetworkPolicyRuleAction; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; 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; @@ -589,14 +588,10 @@ 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(), @@ -798,17 +793,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()) + configured_service_tier.filter(|service_tier| { + service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE + || model_info.supports_service_tier(service_tier) + }) } fn session_permission_profile_state_from_config( @@ -817,12 +811,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 7ae5ff241d4e..cac4806bedbc 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -5,6 +5,7 @@ use crate::goals::GoalRuntimeState; use crate::skills::SkillError; use crate::state::ActiveTurn; use codex_protocol::SessionId; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSpecialPath; @@ -223,12 +224,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(SERVICE_TIER_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 e669becc4c9a..d4d0c286608f 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -32,7 +32,7 @@ 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::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::exec_output::ExecToolCallOutput; @@ -43,6 +43,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; @@ -3390,92 +3391,143 @@ 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_does_not_use_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, + &model_info, ), - Some(ServiceTier::Fast.request_value().to_string()) + None ); +} + +#[test] +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, - Some(AccountPlanType::EnterpriseCbpUsageBased), - /*fast_mode_enabled*/ true, + /*fast_mode_enabled*/ false, + &model_info, ), - Some(ServiceTier::Fast.request_value().to_string()) + None ); +} + +#[test] +fn get_service_tier_keeps_supported_explicit_tier() { + 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::Business), + Some(ServiceTier::Fast.request_value().to_string()), /*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, - Some(AccountPlanType::Team), /*fast_mode_enabled*/ true, + &model_info, ), - Some(ServiceTier::Fast.request_value().to_string()) + None ); +} + +#[test] +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, - /*fast_default_opt_out*/ false, - Some(AccountPlanType::SelfServeBusinessUsageBased), + Some("unsupported".to_string()), /*fast_mode_enabled*/ true, + &model_info, ), - Some(ServiceTier::Fast.request_value().to_string()) + None ); -} - -#[test] -fn get_service_tier_respects_fast_default_opt_out() { assert_eq!( get_service_tier( - /*configured_service_tier*/ None, - /*fast_default_opt_out*/ true, - Some(AccountPlanType::Enterprise), + Some(ServiceTier::Flex.request_value().to_string()), /*fast_mode_enabled*/ true, + &model_info, ), None ); + assert_eq!( + get_service_tier( + Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()), + /*fast_mode_enabled*/ true, + &model_info, + ), + Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) + ); } #[test] -fn get_service_tier_does_not_default_non_enterprise_or_disabled_fast_mode() { +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( - /*configured_service_tier*/ None, - /*fast_default_opt_out*/ false, - Some(AccountPlanType::Pro), - /*fast_mode_enabled*/ true, + Some(ServiceTier::Fast.request_value().to_string()), + /*fast_mode_enabled*/ false, + &model_info, + ), + None + ); + assert_eq!( + get_service_tier( + Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()), + /*fast_mode_enabled*/ false, + &model_info, + ), + None + ); + assert_eq!( + get_service_tier( + Some("unsupported".to_string()), + /*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_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 @@ -3485,7 +3537,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(SERVICE_TIER_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 818a8b7bbe3a..de380831358e 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -473,9 +473,11 @@ 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, + 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 6f9eac1fff11..d90cdc95e052 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/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 5f5c7ee85a86..a39875829171 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -28,7 +28,6 @@ use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::built_in_model_providers; use codex_models_manager::bundled_models_response; -use codex_protocol::config_types::ServiceTier; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; @@ -667,13 +666,13 @@ impl TestCodex { pub async fn submit_turn_with_service_tier( &self, prompt: &str, - service_tier: Option, + service_tier: Option<&str>, ) -> Result<()> { self.submit_turn_with_permission_profile_context( prompt, AskForApproval::Never, PermissionProfile::Disabled, - Some(service_tier.map(|service_tier| service_tier.request_value().to_string())), + Some(service_tier.map(str::to_string)), /*environments*/ None, ) .await diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 6e985eebe0b6..6fe697d2b164 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -269,7 +269,7 @@ async fn websocket_v2_first_turn_uses_updated_fast_tier_after_startup_prewarm() assert_eq!(warmup["generate"].as_bool(), Some(false)); assert_eq!(warmup.get("service_tier"), None); - test.submit_turn_with_service_tier("hello", Some(ServiceTier::Fast)) + test.submit_turn_with_service_tier("hello", Some(ServiceTier::Fast.request_value())) .await?; assert_eq!(server.handshakes().len(), 1); @@ -385,7 +385,7 @@ async fn websocket_v2_next_turn_uses_updated_service_tier() -> Result<()> { assert_eq!(warmup["generate"].as_bool(), Some(false)); assert_eq!(warmup.get("service_tier"), None); - test.submit_turn_with_service_tier("first", Some(ServiceTier::Fast)) + test.submit_turn_with_service_tier("first", Some(ServiceTier::Fast.request_value())) .await?; test.submit_turn_with_service_tier("second", /*service_tier*/ None) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 009dfda60f52..7e77fb79d362 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -4,6 +4,7 @@ use codex_features::Feature; use codex_login::CodexAuth; use codex_models_manager::manager::RefreshStrategy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ConfigShellToolType; @@ -113,6 +114,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, @@ -289,7 +291,7 @@ async fn service_tier_change_is_applied_on_next_http_turn() -> Result<()> { let test = test_codex().build(&server).await?; - test.submit_turn_with_service_tier("fast turn", Some(ServiceTier::Fast)) + test.submit_turn_with_service_tier("fast turn", Some(ServiceTier::Fast.request_value())) .await?; test.submit_turn_with_service_tier("standard turn", /*service_tier*/ None) .await?; @@ -334,7 +336,7 @@ async fn flex_service_tier_is_applied_to_http_turn() -> Result<()> { }); let test = builder.build(&server).await?; - test.submit_turn_with_service_tier("flex turn", Some(ServiceTier::Flex)) + test.submit_turn_with_service_tier("flex turn", Some(ServiceTier::Flex.request_value())) .await?; let request = resp_mock.single_request(); @@ -367,7 +369,85 @@ async fn unsupported_service_tier_is_omitted_from_http_turn() -> Result<()> { }); let test = builder.build(&server).await?; - test.submit_turn_with_service_tier("fast turn", Some(ServiceTier::Fast)) + test.submit_turn_with_service_tier("fast turn", Some(ServiceTier::Fast.request_value())) + .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 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(SERVICE_TIER_DEFAULT_REQUEST_VALUE)) + .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(); @@ -864,6 +944,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 3b729738df38..6d11b47cba7a 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -347,6 +347,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 bbe8178f8d8c..96695769c9a1 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -562,6 +562,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 { @@ -671,6 +672,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 e143cb8e3ed9..18ebd732b7ff 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -475,6 +475,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, @@ -722,6 +723,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, @@ -1203,6 +1205,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 210c287c2f47..c00da7723653 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1275,6 +1275,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 9a7d70adebb1..fbeb90fba50c 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -63,6 +63,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 6306b88b8f19..867ba108588b 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1357,6 +1357,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 d6fe1de09532..8fec7f835e47 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 774dd3eacafc..17b843440aa7 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 b4b4759c9b71..550dbdaf3a98 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -432,6 +432,12 @@ pub enum ServiceTier { Flex, } +/// Request/config sentinel for explicit standard routing. +/// +/// This is not a catalog service tier id. It means the user intentionally +/// selected no service tier, so model catalog defaults should not apply. +pub const SERVICE_TIER_DEFAULT_REQUEST_VALUE: &str = "default"; + impl ServiceTier { pub const fn request_value(self) -> &'static str { match self { diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index d51e70ddf16f..d0a58b502463 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -17,6 +17,7 @@ use ts_rs::TS; use crate::config_types::Personality; use crate::config_types::ReasoningSummary; +use crate::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use crate::config_types::ServiceTier; use crate::config_types::Verbosity; @@ -147,6 +148,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 +274,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 +461,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 +500,13 @@ impl ModelInfo { .iter() .any(|tier| tier.id == service_tier) } + + pub fn service_tier_for_request(&self, service_tier: Option) -> Option { + service_tier.filter(|service_tier| { + service_tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE + && self.supports_service_tier(service_tier) + }) + } } impl ModelPreset { @@ -576,6 +590,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 +861,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 +873,10 @@ mod tests { }) ); assert!(preset.supports_fast_mode()); + assert_eq!( + preset.default_service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); } #[test] @@ -872,4 +892,60 @@ mod tests { assert!(preset.supports_fast_mode()); } + + #[test] + fn service_tier_for_request_omits_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.service_tier_for_request(Some(SERVICE_TIER_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_for_request_does_not_apply_catalog_default() { + 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(/*service_tier*/ None), None); + } } diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 8ce68acbbad1..033bf07b10b0 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -22,6 +22,7 @@ fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo { priority: 0, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, availability_nux: None, upgrade: None, base_instructions: String::new(), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 51615f6a956f..bdd7b46917d2 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -767,6 +767,8 @@ impl App { self.chat_widget.set_model(&model); self.sync_active_thread_model_setting(app_server, model) .await; + self.sync_active_thread_service_tier_to_cached_session() + .await; } AppEvent::UpdatePersonality(personality) => { self.on_update_personality(personality); @@ -1327,9 +1329,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/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 6c8789443402..bf5ffdad36b2 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -656,7 +656,6 @@ impl App { pub(super) fn fresh_session_config(&self) -> Config { let mut config = self.config.clone(); config.service_tier = self.chat_widget.configured_service_tier(); - config.notices.fast_default_opt_out = self.chat_widget.fast_default_opt_out(); config } pub(super) async fn resume_target_session( diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 492639f1397f..04831e16e495 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -464,7 +464,6 @@ impl App { } fork_config.model_reasoning_effort = self.chat_widget.current_reasoning_effort(); fork_config.service_tier = self.chat_widget.configured_service_tier(); - fork_config.notices.fast_default_opt_out = self.chat_widget.fast_default_opt_out(); fork_config.ephemeral = true; fork_config.developer_instructions = Some(Self::side_developer_instructions( fork_config.developer_instructions.as_deref(), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 9eca24e051ba..4597c6091388 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -6,6 +6,7 @@ use crate::bottom_pane::FeedbackAudience; use crate::legacy_core::config::Config; use crate::permission_compat::legacy_compatible_permission_profile; +use crate::service_tier_resolution; use crate::session_state::MessageHistoryMetadata; use crate::session_state::ThreadSessionState; use crate::status::StatusAccountDisplay; @@ -107,6 +108,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::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; @@ -164,6 +166,8 @@ pub(crate) struct AppServerSession { remote_cwd_override: Option, thread_params_mode: ThreadParamsMode, thread_settings_update_supported: bool, + default_model: Option, + available_models: Vec, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -205,6 +209,8 @@ impl AppServerSession { remote_cwd_override: None, thread_params_mode, thread_settings_update_supported: true, + default_model: None, + available_models: Vec::new(), } } @@ -254,6 +260,8 @@ impl AppServerSession { }) .or_else(|| available_models.first().map(|model| model.model.clone())) .wrap_err("model/list returned no models for TUI bootstrap")?; + self.default_model = Some(default_model.clone()); + self.available_models = available_models.clone(); let ( account_email, @@ -365,12 +373,13 @@ impl AppServerSession { session_start_source: Option, ) -> Result { let request_id = self.next_request_id(); + let session_config = self.session_config_with_effective_service_tier(config); let response: ThreadStartResponse = self .client .request_typed(ClientRequest::ThreadStart { request_id, params: thread_start_params_from_config( - config, + &session_config, self.thread_params_mode(), self.remote_cwd_override.as_deref(), session_start_source, @@ -389,12 +398,13 @@ impl AppServerSession { thread_id: ThreadId, ) -> Result { let request_id = self.next_request_id(); + let session_config = self.session_config_with_effective_service_tier(&config); let response: ThreadResumeResponse = self .client .request_typed(ClientRequest::ThreadResume { request_id, params: thread_resume_params_from_config( - config.clone(), + session_config, thread_id, self.thread_params_mode(), self.remote_cwd_override.as_deref(), @@ -420,12 +430,13 @@ impl AppServerSession { thread_id: ThreadId, ) -> Result { let request_id = self.next_request_id(); + let session_config = self.session_config_with_effective_service_tier(&config); let response: ThreadForkResponse = self .client .request_typed(ClientRequest::ThreadFork { request_id, params: thread_fork_params_from_config( - config.clone(), + session_config, thread_id, self.thread_params_mode(), self.remote_cwd_override.as_deref(), @@ -448,6 +459,32 @@ impl AppServerSession { self.thread_params_mode } + fn session_config_with_effective_service_tier(&self, config: &Config) -> Config { + let Some(model) = config.model.as_deref().or(self.default_model.as_deref()) else { + return config.clone(); + }; + let mut session_config = config.clone(); + match service_tier_resolution::service_tier_update_for_core( + config, + model, + &self.available_models, + ) { + Some(Some(service_tier)) => { + session_config.service_tier = Some(service_tier); + session_config.notices.fast_default_opt_out = None; + } + Some(None) => { + session_config.service_tier = Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()); + session_config.notices.fast_default_opt_out = None; + } + None => { + session_config.service_tier = None; + session_config.notices.fast_default_opt_out = None; + } + } + session_config + } + async fn fork_parent_title_from_app_server( &mut self, forked_from_id: Option<&str>, @@ -1159,6 +1196,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, @@ -1219,11 +1257,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(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())) + }) } fn sandbox_mode_from_permission_profile( diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 56bdc6bf6898..ef90c85d4c4c 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -64,7 +64,11 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.to_path_buf()); - let effective_service_tier = config.service_tier.clone(); + let effective_service_tier = crate::service_tier_resolution::effective_service_tier( + &config, + &header_model, + &model_catalog.try_list_models().unwrap_or_default(), + ); let current_terminal_info = terminal_info(); let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok(); let default_keymap = RuntimeKeymap::defaults(); diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 2fd376f139bc..2c960ee3d52d 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -331,11 +331,7 @@ impl ChatWidget { .personality .filter(|_| self.config.features.enabled(Feature::Personality)) .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 => None, - }; + let service_tier = self.service_tier_update_for_core(); let active_permission_profile = self.config.permissions.active_permission_profile(); let op = AppCommand::user_turn( items, diff --git a/codex-rs/tui/src/chatwidget/service_tiers.rs b/codex-rs/tui/src/chatwidget/service_tiers.rs index fd11048cb242..7338673aa340 100644 --- a/codex-rs/tui/src/chatwidget/service_tiers.rs +++ b/codex-rs/tui/src/chatwidget/service_tiers.rs @@ -4,14 +4,16 @@ use super::ChatWidget; use crate::app_command::AppCommand; use crate::app_event::AppEvent; use crate::bottom_pane::slash_commands::ServiceTierCommand; +use crate::service_tier_resolution; use codex_features::Feature; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::SPEED_TIER_FAST; impl ChatWidget { pub(crate) fn set_service_tier(&mut self, service_tier: Option) { - self.config.service_tier = service_tier.clone(); - self.effective_service_tier = service_tier; + self.config.service_tier = service_tier; + self.refresh_effective_service_tier(); self.refresh_model_dependent_surfaces(); } @@ -23,8 +25,12 @@ impl ChatWidget { self.config.service_tier.clone() } - pub(crate) fn fast_default_opt_out(&self) -> Option { - self.config.notices.fast_default_opt_out + pub(crate) fn service_tier_update_for_core(&self) -> Option> { + service_tier_resolution::service_tier_update_for_core( + &self.config, + self.current_model(), + &self.model_catalog.try_list_models().unwrap_or_default(), + ) } pub(crate) fn should_show_fast_status(&self, model: &str, service_tier: Option<&str>) -> bool { @@ -50,7 +56,7 @@ impl ChatWidget { return; }; let next_tier = if self.current_service_tier() == Some(fast_tier.id.as_str()) { - None + Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) } else { Some(fast_tier.id) }; @@ -59,7 +65,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(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) } else { Some(command.id) }; @@ -98,9 +104,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( @@ -144,4 +147,12 @@ impl ChatWidget { .into_iter() .find(|tier| tier.name.eq_ignore_ascii_case(SPEED_TIER_FAST)) } + + pub(super) fn refresh_effective_service_tier(&mut self) { + self.effective_service_tier = service_tier_resolution::effective_service_tier( + &self.config, + self.current_model(), + &self.model_catalog.try_list_models().unwrap_or_default(), + ); + } } diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 0df36f6bf890..1fddad6275bd 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -66,6 +66,7 @@ impl ChatWidget { } } if feature == Feature::FastMode { + self.refresh_effective_service_tier(); self.sync_service_tier_commands(); } if feature == Feature::Personality { @@ -238,6 +239,7 @@ impl ChatWidget { { mask.model = Some(model.to_string()); } + self.refresh_effective_service_tier(); self.refresh_model_dependent_surfaces(); } @@ -519,6 +521,7 @@ impl ChatWidget { settings.collaboration_mode.settings.model = settings.model; settings.collaboration_mode.settings.reasoning_effort = settings.effort; self.set_effective_collaboration_mode(settings.collaboration_mode); + self.refresh_effective_service_tier(); self.refresh_status_surfaces(); self.sync_service_tier_commands(); self.sync_personality_command_enabled(); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 77aa742c0161..5d27e0ddc596 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -144,6 +144,7 @@ pub(super) use codex_protocol::approvals::GuardianUserAuthorization; pub(super) use codex_protocol::config_types::CollaborationMode; pub(super) use codex_protocol::config_types::ModeKind; pub(super) use codex_protocol::config_types::Personality; +pub(super) use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; pub(super) use codex_protocol::config_types::ServiceTier; pub(super) use codex_protocol::models::ActivePermissionProfile; pub(super) use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 79e65173f93c..63487f18b9e8 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -246,15 +246,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, @@ -267,6 +266,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 2a25415c032b..09d911ad16d0 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2279,6 +2279,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, @@ -2500,6 +2501,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 c8c0f6058425..74d965ae5cc7 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -91,8 +91,19 @@ fn next_add_to_history_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver {} + other => panic!("expected Op::UserTurn with default service tier override, got {other:?}"), + } +} + #[tokio::test] async fn queued_fast_slash_applies_before_next_queued_message() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; @@ -2194,18 +2246,20 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() { events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::OverrideTurnContext { - service_tier: Some(None), + service_tier: Some(Some(service_tier)), .. - }) + }) if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE )), - "expected fast-mode off override app event; events: {events:?}" + "expected fast-mode off default service tier app event; events: {events:?}" ); assert!( events.iter().any(|event| matches!( event, - AppEvent::PersistServiceTierSelection { service_tier: None } + AppEvent::PersistServiceTierSelection { + service_tier: Some(service_tier) + } if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE )), - "expected fast-mode opt-out persistence app event; events: {events:?}" + "expected default service tier persistence app event; events: {events:?}" ); chat.bottom_pane @@ -2214,10 +2268,10 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() { match next_submit_op(&mut op_rx) { Op::UserTurn { - service_tier: Some(None), + service_tier: Some(Some(service_tier)), .. - } => {} - other => panic!("expected Op::UserTurn with standard service tier override, got {other:?}"), + } if service_tier == SERVICE_TIER_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 cac79f648e3a..f3dc0dc5fc27 100644 --- a/codex-rs/tui/src/config_update.rs +++ b/codex-rs/tui/src/config_update.rs @@ -13,6 +13,7 @@ use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SkillsConfigWriteParams; use codex_app_server_protocol::SkillsConfigWriteResponse; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -75,26 +76,22 @@ pub(crate) fn build_service_tier_selection_edits( let service_tier_edit = service_tier.map_or_else( || clear_config_value(profile_scoped_key_path(profile, "service_tier")), |service_tier| { - let config_value = + let config_value = if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE { + SERVICE_TIER_DEFAULT_REQUEST_VALUE + } else { match codex_protocol::config_types::ServiceTier::from_request_value(service_tier) { Some(codex_protocol::config_types::ServiceTier::Fast) => "fast", Some(codex_protocol::config_types::ServiceTier::Flex) => "flex", None => service_tier, - }; + } + }; replace_config_value( profile_scoped_key_path(profile, "service_tier"), serde_json::json!(config_value), ) }, ); - 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( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 40cd121c7135..cbd19702bf0e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -164,6 +164,7 @@ mod render; mod resize_reflow_cap; mod resume_picker; mod selection_list; +mod service_tier_resolution; mod session_log; mod session_resume; mod session_state; diff --git a/codex-rs/tui/src/service_tier_resolution.rs b/codex-rs/tui/src/service_tier_resolution.rs new file mode 100644 index 000000000000..1e65fd3fb07e --- /dev/null +++ b/codex-rs/tui/src/service_tier_resolution.rs @@ -0,0 +1,64 @@ +use crate::legacy_core::config::Config; +use codex_features::Feature; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; +use codex_protocol::openai_models::ModelPreset; + +pub(crate) fn configured_service_tier(config: &Config) -> Option { + config.service_tier.clone().or_else(|| { + (config.notices.fast_default_opt_out == Some(true)) + .then(|| SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) + }) +} + +pub(crate) fn effective_service_tier( + config: &Config, + model: &str, + models: &[ModelPreset], +) -> Option { + if !config.features.enabled(Feature::FastMode) { + return None; + } + + let configured = configured_service_tier(config); + let Some(preset) = models.iter().find(|preset| preset.model == model) else { + return configured; + }; + + match configured.as_deref() { + Some(service_tier) if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE => configured, + Some(service_tier) if model_supports_service_tier(preset, service_tier) => configured, + Some(_) => None, + None => preset + .default_service_tier + .clone() + .filter(|service_tier| model_supports_service_tier(preset, service_tier)), + } +} + +pub(crate) fn service_tier_update_for_core( + config: &Config, + model: &str, + models: &[ModelPreset], +) -> Option> { + if !config.features.enabled(Feature::FastMode) { + return None; + } + + let effective = effective_service_tier(config, model, models); + if let Some(service_tier) = effective { + return Some(Some(service_tier)); + } + + if !models.iter().any(|preset| preset.model == model) { + return None; + } + + Some(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())) +} + +pub(crate) fn model_supports_service_tier(model: &ModelPreset, service_tier: &str) -> bool { + model + .service_tiers + .iter() + .any(|tier| tier.id == service_tier) +}