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 5e24845729..d9daa3b6b1 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 @@ -7157,6 +7157,12 @@ "null" ] }, + "service_tier_id": { + "type": [ + "string", + "null" + ] + }, "tools": { "anyOf": [ { @@ -12829,6 +12835,12 @@ "null" ] }, + "service_tier_id": { + "type": [ + "string", + "null" + ] + }, "tools": { "anyOf": [ { 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 6153a54eb9..7b6412de92 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 @@ -3613,6 +3613,12 @@ "null" ] }, + "service_tier_id": { + "type": [ + "string", + "null" + ] + }, "tools": { "anyOf": [ { @@ -9440,6 +9446,12 @@ "null" ] }, + "service_tier_id": { + "type": [ + "string", + "null" + ] + }, "tools": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 87a826e5af..f5fabb42c6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -357,6 +357,12 @@ "null" ] }, + "service_tier_id": { + "type": [ + "string", + "null" + ] + }, "tools": { "anyOf": [ { @@ -659,6 +665,12 @@ "null" ] }, + "service_tier_id": { + "type": [ + "string", + "null" + ] + }, "tools": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index cc7e340ea3..31eae0af4a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -19,4 +19,4 @@ export type Config = {model: string | null, review_model: string | null, model_c * [UNSTABLE] Optional default for where approval requests are routed for * review. */ -approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, service_tier_id: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts index d05038701c..572a5dae3e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts @@ -15,4 +15,4 @@ export type ProfileV2 = {model: string | null, model_provider: string | null, ap * are routed for review. If omitted, the enclosing config default is * used. */ -approvals_reviewer: ApprovalsReviewer | null, service_tier: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, service_tier: string | null, service_tier_id: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 8bc50bb1f2..10d3897fc3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -137,6 +137,7 @@ pub struct ProfileV2 { #[experimental("config/read.approvalsReviewer")] pub approvals_reviewer: Option, pub service_tier: Option, + pub service_tier_id: Option, pub model_reasoning_effort: Option, pub model_reasoning_summary: Option, pub model_verbosity: Option, @@ -248,6 +249,7 @@ pub struct Config { pub model_reasoning_summary: Option, pub model_verbosity: Option, pub service_tier: Option, + pub service_tier_id: Option, pub analytics: Option, #[experimental("config/read.apps")] #[serde(default)] diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index e5c5c5adbb..1b7bb8376b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -60,6 +60,7 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; @@ -423,6 +424,82 @@ async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_configured_service_tier_id_takes_precedence() -> Result<()> { + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + let service_tier_id = "experimental-tier-id".to_string(); + let (config_root, config_rest) = config_toml + .split_once("\n[features]\n") + .expect("test config should contain features section"); + std::fs::write( + &config_path, + format!( + "{config_root}\nservice_tier = \"{}\"\nservice_tier_id = \"{service_tier_id}\"\n\n[features]\n{config_rest}", + ServiceTier::Fast + ), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert_eq!( + response_mock.single_request().body_json()["service_tier"], + json!(service_tier_id) + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Result<()> { let server = responses::start_mock_server().await; diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index d2eeaf927f..d19386f3e8 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -310,6 +310,10 @@ pub struct ConfigToml { /// Optional explicit service tier preference for new turns (`fast` or `flex`). pub service_tier: Option, + /// Optional explicit service tier id preference for new turns. Takes precedence over + /// `service_tier` when both are set. + pub service_tier_id: Option, + /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index fab78a128c..d1870dc514 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -26,6 +26,9 @@ pub struct ConfigProfile { pub model: Option, /// Optional explicit service tier preference for new turns (`fast` or `flex`). pub service_tier: Option, + /// Optional explicit service tier id preference for new turns. Takes precedence over + /// `service_tier` when both are set. + pub service_tier_id: Option, /// The key in the `model_providers` map identifying the /// [`ModelProviderInfo`] to use. pub model_provider: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6eab1d4c55..b07da2c46f 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -677,6 +677,10 @@ ], "description": "Optional explicit service tier preference for new turns (`fast` or `flex`)." }, + "service_tier_id": { + "description": "Optional explicit service tier id preference for new turns. Takes precedence over `service_tier` when both are set.", + "type": "string" + }, "tools": { "$ref": "#/definitions/ToolsToml" }, @@ -4514,6 +4518,10 @@ ], "description": "Optional explicit service tier preference for new turns (`fast` or `flex`)." }, + "service_tier_id": { + "description": "Optional explicit service tier id preference for new turns. Takes precedence over `service_tier` when both are set.", + "type": "string" + }, "shell_environment_policy": { "allOf": [ { diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 9d1c82eb65..08af8ec92d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -543,7 +543,7 @@ async fn drain_to_completed( &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, - turn_context.config.service_tier.clone(), + turn_context.config.service_tier_id.clone(), turn_metadata_header, // Rollout tracing currently models remote compaction only; local compaction streams // are left untraced until the reducer has a first-class local compaction lifecycle. diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index eb9e454677..24344e975a 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -197,7 +197,7 @@ async fn run_remote_compact_task_inner_impl( CompactConversationRequestSettings { effort: turn_context.reasoning_effort, summary: turn_context.reasoning_summary, - service_tier: turn_context.config.service_tier.clone(), + service_tier: turn_context.config.service_tier_id.clone(), }, &turn_context.session_telemetry, &compaction_trace, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 7705616d37..e2476ab7cf 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -252,7 +252,7 @@ async fn run_remote_compaction_request_v2( &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, - turn_context.config.service_tier.clone(), + turn_context.config.service_tier_id.clone(), turn_metadata_header, &InferenceTraceContext::disabled(), ) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index bc9af28a67..74780d1cdd 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5238,6 +5238,34 @@ async fn set_model_updates_profile() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn clearing_service_tier_id_also_clears_legacy_service_tier() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + tokio::fs::write( + &config_path, + r#" +service_tier = "fast" +service_tier_id = "priority" +"#, + ) + .await?; + + ConfigEditsBuilder::new(codex_home.path()) + .set_service_tier(/*service_tier*/ None) + .set_service_tier_id(/*service_tier_id*/ None) + .apply() + .await?; + + let serialized = tokio::fs::read_to_string(config_path).await?; + let parsed: ConfigToml = toml::from_str(&serialized)?; + + assert_eq!((parsed.service_tier, parsed.service_tier_id), (None, None)); + + Ok(()) +} + #[tokio::test] async fn set_model_updates_existing_profile() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -6946,6 +6974,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { model_context_window: None, model_auto_compact_token_limit: None, service_tier: None, + service_tier_id: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), permissions: Permissions { @@ -7126,14 +7155,14 @@ async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std: fixture.cfg.clone(), ConfigOverrides { cwd: Some(fixture.cwd_path()), - service_tier: Some(None), + service_tier_id: Some(None), ..Default::default() }, fixture.codex_home(), ) .await?; - assert_eq!(config.service_tier, None); + assert_eq!(config.service_tier_id, None); assert_eq!(config.notices.fast_default_opt_out, Some(true)); Ok(()) } @@ -7146,7 +7175,7 @@ async fn legacy_fast_service_tier_override_uses_priority_request_value() -> std: fixture.cfg.clone(), ConfigOverrides { cwd: Some(fixture.cwd_path()), - service_tier: Some(Some("fast".to_string())), + service_tier: Some(Some(ServiceTier::Fast)), ..Default::default() }, fixture.codex_home(), @@ -7154,8 +7183,40 @@ async fn legacy_fast_service_tier_override_uses_priority_request_value() -> std: .await?; assert_eq!( - config.service_tier, - Some(ServiceTier::Fast.request_value().to_string()) + config, + Config { + service_tier: Some(ServiceTier::Fast), + service_tier_id: Some(ServiceTier::Fast.request_value().to_string()), + ..config.clone() + } + ); + Ok(()) +} + +#[tokio::test] +async fn service_tier_id_takes_precedence_over_service_tier() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + let mut cfg = fixture.cfg.clone(); + cfg.service_tier = Some(ServiceTier::Fast); + cfg.service_tier_id = Some("flex".to_string()); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + ..Default::default() + }, + fixture.codex_home(), + ) + .await?; + + assert_eq!( + config, + Config { + service_tier: Some(ServiceTier::Fast), + service_tier_id: Some("flex".to_string()), + ..config.clone() + } ); Ok(()) } @@ -7179,7 +7240,7 @@ async fn fast_default_opt_out_notice_config_is_respected() -> std::io::Result<() ) .await?; - assert_eq!(config.service_tier, None); + assert_eq!(config.service_tier_id, None); assert_eq!(config.notices.fast_default_opt_out, Some(true)); Ok(()) } @@ -7205,6 +7266,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { model_context_window: None, model_auto_compact_token_limit: None, service_tier: None, + service_tier_id: None, model_provider_id: "openai-custom".to_string(), model_provider: fixture.openai_custom_provider.clone(), permissions: Permissions { @@ -7363,6 +7425,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { model_context_window: None, model_auto_compact_token_limit: None, service_tier: None, + service_tier_id: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), permissions: Permissions { @@ -7506,6 +7569,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { model_context_window: None, model_auto_compact_token_limit: None, service_tier: None, + service_tier_id: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), permissions: Permissions { diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 1362103364..ccaa8f13dc 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -32,8 +32,10 @@ pub enum ConfigEdit { model: Option, effort: Option, }, - /// Update the service tier preference for future turns. + /// Update the legacy service tier preference for future turns. SetServiceTier { service_tier: Option }, + /// Update the service tier id preference for future turns. + SetServiceTierId { service_tier_id: Option }, /// Update the active (or default) model personality. SetModelPersonality { personality: Option }, /// Toggle the acknowledgement flag under `[notice]`. @@ -538,6 +540,12 @@ impl ConfigDocument { &["service_tier"], service_tier.map(|service_tier| value(service_tier.to_string())), )), + ConfigEdit::SetServiceTierId { service_tier_id } => Ok(self.write_profile_value( + &["service_tier_id"], + service_tier_id + .as_ref() + .map(|service_tier_id| value(service_tier_id.clone())), + )), ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value( &["personality"], personality.map(|personality| value(personality.to_string())), @@ -1114,6 +1122,12 @@ impl ConfigEditsBuilder { self } + pub fn set_service_tier_id(mut self, service_tier_id: Option) -> Self { + self.edits + .push(ConfigEdit::SetServiceTierId { service_tier_id }); + self + } + pub fn set_service_tier(mut self, service_tier: Option) -> Self { self.edits.push(ConfigEdit::SetServiceTier { service_tier }); self diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b2dfbcb2b8..5136c05ee4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -402,8 +402,12 @@ pub struct Config { /// Optional override of model selection. pub model: Option, - /// Effective service tier request id preference for new turns. - pub service_tier: Option, + /// Effective legacy service tier preference for new turns. + pub service_tier: Option, + + /// Effective service tier id preference for new turns. Takes precedence over + /// `service_tier` when both are set. + pub service_tier_id: Option, /// Model used specifically for review sessions. pub review_model: Option, @@ -1870,7 +1874,8 @@ pub struct ConfigOverrides { pub permission_profile: Option, pub default_permissions: Option, pub model_provider: Option, - pub service_tier: Option>, + pub service_tier: Option>, + pub service_tier_id: Option>, pub config_profile: Option, pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, @@ -2139,6 +2144,7 @@ impl Config { default_permissions: default_permissions_override, model_provider, service_tier: service_tier_override, + service_tier_id: service_tier_id_override, config_profile: config_profile_key, codex_self_exe, codex_linux_sandbox_exe, @@ -2733,25 +2739,39 @@ impl Config { 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 } - None => config_profile - .service_tier - .or(cfg.service_tier) - .map(|service_tier| service_tier.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::Fast) => features - .enabled(Feature::FastMode) - .then(|| ServiceTier::Fast.request_value().to_string()), - Some(ServiceTier::Flex) => Some(ServiceTier::Flex.request_value().to_string()), - None => Some(service_tier), + let service_tier = match service_tier { + Some(ServiceTier::Fast) if features.enabled(Feature::FastMode) => { + Some(ServiceTier::Fast) } - }); + Some(ServiceTier::Fast) => None, + Some(ServiceTier::Flex) => Some(ServiceTier::Flex), + None => None, + }; + let service_tier_id = match service_tier_id_override { + Some(Some(service_tier_id)) => Some(service_tier_id), + Some(None) => { + // Preserve explicit standard/clear intent after the nested override + // collapses into `Config.service_tier_id = None`. + notices.fast_default_opt_out = Some(true); + None + } + None => config_profile.service_tier_id.or(cfg.service_tier_id).or_else(|| { + service_tier + .as_ref() + .map(|service_tier| service_tier.request_value().to_string()) + }), + }; + let service_tier_id = + match service_tier_id.as_deref() { + Some("priority") if features.enabled(Feature::FastMode) => service_tier_id, + Some("priority") => None, + Some(_) | None => service_tier_id, + }; let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { let trimmed = value.trim(); @@ -2981,6 +3001,7 @@ impl Config { let config = Self { model, service_tier, + service_tier_id, review_model, model_context_window: cfg.model_context_window, model_auto_compact_token_limit: cfg.model_auto_compact_token_limit, diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 10f2242647..2d854d7ae2 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -113,6 +113,7 @@ fn save_session_resolved_fields(sc: &SessionConfiguration, lock_config: &mut Con .service_tier .as_deref() .and_then(codex_protocol::config_types::ServiceTier::from_request_value); + lock_config.service_tier_id = sc.service_tier.clone(); lock_config.instructions = Some(sc.base_instructions.clone()); lock_config.developer_instructions = sc.developer_instructions.clone(); lock_config.compact_prompt = sc.compact_prompt.clone(); @@ -291,7 +292,7 @@ mod tests { assert_eq!(lock.model, None); assert_eq!(lock.model_reasoning_effort, None); assert_eq!(lock.model_reasoning_summary, None); - assert_eq!(lock.service_tier, None); + assert_eq!(lock.service_tier_id, None); assert_eq!(lock.instructions, None); assert_eq!(lock.developer_instructions, None); assert_eq!(lock.compact_prompt, None); @@ -300,6 +301,24 @@ mod tests { assert_eq!(lock.approvals_reviewer, None); } + #[tokio::test] + async fn lock_clears_legacy_service_tier_when_session_tier_is_cleared() { + let mut sc = crate::session::tests::make_session_configuration_for_tests().await; + let mut config = (*sc.original_config_do_not_use).clone(); + config.service_tier = Some(codex_protocol::config_types::ServiceTier::Fast); + config.service_tier_id = Some("priority".to_string()); + sc.original_config_do_not_use = Arc::new(config); + sc.service_tier = None; + + let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize"); + let lock = &lockfile.config; + + assert_eq!( + (lock.service_tier, lock.service_tier_id.clone()), + (None, None) + ); + } + #[tokio::test] async fn lock_validation_reports_config_diff() { let sc = crate::session::tests::make_session_configuration_for_tests().await; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 6d4b275421..b3c83f538f 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -595,8 +595,8 @@ impl Codex { 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(), + let service_tier = get_service_tier_id( + config.service_tier_id.clone(), config.notices.fast_default_opt_out.unwrap_or(false), account_plan_type, config.features.enabled(Feature::FastMode), @@ -793,14 +793,14 @@ impl Codex { } } -fn get_service_tier( - configured_service_tier: Option, +fn get_service_tier_id( + configured_service_tier_id: Option, fast_default_opt_out: bool, account_plan_type: Option, fast_mode_enabled: bool, ) -> Option { - if configured_service_tier.is_some() || fast_default_opt_out || !fast_mode_enabled { - return configured_service_tier; + if configured_service_tier_id.is_some() || fast_default_opt_out || !fast_mode_enabled { + return configured_service_tier_id; } account_plan_type diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b63b16cbf4..a9f247d270 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3011,10 +3011,10 @@ fn session_telemetry( } #[test] -fn get_service_tier_defaults_enterprise_accounts_to_fast() { +fn get_service_tier_id_defaults_enterprise_accounts_to_fast() { assert_eq!( - get_service_tier( - /*configured_service_tier*/ None, + get_service_tier_id( + /*configured_service_tier_id*/ None, /*fast_default_opt_out*/ false, Some(AccountPlanType::Enterprise), /*fast_mode_enabled*/ true, @@ -3022,8 +3022,8 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( - get_service_tier( - /*configured_service_tier*/ None, + get_service_tier_id( + /*configured_service_tier_id*/ None, /*fast_default_opt_out*/ false, Some(AccountPlanType::EnterpriseCbpUsageBased), /*fast_mode_enabled*/ true, @@ -3031,8 +3031,8 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( - get_service_tier( - /*configured_service_tier*/ None, + get_service_tier_id( + /*configured_service_tier_id*/ None, /*fast_default_opt_out*/ false, Some(AccountPlanType::Business), /*fast_mode_enabled*/ true, @@ -3040,8 +3040,8 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( - get_service_tier( - /*configured_service_tier*/ None, + get_service_tier_id( + /*configured_service_tier_id*/ None, /*fast_default_opt_out*/ false, Some(AccountPlanType::Team), /*fast_mode_enabled*/ true, @@ -3049,8 +3049,8 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( - get_service_tier( - /*configured_service_tier*/ None, + get_service_tier_id( + /*configured_service_tier_id*/ None, /*fast_default_opt_out*/ false, Some(AccountPlanType::SelfServeBusinessUsageBased), /*fast_mode_enabled*/ true, @@ -3060,10 +3060,10 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { } #[test] -fn get_service_tier_respects_fast_default_opt_out() { +fn get_service_tier_id_respects_fast_default_opt_out() { assert_eq!( - get_service_tier( - /*configured_service_tier*/ None, + get_service_tier_id( + /*configured_service_tier_id*/ None, /*fast_default_opt_out*/ true, Some(AccountPlanType::Enterprise), /*fast_mode_enabled*/ true, @@ -3073,10 +3073,10 @@ fn get_service_tier_respects_fast_default_opt_out() { } #[test] -fn get_service_tier_does_not_default_non_enterprise_or_disabled_fast_mode() { +fn get_service_tier_id_does_not_default_non_enterprise_or_disabled_fast_mode() { assert_eq!( - get_service_tier( - /*configured_service_tier*/ None, + get_service_tier_id( + /*configured_service_tier_id*/ None, /*fast_default_opt_out*/ false, Some(AccountPlanType::Pro), /*fast_mode_enabled*/ true, @@ -3084,8 +3084,8 @@ fn get_service_tier_does_not_default_non_enterprise_or_disabled_fast_mode() { None ); assert_eq!( - get_service_tier( - /*configured_service_tier*/ None, + get_service_tier_id( + /*configured_service_tier_id*/ None, /*fast_default_opt_out*/ false, Some(AccountPlanType::Enterprise), /*fast_mode_enabled*/ false, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 1723904cff..0314eb84c0 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -702,7 +702,7 @@ async fn track_turn_resolved_config_analytics( reasoning_summary: Some(turn_context.reasoning_summary), service_tier: turn_context .config - .service_tier + .service_tier_id .as_deref() .and_then(ServiceTier::from_request_value), approval_policy: turn_context.approval_policy.value(), @@ -1856,7 +1856,7 @@ async fn try_run_sampling_request( &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, - turn_context.config.service_tier.clone(), + turn_context.config.service_tier_id.clone(), turn_metadata_header, &inference_trace, ) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index d4fe30063f..62789ec51c 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -414,7 +414,11 @@ impl Session { per_turn_config.model_reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; - per_turn_config.service_tier = session_configuration.service_tier.clone(); + per_turn_config.service_tier = session_configuration + .service_tier + .as_deref() + .and_then(codex_protocol::config_types::ServiceTier::from_request_value); + per_turn_config.service_tier_id = session_configuration.service_tier.clone(); per_turn_config.personality = session_configuration.personality; per_turn_config.approvals_reviewer = session_configuration.approvals_reviewer; per_turn_config.permissions.permission_profile = diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 93d14ff7d6..a90481d52d 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -232,7 +232,7 @@ async fn schedule_startup_prewarm_inner( &startup_turn_context.session_telemetry, startup_turn_context.reasoning_effort, startup_turn_context.reasoning_summary, - startup_turn_context.config.service_tier.clone(), + startup_turn_context.config.service_tier_id.clone(), startup_turn_metadata_header.as_deref(), ) .await?; diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 6e985eebe0..4a5f32982f 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -313,7 +313,7 @@ async fn websocket_v2_first_turn_drops_fast_tier_after_startup_prewarm() -> Resu .features .enable(Feature::ResponsesWebsocketsV2) .expect("test config should allow feature update"); - config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + config.service_tier_id = Some(ServiceTier::Fast.request_value().to_string()); }); let test = builder.build_with_websocket_server(&server).await?; diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 3da841ba79..7452c5829d 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -445,7 +445,7 @@ async fn assert_remote_manual_compact_request_parity( let mut builder = test_codex().with_auth(auth); if let Some(service_tier) = configured_service_tier { builder = builder.with_config(move |config| { - config.service_tier = Some(service_tier.request_value().to_string()); + config.service_tier_id = Some(service_tier.request_value().to_string()); }); } let harness = TestCodexHarness::with_builder(builder).await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b035a19517..3a81eb3570 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -411,6 +411,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result cwd: resolved_cwd, model_provider: model_provider.clone(), service_tier: None, + service_tier_id: None, codex_self_exe: arg0_paths.codex_self_exe.clone(), codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index 4fcb1d409b..db8cdec063 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -240,7 +240,7 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu let server = start_mock_server().await; let home = Arc::new(TempDir::new()?); let test = build_test_codex(&server, home).await?; - assert_eq!(test.config.service_tier, None); + assert_eq!(test.config.service_tier_id, None); test.codex .submit(Op::OverrideTurnContext { diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 6817f677e6..0dd4850b10 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -161,6 +161,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R startup_warnings: Vec::new(), model, service_tier: None, + service_tier_id: None, review_model: None, model_context_window: None, model_auto_compact_token_limit: None, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 670c6f7de3..5499d065e2 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1274,11 +1274,13 @@ impl App { AppEvent::PersistServiceTierSelection { service_tier } => { self.refresh_status_line(); let profile = self.active_profile.as_deref(); - self.config.service_tier = + self.config.service_tier = service_tier; + self.config.service_tier_id = service_tier.map(|service_tier| service_tier.request_value().to_string()); let mut edits = ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) - .set_service_tier(service_tier); + .set_service_tier(service_tier) + .set_service_tier_id(self.config.service_tier_id.clone()); if service_tier.is_none() { self.config.notices.fast_default_opt_out = Some(true); edits = edits.set_fast_default_opt_out(/*opted_out*/ true); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index b114d08be9..6c933d94a9 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -617,10 +617,12 @@ 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() - .map(|service_tier| service_tier.request_value().to_string()); + config.service_tier = self.chat_widget.configured_service_tier(); + config.service_tier_id = self.config.service_tier_id.clone().or_else(|| { + config + .service_tier + .map(|service_tier| service_tier.request_value().to_string()) + }); config.notices.fast_default_opt_out = self.chat_widget.fast_default_opt_out(); config } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 5ab85887a7..6d2787460a 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4484,7 +4484,7 @@ async fn fresh_session_config_uses_current_service_tier() { let config = app.fresh_session_config(); assert_eq!( - config.service_tier, + config.service_tier_id, Some( codex_protocol::config_types::ServiceTier::Fast .request_value() @@ -4493,6 +4493,19 @@ async fn fresh_session_config_uses_current_service_tier() { ); } +#[tokio::test] +async fn fresh_session_config_preserves_custom_service_tier_id() { + let mut app = make_test_app().await; + app.config.service_tier_id = Some("experimental-tier-id".to_string()); + + let config = app.fresh_session_config(); + + assert_eq!( + (config.service_tier, config.service_tier_id), + (None, Some("experimental-tier-id".to_string())) + ); +} + #[tokio::test] async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9227f29bee..d9ff88a0fa 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -762,7 +762,8 @@ pub(crate) struct ChatWidget { active_cell_revision: u64, config: Config, raw_output_mode: bool, - /// Runtime value resolved by core. `config.service_tier` remains the explicit user choice. + /// Runtime value resolved by core. `config.service_tier_id`/`config.service_tier` + /// remain the explicit user choice. effective_service_tier: Option, /// The unmasked collaboration mode settings (always Default mode). /// @@ -4900,9 +4901,10 @@ impl ChatWidget { let current_cwd = Some(config.cwd.to_path_buf()); let effective_service_tier = config - .service_tier + .service_tier_id .as_deref() - .and_then(ServiceTier::from_request_value); + .and_then(ServiceTier::from_request_value) + .or(config.service_tier); let current_terminal_info = terminal_info(); let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok(); let default_keymap = RuntimeKeymap::defaults(); @@ -5891,10 +5893,14 @@ 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 = match ( + self.config.service_tier_id.clone(), + self.config.service_tier, + ) { + (Some(service_tier_id), _) => Some(Some(service_tier_id)), + (None, Some(service_tier)) => Some(Some(service_tier.request_value().to_string())), + (None, None) if self.config.notices.fast_default_opt_out == Some(true) => Some(None), + (None, None) => None, }; let permission_profile = self.config.permissions.permission_profile(); let op = AppCommand::user_turn( @@ -9304,7 +9310,8 @@ impl ChatWidget { /// Set Fast mode in the widget's config copy. #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn set_service_tier(&mut self, service_tier: Option) { - self.config.service_tier = + self.config.service_tier = service_tier; + self.config.service_tier_id = service_tier.map(|service_tier| service_tier.request_value().to_string()); self.effective_service_tier = service_tier; } @@ -9315,9 +9322,10 @@ impl ChatWidget { pub(crate) fn configured_service_tier(&self) -> Option { self.config - .service_tier + .service_tier_id .as_deref() .and_then(service_tier_from_value) + .or(self.config.service_tier) } pub(crate) fn fast_default_opt_out(&self) -> Option { diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 9e12a2b45c..fc535fa16d 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -184,9 +184,10 @@ pub(super) async fn make_chatwidget_manual( let current_collaboration_mode = base_mode; let active_collaboration_mask = collaboration_modes::default_mask(model_catalog.as_ref()); let effective_service_tier = cfg - .service_tier + .service_tier_id .as_deref() - .and_then(ServiceTier::from_request_value); + .and_then(ServiceTier::from_request_value) + .or(cfg.service_tier); let mut widget = ChatWidget { app_event_tx, codex_op_target: super::CodexOpTarget::Direct(op_tx),