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 ec024d1f09..13a197d343 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 @@ -11208,10 +11208,10 @@ }, "Model": { "properties": { - "additionalSpeedTiers": { + "serviceTiers": { "default": [], "items": { - "type": "string" + "$ref": "#/definitions/v2/ModelServiceTier" }, "type": "array" }, @@ -11306,6 +11306,25 @@ ], "type": "object" }, + "ModelServiceTier": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "$ref": "#/definitions/v2/ServiceTier" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "ModelListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14143,10 +14162,7 @@ "type": "object" }, "ServiceTier": { - "enum": [ - "fast", - "flex" - ], + "description": "String-backed service tier identifier. Known values include `priority`, `flex`, and `ultrafast`, but other backend-provided ids are allowed.", "type": "string" }, "SessionMigration": { @@ -18230,4 +18246,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} \ No newline at end of file +} 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 3a18b7cd21..5d4a799a6e 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 @@ -7862,10 +7862,10 @@ }, "Model": { "properties": { - "additionalSpeedTiers": { + "serviceTiers": { "default": [], "items": { - "type": "string" + "$ref": "#/definitions/ModelServiceTier" }, "type": "array" }, @@ -7960,6 +7960,25 @@ ], "type": "object" }, + "ModelServiceTier": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "$ref": "#/definitions/ServiceTier" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "ModelListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12029,10 +12048,7 @@ "type": "object" }, "ServiceTier": { - "enum": [ - "fast", - "flex" - ], + "description": "String-backed service tier identifier. Known values include `priority`, `flex`, and `ultrafast`, but other backend-provided ids are allowed.", "type": "string" }, "SessionMigration": { @@ -16115,4 +16131,4 @@ }, "title": "CodexAppServerProtocolV2", "type": "object" -} \ No newline at end of file +} 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 dc60c5b770..8def9e61e7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json @@ -22,10 +22,10 @@ }, "Model": { "properties": { - "additionalSpeedTiers": { + "serviceTiers": { "default": [], "items": { - "type": "string" + "$ref": "#/definitions/ModelServiceTier" }, "type": "array" }, @@ -120,6 +120,25 @@ ], "type": "object" }, + "ModelServiceTier": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "$ref": "#/definitions/ServiceTier" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "ModelUpgradeInfo": { "properties": { "migrationMarkdown": { @@ -175,6 +194,10 @@ "reasoningEffort" ], "type": "object" + }, + "ServiceTier": { + "description": "String-backed service tier identifier. Known values include `priority`, `flex`, and `ultrafast`, but other backend-provided ids are allowed.", + "type": "string" } }, "properties": { @@ -197,4 +220,4 @@ ], "title": "ModelListResponse", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts b/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts index ce11286dbd..c6e19383e3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ServiceTier = "fast" | "flex"; +export type ServiceTier = 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 f4cf5a9462..698b3c4662 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts @@ -4,7 +4,8 @@ import type { InputModality } from "../InputModality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ModelAvailabilityNux } from "./ModelAvailabilityNux"; +import type { ModelServiceTier } from "./ModelServiceTier"; import type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; import type { ReasoningEffortOption } from "./ReasoningEffortOption"; -export type Model = { id: string, model: string, upgrade: string | null, upgradeInfo: ModelUpgradeInfo | null, availabilityNux: ModelAvailabilityNux | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array, defaultReasoningEffort: ReasoningEffort, inputModalities: Array, supportsPersonality: boolean, additionalSpeedTiers: Array, isDefault: boolean, }; +export type Model = { id: string, model: string, upgrade: string | null, upgradeInfo: ModelUpgradeInfo | null, availabilityNux: ModelAvailabilityNux | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array, defaultReasoningEffort: ReasoningEffort, inputModalities: Array, supportsPersonality: boolean, serviceTiers: Array, isDefault: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelServiceTier.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelServiceTier.ts new file mode 100644 index 0000000000..29a5079c69 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelServiceTier.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ServiceTier } from "../ServiceTier"; + +export type ModelServiceTier = { id: ServiceTier, name: string, description: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 28de6b108d..b3c73f4e54 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -241,6 +241,7 @@ export type { Model } from "./Model"; export type { ModelAvailabilityNux } from "./ModelAvailabilityNux"; export type { ModelListParams } from "./ModelListParams"; export type { ModelListResponse } from "./ModelListResponse"; +export type { ModelServiceTier } from "./ModelServiceTier"; export type { ModelProviderCapabilitiesReadParams } from "./ModelProviderCapabilitiesReadParams"; export type { ModelProviderCapabilitiesReadResponse } from "./ModelProviderCapabilitiesReadResponse"; export type { ModelRerouteReason } from "./ModelRerouteReason"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 900351fee3..391202c42a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -49,6 +49,7 @@ use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelAvailabilityNux as CoreModelAvailabilityNux; +use codex_protocol::openai_models::ModelServiceTier as CoreModelServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::default_input_modalities; use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; @@ -2508,6 +2509,35 @@ impl From for ModelAvailabilityNux { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelServiceTier { + pub id: ServiceTier, + pub name: String, + pub description: String, +} + +impl From for ModelServiceTier { + fn from(value: CoreModelServiceTier) -> Self { + Self { + id: value.id, + name: value.name, + description: value.description, + } + } +} + +impl From for CoreModelServiceTier { + fn from(value: ModelServiceTier) -> Self { + Self { + id: value.id, + name: value.name, + description: value.description, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -2527,7 +2557,7 @@ pub struct Model { #[serde(default)] pub supports_personality: bool, #[serde(default)] - pub additional_speed_tiers: Vec, + pub service_tiers: Vec, // 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 3aae4ae610..74bb2d6583 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -191,7 +191,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, `serviceTiers` (`id`, `name`, `description`), 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. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 16de9ba7a5..8b48a938d6 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -10352,7 +10352,7 @@ mod tests { path: None, model: None, model_provider: None, - service_tier: Some(Some(codex_protocol::config_types::ServiceTier::Fast)), + service_tier: Some(Some(codex_protocol::config_types::ServiceTier::priority())), cwd: None, approval_policy: None, approvals_reviewer: None, @@ -10368,7 +10368,7 @@ mod tests { let config_snapshot = ThreadConfigSnapshot { model: "gpt-5".to_string(), model_provider_id: "openai".to_string(), - service_tier: Some(codex_protocol::config_types::ServiceTier::Flex), + service_tier: Some(codex_protocol::config_types::ServiceTier::flex()), approval_policy: codex_protocol::protocol::AskForApproval::OnRequest, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::Disabled, diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index fd08098f74..aad4874b5a 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -42,7 +42,7 @@ fn model_from_preset(preset: ModelPreset) -> Model { default_reasoning_effort: preset.default_reasoning_effort, input_modalities: preset.input_modalities, supports_personality: preset.supports_personality, - additional_speed_tiers: preset.additional_speed_tiers, + service_tiers: preset.service_tiers.into_iter().map(Into::into).collect(), 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 3b4a58a7ab..af3e0da69f 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -28,7 +28,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { }, supported_in_api: preset.supported_in_api, priority, - additional_speed_tiers: preset.additional_speed_tiers.clone(), + service_tiers: preset.service_tiers.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 830ab0f783..0a7b2d0bb0 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -50,7 +50,12 @@ fn model_from_preset(preset: &ModelPreset) -> Model { // cache report `supports_personality = false`. // todo(sayan): fix, maybe make roundtrip use ModelInfo only supports_personality: false, - additional_speed_tiers: preset.additional_speed_tiers.clone(), + service_tiers: preset + .service_tiers + .clone() + .into_iter() + .map(Into::into) + .collect(), is_default: preset.is_default, } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index d8a50b88a4..de3b010279 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -340,7 +340,7 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> { let req_id = mcp .send_thread_start_request(ThreadStartParams { - service_tier: Some(Some(ServiceTier::Flex)), + service_tier: Some(Some(ServiceTier::flex())), ..Default::default() }) .await?; @@ -352,7 +352,7 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> { .await??; let ThreadStartResponse { service_tier, .. } = to_response::(resp)?; - assert_eq!(service_tier, Some(ServiceTier::Flex)); + assert_eq!(service_tier, Some(ServiceTier::flex())); Ok(()) } diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 9f95c9441f..93e08af20d 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -74,7 +74,7 @@ async fn models_client_hits_models_endpoint() { visibility: ModelVisibility::List, supported_in_api: true, priority: 1, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 28c81d69c8..268dc358a6 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2107,10 +2107,7 @@ "type": "object" }, "ServiceTier": { - "enum": [ - "fast", - "flex" - ], + "description": "String-backed service tier identifier. Known values include `priority`, `flex`, and `ultrafast`, but other backend-provided ids are allowed.", "type": "string" }, "ShellEnvironmentPolicyInherit": { @@ -4036,4 +4033,4 @@ }, "title": "ConfigToml", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index ba81b451a7..5ed1658d3d 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -889,11 +889,7 @@ impl ModelClientSession { store: provider.is_azure_responses_endpoint(), stream: true, include, - service_tier: match service_tier { - Some(ServiceTier::Fast) => Some("priority".to_string()), - Some(service_tier) => Some(service_tier.to_string()), - None => None, - }, + service_tier: service_tier.map(|service_tier| service_tier.to_string()), prompt_cache_key, text, client_metadata: Some(HashMap::from([( diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index f67e4f1fd8..732ae63e4d 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -155,7 +155,7 @@ fn serializes_flex_service_tier_when_set() { stream: true, include: vec![], prompt_cache_key: None, - service_tier: Some(ServiceTier::Flex.to_string()), + service_tier: Some(ServiceTier::flex().to_string()), text: None, client_metadata: None, }; diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 1ebc307e33..ef8295bb9b 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -530,7 +530,7 @@ async fn drain_to_completed( &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, - turn_context.config.service_tier, + turn_context.config.service_tier.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/config/mod.rs b/codex-rs/core/src/config/mod.rs index c5965d389e..1cfcaeb58d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2578,12 +2578,10 @@ impl Config { None => config_profile.service_tier.or(cfg.service_tier), }; let service_tier = match service_tier { - Some(ServiceTier::Fast) if features.enabled(Feature::FastMode) => { - Some(ServiceTier::Fast) + Some(service_tier) if service_tier.is_priority() && !features.enabled(Feature::FastMode) => { + None } - Some(ServiceTier::Fast) => None, - Some(ServiceTier::Flex) => Some(ServiceTier::Flex), - None => None, + other => other, }; let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index e45db20848..185a102420 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -586,7 +586,7 @@ impl Codex { .auth_cached() .and_then(|auth| auth.account_plan_type()); let service_tier = get_service_tier( - config.service_tier, + config.service_tier.clone(), config.notices.fast_default_opt_out.unwrap_or(false), account_plan_type, config.features.enabled(Feature::FastMode), @@ -789,7 +789,7 @@ fn get_service_tier( account_plan_type .is_some_and(is_enterprise_default_service_tier_plan) - .then_some(ServiceTier::Fast) + .then(ServiceTier::priority) } fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 6c6cc22a0e..4dc02b4429 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -130,7 +130,7 @@ impl SessionConfiguration { ThreadConfigSnapshot { model: self.collaboration_mode.model().to_string(), model_provider_id: self.original_config_do_not_use.model_provider_id.clone(), - service_tier: self.service_tier, + service_tier: self.service_tier.clone(), approval_policy: self.approval_policy.value(), approvals_reviewer: self.approvals_reviewer, permission_profile: self.permission_profile(), @@ -175,7 +175,7 @@ impl SessionConfiguration { if let Some(summary) = updates.reasoning_summary { next_configuration.model_reasoning_summary = Some(summary); } - if let Some(service_tier) = updates.service_tier { + if let Some(service_tier) = updates.service_tier.clone() { next_configuration.service_tier = service_tier; } if let Some(personality) = updates.personality { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 1afb28f35d..626b1ecd9d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2790,7 +2790,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::Enterprise), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::priority()) ); assert_eq!( get_service_tier( @@ -2799,7 +2799,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::EnterpriseCbpUsageBased), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::priority()) ); assert_eq!( get_service_tier( @@ -2808,7 +2808,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::Business), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::priority()) ); assert_eq!( get_service_tier( @@ -2817,7 +2817,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::Team), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::priority()) ); assert_eq!( get_service_tier( @@ -2826,7 +2826,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::SelfServeBusinessUsageBased), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::priority()) ); } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 07e4e30041..309155c435 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -689,7 +689,7 @@ async fn track_turn_resolved_config_analytics( permission_profile_cwd: turn_context.cwd.to_path_buf(), reasoning_effort: turn_context.reasoning_effort, reasoning_summary: Some(turn_context.reasoning_summary), - service_tier: turn_context.config.service_tier, + service_tier: turn_context.config.service_tier.clone(), approval_policy: turn_context.approval_policy.value(), approvals_reviewer: turn_context.config.approvals_reviewer, sandbox_network_access: turn_context.network_sandbox_policy().is_enabled(), @@ -1837,7 +1837,7 @@ async fn try_run_sampling_request( &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, - turn_context.config.service_tier, + turn_context.config.service_tier.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 09690f93de..e74aa7c570 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -394,7 +394,7 @@ 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; + per_turn_config.service_tier = 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/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 305346afac..f765c3c137 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::priority())) .await?; assert_eq!(server.handshakes().len(), 1); @@ -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); + config.service_tier = Some(ServiceTier::priority()); }); let test = builder.build_with_websocket_server(&server).await?; @@ -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::priority())) .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 43ec50746e..aa8d6d8ceb 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -105,7 +105,7 @@ fn test_model_info( used_fallback_model_metadata: false, supports_search_tool: false, priority: 1, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, @@ -297,7 +297,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::priority())) .await?; test.submit_turn_with_service_tier("standard turn", /*service_tier*/ None) .await?; @@ -323,7 +323,7 @@ async fn flex_service_tier_is_applied_to_http_turn() -> Result<()> { let test = test_codex().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())) .await?; let request = resp_mock.single_request(); @@ -817,7 +817,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< used_fallback_model_metadata: false, supports_search_tool: false, priority: 1, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), 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 e2688afc97..207027bfdd 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -340,7 +340,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { visibility: ModelVisibility::List, supported_in_api: true, priority, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), 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 dde6d2ca51..de43ad7869 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -578,7 +578,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow visibility: ModelVisibility::List, supported_in_api: true, priority: 1, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: Some(ModelMessages { @@ -686,7 +686,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - visibility: ModelVisibility::List, supported_in_api: true, priority: 1, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), 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 49218c78d2..4431d9e2c9 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -532,7 +532,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { used_fallback_model_metadata: false, supports_search_tool: false, priority: 1, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, @@ -788,7 +788,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { used_fallback_model_metadata: false, supports_search_tool: false, priority: 1, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: remote_base.to_string(), model_messages: None, @@ -1278,7 +1278,7 @@ fn test_remote_model_with_policy( used_fallback_model_metadata: false, supports_search_tool: false, priority, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), 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 0947f4fba7..79469c20a5 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1282,7 +1282,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re visibility: ModelVisibility::List, supported_in_api: true, priority: 1, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), 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 031c3135e8..12845a0188 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -66,7 +66,7 @@ fn test_model_info( used_fallback_model_metadata: false, supports_search_tool: false, priority: 1, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), 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 9dd5d82e0a..9437895fd5 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1059,7 +1059,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an used_fallback_model_metadata: false, supports_search_tool: false, priority: 1, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index d89b68825e..1b1053fa44 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -253,14 +253,14 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu model: None, effort: None, summary: None, - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(ServiceTier::priority())), collaboration_mode: None, personality: None, }) .await?; - let config_snapshot = wait_for_service_tier(&test, Some(ServiceTier::Fast)).await?; - assert_eq!(config_snapshot.service_tier, Some(ServiceTier::Fast)); + let config_snapshot = wait_for_service_tier(&test, Some(ServiceTier::priority())).await?; + assert_eq!(config_snapshot.service_tier, Some(ServiceTier::priority())); let context = crate::runtime::MemoryStartupContext::new( Arc::clone(&test.thread_manager), @@ -277,7 +277,7 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu ReasoningEffort::Low, ) .await; - assert_eq!(request_context.service_tier, Some(ServiceTier::Fast)); + assert_eq!(request_context.service_tier, Some(ServiceTier::priority())); shutdown_test_codex(&test).await?; Ok(()) diff --git a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs index 4ca2cb891e..47d2c75619 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs @@ -1,10 +1,12 @@ use codex_models_manager::model_info::BASE_INSTRUCTIONS; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; @@ -46,7 +48,11 @@ fn gpt_5_4_cmb_bedrock_model(priority: i32) -> ModelInfo { visibility: ModelVisibility::List, supported_in_api: true, priority, - additional_speed_tiers: vec!["fast".to_string()], + service_tiers: vec![ModelServiceTier { + id: ServiceTier::priority(), + name: "Fast".to_string(), + description: "Fast tier".to_string(), + }], availability_nux: None, upgrade: None, base_instructions: BASE_INSTRUCTIONS.to_string(), @@ -86,7 +92,7 @@ fn bedrock_oss_model(slug: &str, display_name: &str, priority: i32) -> ModelInfo visibility: ModelVisibility::List, supported_in_api: true, priority, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), availability_nux: None, upgrade: None, base_instructions: BASE_INSTRUCTIONS.to_string(), diff --git a/codex-rs/models-manager/models.json b/codex-rs/models-manager/models.json index c025fe36ad..ce06a3dd35 100644 --- a/codex-rs/models-manager/models.json +++ b/codex-rs/models-manager/models.json @@ -82,8 +82,12 @@ "team" ], "supports_search_tool": true, - "additional_speed_tiers": [ - "fast" + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "Fast tier" + } ], "supports_reasoning_summaries": true }, @@ -164,8 +168,12 @@ "team" ], "supports_search_tool": true, - "additional_speed_tiers": [ - "fast" + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "Fast tier" + } ], "supports_reasoning_summaries": true }, @@ -249,7 +257,7 @@ "team" ], "supports_search_tool": true, - "additional_speed_tiers": [], + "service_tiers": [], "supports_reasoning_summaries": true }, { @@ -332,7 +340,7 @@ "team" ], "supports_search_tool": true, - "additional_speed_tiers": [], + "service_tiers": [], "supports_reasoning_summaries": true }, { @@ -411,7 +419,7 @@ "team" ], "supports_search_tool": true, - "additional_speed_tiers": [], + "service_tiers": [], "supports_reasoning_summaries": true }, { @@ -491,7 +499,7 @@ "team" ], "supports_search_tool": true, - "additional_speed_tiers": [], + "service_tiers": [], "supports_reasoning_summaries": true } ] diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 8e8abae549..5afcaf7ce2 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -75,7 +75,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { visibility: ModelVisibility::None, supported_in_api: true, priority: 99, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), 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 da83ee858a..f551eff5ae 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -347,12 +347,93 @@ impl From for WebSearchConfig { } } -#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] -pub enum ServiceTier { - Fast, - Flex, +pub const SERVICE_TIER_PRIORITY: &str = "priority"; +pub const SERVICE_TIER_FLEX: &str = "flex"; +pub const SERVICE_TIER_ULTRAFAST: &str = "ultrafast"; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, TS)] +#[serde(transparent)] +#[ts(type = "string")] +pub struct ServiceTier(String); + +impl ServiceTier { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn priority() -> Self { + Self::new(SERVICE_TIER_PRIORITY) + } + + pub fn flex() -> Self { + Self::new(SERVICE_TIER_FLEX) + } + + pub fn ultrafast() -> Self { + Self::new(SERVICE_TIER_ULTRAFAST) + } + + pub fn is_priority(&self) -> bool { + self.as_str() == SERVICE_TIER_PRIORITY + } + + pub fn is_flex(&self) -> bool { + self.as_str() == SERVICE_TIER_FLEX + } +} + +impl AsRef for ServiceTier { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl From<&str> for ServiceTier { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +impl From for ServiceTier { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl From for String { + fn from(value: ServiceTier) -> Self { + value.0 + } +} + +impl std::fmt::Display for ServiceTier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl JsonSchema for ServiceTier { + fn schema_name() -> String { + "ServiceTier".to_string() + } + + fn json_schema(_generator: &mut SchemaGenerator) -> Schema { + Schema::Object(SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some( + "String-backed service tier identifier. Known values include `priority`, `flex`, and `ultrafast`, but other backend-provided ids are allowed." + .to_string(), + ), + ..Default::default() + })), + ..Default::default() + }) + } } #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 41275e6a6b..6f2daccea9 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -17,10 +17,10 @@ use ts_rs::TS; use crate::config_types::Personality; use crate::config_types::ReasoningSummary; +use crate::config_types::ServiceTier; use crate::config_types::Verbosity; const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}"; -pub const SPEED_TIER_FAST: &str = "fast"; /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning #[derive( @@ -115,6 +115,13 @@ pub struct ModelAvailabilityNux { pub message: String, } +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] +pub struct ModelServiceTier { + pub id: ServiceTier, + pub name: String, + pub description: String, +} + /// Metadata describing a Codex-supported model. #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] pub struct ModelPreset { @@ -133,9 +140,9 @@ pub struct ModelPreset { /// Whether this model supports personality-specific instructions. #[serde(default)] pub supports_personality: bool, - /// Additional speed tiers this model can run with beyond the standard path. + /// User-selectable service tiers supported by this model. #[serde(default)] - pub additional_speed_tiers: Vec, + pub service_tiers: Vec, /// Whether this is the default model for new users. pub is_default: bool, /// recommended upgrade model @@ -257,7 +264,7 @@ pub struct ModelInfo { pub supported_in_api: bool, pub priority: i32, #[serde(default)] - pub additional_speed_tiers: Vec, + pub service_tiers: Vec, pub availability_nux: Option, pub upgrade: Option, pub base_instructions: String, @@ -441,7 +448,7 @@ impl From for ModelPreset { .unwrap_or(ReasoningEffort::None), supported_reasoning_efforts: info.supported_reasoning_levels.clone(), supports_personality, - additional_speed_tiers: info.additional_speed_tiers, + service_tiers: info.service_tiers, is_default: false, // default is the highest priority available model upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade { id: upgrade.model.clone(), @@ -463,10 +470,16 @@ impl From for ModelPreset { } impl ModelPreset { - pub fn supports_fast_mode(&self) -> bool { - self.additional_speed_tiers + pub fn supports_service_tier(&self, service_tier: &ServiceTier) -> bool { + self.service_tiers .iter() - .any(|tier| tier == SPEED_TIER_FAST) + .any(|tier| tier.id == *service_tier) + } + + pub fn service_tier(&self, service_tier: &ServiceTier) -> Option<&ModelServiceTier> { + self.service_tiers + .iter() + .find(|tier| tier.id == *service_tier) } /// Filter models based on authentication mode. @@ -547,7 +560,7 @@ mod tests { visibility: ModelVisibility::List, supported_in_api: true, priority: 1, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), availability_nux: None, upgrade: None, base_instructions: "base".to_string(), @@ -817,7 +830,11 @@ mod tests { availability_nux: Some(ModelAvailabilityNux { message: "Try Spark.".to_string(), }), - additional_speed_tiers: vec![SPEED_TIER_FAST.to_string()], + service_tiers: vec![ModelServiceTier { + id: ServiceTier::priority(), + name: "Fast".to_string(), + description: "Fast tier".to_string(), + }], ..test_model(/*spec*/ None) }); @@ -827,6 +844,6 @@ mod tests { message: "Try Spark.".to_string(), }) ); - assert!(preset.supports_fast_mode()); + assert!(preset.supports_service_tier(&ServiceTier::priority())); } } diff --git a/codex-rs/tools/src/agent_tool_tests.rs b/codex-rs/tools/src/agent_tool_tests.rs index 3157cfc547..46fed4db23 100644 --- a/codex-rs/tools/src/agent_tool_tests.rs +++ b/codex-rs/tools/src/agent_tool_tests.rs @@ -19,7 +19,7 @@ fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset { description: "Balanced".to_string(), }], supports_personality: false, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), is_default: false, upgrade: None, show_in_picker, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 62cb727159..ade29493e0 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1163,25 +1163,26 @@ impl App { AppEvent::PersistServiceTierSelection { service_tier } => { self.refresh_status_line(); let profile = self.active_profile.as_deref(); - self.config.service_tier = service_tier; + self.config.service_tier = service_tier.clone(); let mut edits = ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) - .set_service_tier(service_tier); + .set_service_tier(service_tier.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); } match edits.apply().await { Ok(()) => { - let status = if matches!( - service_tier, - Some(codex_protocol::config_types::ServiceTier::Fast) - ) { - "on" - } else { - "off" + let mut message = match service_tier.as_ref() { + Some(service_tier) => format!( + "Service tier set to {}", + self.chat_widget.service_tier_display_name( + self.chat_widget.current_model(), + service_tier, + ) + ), + None => "Service tier cleared".to_string(), }; - let mut message = format!("Fast mode set to {status}"); if let Some(profile) = profile { message.push_str(" for "); message.push_str(profile); @@ -1190,14 +1191,14 @@ impl App { self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { - tracing::error!(error = %err, "failed to persist fast mode selection"); + tracing::error!(error = %err, "failed to persist service tier selection"); if let Some(profile) = profile { self.chat_widget.add_error_message(format!( - "Failed to save Fast mode for profile `{profile}`: {err}" + "Failed to save service tier for profile `{profile}`: {err}" )); } else { self.chat_widget.add_error_message(format!( - "Failed to save default Fast mode: {err}" + "Failed to save default service tier: {err}" )); } } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 756c50869d..b2426d2803 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3662,7 +3662,7 @@ async fn clear_ui_header_shows_fast_status_for_fast_capable_models() { app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); app.chat_widget - .set_service_tier(Some(codex_protocol::config_types::ServiceTier::Fast)); + .set_service_tier(Some(codex_protocol::config_types::ServiceTier::priority())); set_chatgpt_auth(&mut app.chat_widget); set_fast_mode_test_catalog(&mut app.chat_widget); @@ -4280,13 +4280,13 @@ fn active_turn_steer_race_extracts_actual_turn_id_from_mismatch() { async fn fresh_session_config_uses_current_service_tier() { let mut app = make_test_app().await; app.chat_widget - .set_service_tier(Some(codex_protocol::config_types::ServiceTier::Fast)); + .set_service_tier(Some(codex_protocol::config_types::ServiceTier::priority())); let config = app.fresh_session_config(); assert_eq!( config.service_tier, - Some(codex_protocol::config_types::ServiceTier::Fast) + Some(codex_protocol::config_types::ServiceTier::priority()) ); } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 263838e7b1..c8756dd340 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1073,7 +1073,7 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset { }) .collect(), supports_personality: model.supports_personality, - additional_speed_tiers: model.additional_speed_tiers, + service_tiers: model.service_tiers.into_iter().map(Into::into).collect(), is_default: model.is_default, upgrade, show_in_picker: !model.hidden, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c879743425..1a6cacd999 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -184,6 +184,8 @@ use super::skill_popup::MentionItem; use super::skill_popup::SkillPopup; use super::slash_commands; use super::slash_commands::BuiltinCommandFlags; +use super::slash_commands::ServiceTierCommand; +use super::slash_commands::SlashCommandAction; use crate::bottom_pane::paste_burst::FlushResult; use crate::bottom_pane::prompt_args::parse_slash_name; use crate::key_hint::KeyBindingListExt; @@ -262,13 +264,13 @@ pub enum InputResult { /// /// Callers that dispatch this variant are also responsible for resolving any pending local /// command-history entry that the composer staged before clearing the visible input. - Command(SlashCommand), + Command(SlashCommandAction), /// An inline slash command and its trimmed argument text. /// /// The `TextElement` ranges are rebased into the argument string, while any pending local /// command-history entry still represents the original command invocation that should be /// committed only if dispatch accepts it. - CommandWithArgs(SlashCommand, String, Vec), + CommandWithArgs(SlashCommandAction, String, Vec), None, } @@ -386,6 +388,7 @@ pub(crate) struct ChatComposer { goal_status_indicator: Option, connectors_enabled: bool, plugins_command_enabled: bool, + service_tier_commands: Vec, fast_command_enabled: bool, goal_command_enabled: bool, personality_command_enabled: bool, @@ -573,6 +576,7 @@ impl ChatComposer { goal_status_indicator: None, connectors_enabled: false, plugins_command_enabled: false, + service_tier_commands: Vec::new(), fast_command_enabled: false, goal_command_enabled: false, personality_command_enabled: false, @@ -646,6 +650,11 @@ impl ChatComposer { self.plugins_command_enabled = enabled; } + pub fn set_service_tier_commands(&mut self, commands: Vec) { + self.service_tier_commands = commands; + self.sync_popups(); + } + /// Toggle composer-side image paste handling. /// /// This only affects whether image-like paste content is converted into attachments; the @@ -1634,13 +1643,10 @@ impl ChatComposer { // before applying completion. let first_line = self.textarea.text().lines().next().unwrap_or(""); popup.on_composer_text_change(first_line.to_string()); - let selected_cmd = popup.selected_item().map(|sel| { - let CommandItem::Builtin(cmd) = sel; - cmd - }); + let selected_cmd = popup.selected_item(); if let Some(cmd) = selected_cmd { - if cmd == SlashCommand::Skills { - self.stage_selected_slash_command_history(cmd); + if cmd.as_builtin() == Some(SlashCommand::Skills) { + self.stage_selected_slash_command_history(&cmd); self.textarea.set_text_clearing_elements(""); self.is_bash_mode = false; return (InputResult::Command(cmd), true); @@ -1672,11 +1678,7 @@ impl ChatComposer { // while the slash-command popup is active. let first_line = self.textarea.text().lines().next().unwrap_or(""); popup.on_composer_text_change(first_line.to_string()); - let selected_cmd = popup.selected_item().map(|sel| { - let CommandItem::Builtin(cmd) = sel; - cmd - }); - if let Some(cmd) = selected_cmd { + if let Some(cmd) = popup.selected_item() { let starts_with_cmd = first_line .trim_start() .starts_with(&format!("/{}", cmd.command())); @@ -1696,9 +1698,8 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, .. } => { - if let Some(sel) = popup.selected_item() { - let CommandItem::Builtin(cmd) = sel; - self.stage_selected_slash_command_history(cmd); + if let Some(cmd) = popup.selected_item() { + self.stage_selected_slash_command_history(&cmd); self.textarea.set_text_clearing_elements(""); self.is_bash_mode = false; return (InputResult::Command(cmd), true); @@ -2496,10 +2497,13 @@ impl ChatComposer { { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { - let is_builtin = - slash_commands::find_builtin_command(name, self.builtin_command_flags()) - .is_some(); - if !is_builtin { + if slash_commands::find_command( + name, + self.builtin_command_flags(), + &self.service_tier_commands, + ) + .is_none() + { let message = format!( r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# ); @@ -2700,10 +2704,13 @@ impl ChatComposer { let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line) && rest.is_empty() - && let Some(cmd) = - slash_commands::find_builtin_command(name, self.builtin_command_flags()) + && let Some(cmd) = slash_commands::find_command( + name, + self.builtin_command_flags(), + &self.service_tier_commands, + ) { - if self.reject_slash_command_if_unavailable(cmd) { + if self.reject_slash_command_if_unavailable(&cmd) { self.stage_slash_command_history(); self.record_pending_slash_command_history(); return Some(InputResult::None); @@ -2733,12 +2740,16 @@ impl ChatComposer { return None; } - let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?; + let cmd = slash_commands::find_command( + name, + self.builtin_command_flags(), + &self.service_tier_commands, + )?; if !cmd.supports_inline_args() { return None; } - if self.reject_slash_command_if_unavailable(cmd) { + if self.reject_slash_command_if_unavailable(&cmd) { self.stage_slash_command_history(); self.record_pending_slash_command_history(); return Some(InputResult::None); @@ -2783,7 +2794,7 @@ impl ChatComposer { Some((trimmed_rest.to_string(), args_elements)) } - fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool { + fn reject_slash_command_if_unavailable(&self, cmd: &SlashCommandAction) -> bool { if !self.is_task_running || cmd.available_during_task() { return false; } @@ -2828,7 +2839,7 @@ impl ChatComposer { /// /// Popup filtering text can be partial, so recording the selected command avoids recalling /// `/di` after the user actually accepted `/diff`. - fn stage_selected_slash_command_history(&mut self, cmd: SlashCommand) { + fn stage_selected_slash_command_history(&mut self, cmd: &SlashCommandAction) { self.stage_slash_command_history_text(format!("/{}", cmd.command())); } @@ -3545,7 +3556,12 @@ impl ChatComposer { } fn is_known_slash_name(&self, name: &str) -> bool { - slash_commands::find_builtin_command(name, self.builtin_command_flags()).is_some() + slash_commands::find_command( + name, + self.builtin_command_flags(), + &self.service_tier_commands, + ) + .is_some() } /// If the cursor is currently within a slash command on the first line, @@ -3577,7 +3593,7 @@ impl ChatComposer { } /// Heuristic for whether the typed slash command looks like a valid - /// prefix for any known built-in command. + /// prefix for any known command. /// Empty names only count when there is no extra content after the '/'. fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool { if !self.slash_commands_enabled() { @@ -3587,7 +3603,11 @@ impl ChatComposer { return rest_after_name.is_empty(); } - slash_commands::has_builtin_prefix(name, self.builtin_command_flags()) + slash_commands::has_command_prefix( + name, + self.builtin_command_flags(), + &self.service_tier_commands, + ) } /// Synchronize `self.command_popup` with the current text in the @@ -3638,18 +3658,21 @@ impl ChatComposer { let personality_command_enabled = self.personality_command_enabled; let realtime_conversation_enabled = self.realtime_conversation_enabled; let audio_device_selection_enabled = self.audio_device_selection_enabled; - let mut command_popup = CommandPopup::new(CommandPopupFlags { - collaboration_modes_enabled, - connectors_enabled, - plugins_command_enabled, - fast_command_enabled, - goal_command_enabled, - personality_command_enabled, - realtime_conversation_enabled, - audio_device_selection_enabled, - windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, - side_conversation_active: self.side_conversation_active, - }); + let mut command_popup = CommandPopup::new( + CommandPopupFlags { + collaboration_modes_enabled, + connectors_enabled, + plugins_command_enabled, + fast_command_enabled, + goal_command_enabled, + personality_command_enabled, + realtime_conversation_enabled, + audio_device_selection_enabled, + windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, + side_conversation_active: self.side_conversation_active, + }, + &self.service_tier_commands, + ); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -6416,7 +6439,10 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(matches!(result, InputResult::Command(SlashCommand::Diff))); + assert!(matches!( + result, + InputResult::Command(SlashCommandAction::Builtin(SlashCommand::Diff)) + )); } /// Behavior: if a burst is buffering text and the user presses a non-char key, flush the @@ -6883,7 +6909,6 @@ mod tests { #[test] fn slash_popup_model_first_for_mo_logic() { - use super::super::command_popup::CommandItem; let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -6897,9 +6922,7 @@ mod tests { match &composer.active_popup { ActivePopup::Command(popup) => match popup.selected_item() { - Some(CommandItem::Builtin(cmd)) => { - assert_eq!(cmd.command(), "model") - } + Some(command) => assert_eq!(command.command(), "model"), None => panic!("no selected command for '/mo'"), }, _ => panic!("slash popup not active after typing '/mo'"), @@ -6936,7 +6959,6 @@ mod tests { #[test] fn slash_popup_resume_for_res_logic() { - use super::super::command_popup::CommandItem; let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -6950,9 +6972,7 @@ mod tests { match &composer.active_popup { ActivePopup::Command(popup) => match popup.selected_item() { - Some(CommandItem::Builtin(cmd)) => { - assert_eq!(cmd.command(), "resume") - } + Some(command) => assert_eq!(command.command(), "resume"), None => panic!("no selected command for '/res'"), }, _ => panic!("slash popup not active after typing '/res'"), @@ -9083,7 +9103,10 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(result, InputResult::Command(SlashCommand::Diff)); + assert_eq!( + result, + InputResult::Command(SlashCommandAction::Builtin(SlashCommand::Diff)) + ); composer.record_pending_slash_command_history(); let (result, _needs_redraw) = @@ -9108,7 +9131,10 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(result, InputResult::Command(SlashCommand::Diff)); + assert_eq!( + result, + InputResult::Command(SlashCommandAction::Builtin(SlashCommand::Diff)) + ); composer.record_pending_slash_command_history(); let (result, _needs_redraw) = @@ -9137,7 +9163,7 @@ mod tests { match result { InputResult::CommandWithArgs(cmd, args, text_elements) => { - assert_eq!(cmd, SlashCommand::Plan); + assert_eq!(cmd.command(), "plan"); assert_eq!(args, "investigate this"); assert!(text_elements.is_empty()); } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 1ec258fb35..77557d74c5 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -10,6 +10,8 @@ use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::measure_rows_height_with_col_width_mode; use super::selection_popup_common::render_rows_with_col_width_mode; use super::slash_commands; +use super::slash_commands::ServiceTierCommand; +use super::slash_commands::SlashCommandAction; use crate::render::Insets; use crate::render::RectExt; use crate::slash_command::SlashCommand; @@ -24,14 +26,11 @@ const COMMAND_COLUMN_WIDTH: ColumnWidthConfig = ColumnWidthConfig::new( ); /// A selectable item in the popup. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum CommandItem { - Builtin(SlashCommand), -} +pub(crate) type CommandItem = SlashCommandAction; pub(crate) struct CommandPopup { command_filter: String, - builtins: Vec<(&'static str, SlashCommand)>, + commands: Vec, state: ScrollState, } @@ -67,17 +66,19 @@ impl From for slash_commands::BuiltinCommandFlags { } impl CommandPopup { - pub(crate) fn new(flags: CommandPopupFlags) -> Self { - // Keep built-in availability in sync with the composer. - let builtins: Vec<(&'static str, SlashCommand)> = - slash_commands::builtins_for_input(flags.into()) - .into_iter() - .filter(|(name, _)| !name.starts_with("debug")) - .filter(|(_, cmd)| *cmd != SlashCommand::Apps) - .collect(); + pub(crate) fn new( + flags: CommandPopupFlags, + service_tier_commands: &[ServiceTierCommand], + ) -> Self { + // Keep command availability in sync with the composer. + let commands = slash_commands::commands_for_input(flags.into(), service_tier_commands) + .into_iter() + .filter(|command| !command.command().starts_with("debug")) + .filter(|command| command.as_builtin() != Some(SlashCommand::Apps)) + .collect(); Self { command_filter: String::new(), - builtins, + commands, state: ScrollState::new(), } } @@ -127,18 +128,20 @@ impl CommandPopup { ) } - /// Compute exact/prefix matches over built-in commands and user prompts, - /// paired with optional highlight indices. Preserves the original - /// presentation order for built-ins and prompts. + /// Compute exact/prefix matches over visible commands, paired with + /// optional highlight indices. Preserves the original presentation order. fn filtered(&self) -> Vec<(CommandItem, Option>)> { let filter = self.command_filter.trim(); let mut out: Vec<(CommandItem, Option>)> = Vec::new(); if filter.is_empty() { - for (_, cmd) in self.builtins.iter() { - if ALIAS_COMMANDS.contains(cmd) { + for command in self.commands.iter() { + if command + .as_builtin() + .is_some_and(|builtin| ALIAS_COMMANDS.contains(&builtin)) + { continue; } - out.push((CommandItem::Builtin(*cmd), None)); + out.push((command.clone(), None)); } return out; } @@ -170,8 +173,8 @@ impl CommandPopup { } }; - for (_, cmd) in self.builtins.iter() { - push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0); + for command in self.commands.iter() { + push_match(command.clone(), command.command(), None, 0); } out.extend(exact); @@ -190,9 +193,8 @@ impl CommandPopup { matches .into_iter() .map(|(item, indices)| { - let CommandItem::Builtin(cmd) = item; - let name = format!("/{}", cmd.command()); - let description = cmd.description().to_string(); + let name = format!("/{}", item.command()); + let description = item.description().to_string(); GenericDisplayRow { name, name_prefix_spans: Vec::new(), @@ -228,7 +230,7 @@ impl CommandPopup { let matches = self.filtered_items(); self.state .selected_idx - .and_then(|idx| matches.get(idx).copied()) + .and_then(|idx| matches.get(idx).cloned()) } } @@ -256,7 +258,7 @@ mod tests { #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default(), &[]); // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); @@ -264,9 +266,7 @@ mod tests { // Access the filtered list via the selected command and ensure that // one of the matches is the new "init" command. let matches = popup.filtered_items(); - let has_init = matches.iter().any(|item| match item { - CommandItem::Builtin(cmd) => cmd.command() == "init", - }); + let has_init = matches.iter().any(|item| item.command() == "init"); assert!( has_init, "expected '/init' to appear among filtered commands" @@ -275,55 +275,51 @@ mod tests { #[test] fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default(), &[]); popup.on_composer_text_change("/init".to_string()); // When an exact match exists, the selected command should be that // command by default. let selected = popup.selected_item(); match selected { - Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"), + Some(command) => assert_eq!(command.command(), "init"), None => panic!("expected a selected command for exact match"), } } #[test] fn model_is_first_suggestion_for_mo() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default(), &[]); popup.on_composer_text_change("/mo".to_string()); let matches = popup.filtered_items(); match matches.first() { - Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"), + Some(command) => assert_eq!(command.command(), "model"), None => panic!("expected at least one match for '/mo'"), } } #[test] fn filtered_commands_keep_presentation_order_for_prefix() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default(), &[]); popup.on_composer_text_change("/m".to_string()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) + .map(|item| item.command()) .collect(); assert_eq!(cmds, vec!["model", "memories", "mention", "mcp"]); } #[test] fn prefix_filter_limits_matches_for_ac() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default(), &[]); popup.on_composer_text_change("/ac".to_string()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) + .map(|item| item.command()) .collect(); assert!( !cmds.contains(&"compact"), @@ -333,7 +329,7 @@ mod tests { #[test] fn quit_hidden_in_empty_filter_but_shown_for_prefix() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default(), &[]); popup.on_composer_text_change("/".to_string()); let items = popup.filtered_items(); assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Quit))); @@ -345,15 +341,13 @@ mod tests { #[test] fn collab_command_hidden_when_collaboration_modes_disabled() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default(), &[]); popup.on_composer_text_change("/".to_string()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) + .map(|item| item.command()) .collect(); assert!( !cmds.contains(&"collab"), @@ -367,70 +361,77 @@ mod tests { #[test] fn collab_command_visible_when_collaboration_modes_enabled() { - let mut popup = CommandPopup::new(CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_enabled: false, - plugins_command_enabled: false, - fast_command_enabled: false, - goal_command_enabled: false, - personality_command_enabled: true, - realtime_conversation_enabled: false, - audio_device_selection_enabled: false, - windows_degraded_sandbox_active: false, - side_conversation_active: false, - }); + let mut popup = CommandPopup::new( + CommandPopupFlags { + collaboration_modes_enabled: true, + connectors_enabled: false, + plugins_command_enabled: false, + fast_command_enabled: false, + goal_command_enabled: false, + personality_command_enabled: true, + realtime_conversation_enabled: false, + audio_device_selection_enabled: false, + windows_degraded_sandbox_active: false, + side_conversation_active: false, + }, + &[], + ); popup.on_composer_text_change("/collab".to_string()); match popup.selected_item() { - Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"), + Some(command) => assert_eq!(command.command(), "collab"), other => panic!("expected collab to be selected for exact match, got {other:?}"), } } #[test] fn plan_command_visible_when_collaboration_modes_enabled() { - let mut popup = CommandPopup::new(CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_enabled: false, - plugins_command_enabled: false, - fast_command_enabled: false, - goal_command_enabled: false, - personality_command_enabled: true, - realtime_conversation_enabled: false, - audio_device_selection_enabled: false, - windows_degraded_sandbox_active: false, - side_conversation_active: false, - }); + let mut popup = CommandPopup::new( + CommandPopupFlags { + collaboration_modes_enabled: true, + connectors_enabled: false, + plugins_command_enabled: false, + fast_command_enabled: false, + goal_command_enabled: false, + personality_command_enabled: true, + realtime_conversation_enabled: false, + audio_device_selection_enabled: false, + windows_degraded_sandbox_active: false, + side_conversation_active: false, + }, + &[], + ); popup.on_composer_text_change("/plan".to_string()); match popup.selected_item() { - Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "plan"), + Some(command) => assert_eq!(command.command(), "plan"), other => panic!("expected plan to be selected for exact match, got {other:?}"), } } #[test] fn personality_command_hidden_when_disabled() { - let mut popup = CommandPopup::new(CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_enabled: false, - plugins_command_enabled: false, - fast_command_enabled: false, - goal_command_enabled: false, - personality_command_enabled: false, - realtime_conversation_enabled: false, - audio_device_selection_enabled: false, - windows_degraded_sandbox_active: false, - side_conversation_active: false, - }); + let mut popup = CommandPopup::new( + CommandPopupFlags { + collaboration_modes_enabled: true, + connectors_enabled: false, + plugins_command_enabled: false, + fast_command_enabled: false, + goal_command_enabled: false, + personality_command_enabled: false, + realtime_conversation_enabled: false, + audio_device_selection_enabled: false, + windows_degraded_sandbox_active: false, + side_conversation_active: false, + }, + &[], + ); popup.on_composer_text_change("/pers".to_string()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) + .map(|item| item.command()) .collect(); assert!( !cmds.contains(&"personality"), @@ -440,48 +441,52 @@ mod tests { #[test] fn personality_command_visible_when_enabled() { - let mut popup = CommandPopup::new(CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_enabled: false, - plugins_command_enabled: false, - fast_command_enabled: false, - goal_command_enabled: false, - personality_command_enabled: true, - realtime_conversation_enabled: false, - audio_device_selection_enabled: false, - windows_degraded_sandbox_active: false, - side_conversation_active: false, - }); + let mut popup = CommandPopup::new( + CommandPopupFlags { + collaboration_modes_enabled: true, + connectors_enabled: false, + plugins_command_enabled: false, + fast_command_enabled: false, + goal_command_enabled: false, + personality_command_enabled: true, + realtime_conversation_enabled: false, + audio_device_selection_enabled: false, + windows_degraded_sandbox_active: false, + side_conversation_active: false, + }, + &[], + ); popup.on_composer_text_change("/personality".to_string()); match popup.selected_item() { - Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "personality"), + Some(command) => assert_eq!(command.command(), "personality"), other => panic!("expected personality to be selected for exact match, got {other:?}"), } } #[test] fn settings_command_hidden_when_audio_device_selection_is_disabled() { - let mut popup = CommandPopup::new(CommandPopupFlags { - collaboration_modes_enabled: false, - connectors_enabled: false, - plugins_command_enabled: false, - fast_command_enabled: false, - goal_command_enabled: false, - personality_command_enabled: true, - realtime_conversation_enabled: true, - audio_device_selection_enabled: false, - windows_degraded_sandbox_active: false, - side_conversation_active: false, - }); + let mut popup = CommandPopup::new( + CommandPopupFlags { + collaboration_modes_enabled: false, + connectors_enabled: false, + plugins_command_enabled: false, + fast_command_enabled: false, + goal_command_enabled: false, + personality_command_enabled: true, + realtime_conversation_enabled: true, + audio_device_selection_enabled: false, + windows_degraded_sandbox_active: false, + side_conversation_active: false, + }, + &[], + ); popup.on_composer_text_change("/aud".to_string()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) + .map(|item| item.command()) .collect(); assert!( @@ -492,13 +497,11 @@ mod tests { #[test] fn debug_commands_are_hidden_from_popup() { - let popup = CommandPopup::new(CommandPopupFlags::default()); + let popup = CommandPopup::new(CommandPopupFlags::default(), &[]); let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) + .map(|item| item.command()) .collect(); assert!( diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 02275755b7..30fd40a4a3 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -137,6 +137,7 @@ mod textarea; mod unified_exec_footer; pub(crate) use feedback_view::FeedbackNoteView; pub(crate) use selection_tabs::SelectionTab; +use slash_commands::ServiceTierCommand; /// How long the "press again to quit" hint stays visible. /// @@ -386,6 +387,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_service_tier_commands(&mut self, commands: Vec) { + self.composer.set_service_tier_commands(commands); + self.request_redraw(); + } + pub fn set_goal_command_enabled(&mut self, enabled: bool) { self.composer.set_goal_command_enabled(enabled); self.request_redraw(); diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index f75d759d5e..3207199420 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -1,10 +1,14 @@ -//! Shared helpers for filtering and matching built-in slash commands. +//! Shared helpers for filtering and matching slash commands. //! //! The same sandbox- and feature-gating rules are used by both the composer //! and the command popup. Centralizing them here keeps those call sites small //! and ensures they stay in sync. +use std::collections::HashSet; use std::str::FromStr; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelServiceTier; use codex_utils_fuzzy_match::fuzzy_match; use crate::slash_command::SlashCommand; @@ -24,6 +28,75 @@ pub(crate) struct BuiltinCommandFlags { pub(crate) side_conversation_active: bool, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ServiceTierCommand { + pub(crate) service_tier: ServiceTier, + pub(crate) command: String, + pub(crate) name: String, + pub(crate) description: String, +} + +impl ServiceTierCommand { + fn from_model_service_tier(service_tier: &ModelServiceTier, command: String) -> Self { + Self { + service_tier: service_tier.id.clone(), + command, + name: service_tier.name.clone(), + description: service_tier.description.clone(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SlashCommandAction { + Builtin(SlashCommand), + ServiceTier(ServiceTierCommand), +} + +impl SlashCommandAction { + pub(crate) fn command(&self) -> &str { + match self { + Self::Builtin(command) => command.command(), + Self::ServiceTier(command) => command.command.as_str(), + } + } + + pub(crate) fn description(&self) -> &str { + match self { + Self::Builtin(command) => command.description(), + Self::ServiceTier(command) => command.description.as_str(), + } + } + + pub(crate) fn supports_inline_args(&self) -> bool { + match self { + Self::Builtin(command) => command.supports_inline_args(), + Self::ServiceTier(_) => true, + } + } + + pub(crate) fn available_in_side_conversation(&self) -> bool { + match self { + Self::Builtin(command) => command.available_in_side_conversation(), + Self::ServiceTier(_) => false, + } + } + + pub(crate) fn available_during_task(&self) -> bool { + match self { + Self::Builtin(command) => command.available_during_task(), + Self::ServiceTier(_) => false, + } + } + + pub(crate) fn as_builtin(&self) -> Option { + match self { + Self::Builtin(command) => Some(*command), + Self::ServiceTier(_) => None, + } + } +} + /// Return the built-ins that should be visible/usable for the current input. pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static str, SlashCommand)> { built_in_slash_commands() @@ -35,7 +108,6 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st }) .filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps) .filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins) - .filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast) .filter(|(_, cmd)| flags.goal_command_enabled || *cmd != SlashCommand::Goal) .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) @@ -44,31 +116,131 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st .collect() } -/// Find a single built-in command by exact name, after applying feature gating. +fn visible_service_tier_commands( + flags: BuiltinCommandFlags, + service_tier_commands: &[ServiceTierCommand], +) -> Vec { + if !flags.fast_command_enabled || flags.side_conversation_active { + return Vec::new(); + } + service_tier_commands.to_vec() +} + +pub(crate) fn commands_for_input( + flags: BuiltinCommandFlags, + service_tier_commands: &[ServiceTierCommand], +) -> Vec { + let visible_service_tier_commands = visible_service_tier_commands(flags, service_tier_commands); + let mut commands = Vec::new(); + for (_, command) in builtins_for_input(flags) { + commands.push(SlashCommandAction::Builtin(command)); + if command == SlashCommand::Model { + commands.extend( + visible_service_tier_commands + .iter() + .cloned() + .map(SlashCommandAction::ServiceTier), + ); + } + } + commands +} + +/// Find a single slash command by exact name, after applying feature gating. /// /// Side-conversation gating is intentionally enforced by dispatch rather than exact lookup so a /// typed command can produce a side-specific unavailable message while the popup still hides it. -pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Option { - let cmd = SlashCommand::from_str(name).ok()?; - builtins_for_input(BuiltinCommandFlags { +pub(crate) fn find_command( + name: &str, + flags: BuiltinCommandFlags, + service_tier_commands: &[ServiceTierCommand], +) -> Option { + let lookup_flags = BuiltinCommandFlags { side_conversation_active: false, ..flags - }) - .into_iter() - .any(|(_, visible_cmd)| visible_cmd == cmd) - .then_some(cmd) + }; + if let Some(command) = SlashCommand::from_str(name).ok().filter(|command| { + builtins_for_input(lookup_flags) + .into_iter() + .any(|(_, visible_command)| visible_command == *command) + }) { + return Some(SlashCommandAction::Builtin(command)); + } + visible_service_tier_commands(lookup_flags, service_tier_commands) + .into_iter() + .find(|command| command.command == name) + .map(SlashCommandAction::ServiceTier) } -/// Whether any visible built-in fuzzily matches the provided prefix. -pub(crate) fn has_builtin_prefix(name: &str, flags: BuiltinCommandFlags) -> bool { - builtins_for_input(flags) +/// Whether any visible slash command fuzzily matches the provided prefix. +pub(crate) fn has_command_prefix( + name: &str, + flags: BuiltinCommandFlags, + service_tier_commands: &[ServiceTierCommand], +) -> bool { + commands_for_input(flags, service_tier_commands) .into_iter() - .any(|(command_name, _)| fuzzy_match(command_name, name).is_some()) + .any(|command| fuzzy_match(command.command(), name).is_some()) +} + +pub(crate) fn service_tier_commands_for_model(model: &ModelPreset) -> Vec { + let mut reserved_names = HashSet::new(); + let mut commands = Vec::new(); + for service_tier in &model.service_tiers { + let Some(command) = command_token_for_service_tier(service_tier, &reserved_names) else { + continue; + }; + reserved_names.insert(command.clone()); + commands.push(ServiceTierCommand::from_model_service_tier( + service_tier, + command, + )); + } + commands +} + +fn command_token_for_service_tier( + service_tier: &ModelServiceTier, + reserved_names: &HashSet, +) -> Option { + [ + normalize_service_tier_command_token(service_tier.name.as_str()), + Some(service_tier.id.to_string()), + ] + .into_iter() + .flatten() + .find(|candidate| !command_token_conflicts(candidate, reserved_names)) +} + +fn command_token_conflicts(candidate: &str, reserved_names: &HashSet) -> bool { + candidate.is_empty() + || reserved_names.contains(candidate) + || SlashCommand::from_str(candidate).is_ok() +} + +fn normalize_service_tier_command_token(name: &str) -> Option { + let mut normalized = String::new(); + let mut pending_dash = false; + + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + if pending_dash && !normalized.is_empty() { + normalized.push('-'); + } + normalized.push(ch.to_ascii_lowercase()); + pending_dash = false; + } else if !normalized.is_empty() { + pending_dash = true; + } + } + + (!normalized.is_empty()).then_some(normalized) } #[cfg(test)] mod tests { use super::*; + use codex_protocol::openai_models::ModelServiceTier; use pretty_assertions::assert_eq; fn all_enabled_flags() -> BuiltinCommandFlags { @@ -88,53 +260,62 @@ mod tests { #[test] fn debug_command_still_resolves_for_dispatch() { - let cmd = find_builtin_command("debug-config", all_enabled_flags()); - assert_eq!(cmd, Some(SlashCommand::DebugConfig)); + let cmd = find_command("debug-config", all_enabled_flags(), &[]); + assert_eq!( + cmd, + Some(SlashCommandAction::Builtin(SlashCommand::DebugConfig)) + ); } #[test] fn clear_command_resolves_for_dispatch() { assert_eq!( - find_builtin_command("clear", all_enabled_flags()), - Some(SlashCommand::Clear) + find_command("clear", all_enabled_flags(), &[]), + Some(SlashCommandAction::Builtin(SlashCommand::Clear)) ); } #[test] fn stop_command_resolves_for_dispatch() { assert_eq!( - find_builtin_command("stop", all_enabled_flags()), - Some(SlashCommand::Stop) + find_command("stop", all_enabled_flags(), &[]), + Some(SlashCommandAction::Builtin(SlashCommand::Stop)) ); } #[test] fn clean_command_alias_resolves_for_dispatch() { assert_eq!( - find_builtin_command("clean", all_enabled_flags()), - Some(SlashCommand::Stop) + find_command("clean", all_enabled_flags(), &[]), + Some(SlashCommandAction::Builtin(SlashCommand::Stop)) ); } #[test] - fn fast_command_is_hidden_when_disabled() { + fn service_tier_command_is_hidden_when_disabled() { + let service_tier_commands = vec![ServiceTierCommand { + service_tier: ServiceTier::priority(), + command: "fast".to_string(), + name: "Fast".to_string(), + description: "Fast tier".to_string(), + }]; let mut flags = all_enabled_flags(); flags.fast_command_enabled = false; - assert_eq!(find_builtin_command("fast", flags), None); + assert_eq!(find_command("fast", flags, &service_tier_commands), None); } #[test] fn goal_command_is_hidden_when_disabled() { let mut flags = all_enabled_flags(); flags.goal_command_enabled = false; - assert_eq!(find_builtin_command("goal", flags), None); + assert_eq!(find_command("goal", flags, &[]), None); } #[test] fn realtime_command_is_hidden_when_realtime_is_disabled() { let mut flags = all_enabled_flags(); flags.realtime_conversation_enabled = false; - assert_eq!(find_builtin_command("realtime", flags), None); + assert_eq!(find_command("realtime", flags, &[]), None); } #[test] @@ -142,33 +323,36 @@ mod tests { let mut flags = all_enabled_flags(); flags.realtime_conversation_enabled = false; flags.audio_device_selection_enabled = false; - assert_eq!(find_builtin_command("settings", flags), None); + assert_eq!(find_command("settings", flags, &[]), None); } #[test] fn settings_command_is_hidden_when_audio_device_selection_is_disabled() { let mut flags = all_enabled_flags(); flags.audio_device_selection_enabled = false; - assert_eq!(find_builtin_command("settings", flags), None); + assert_eq!(find_command("settings", flags, &[]), None); } #[test] fn side_conversation_hides_commands_without_side_flag() { - let commands = builtins_for_input(BuiltinCommandFlags { - side_conversation_active: true, - ..all_enabled_flags() - }) + let commands = commands_for_input( + BuiltinCommandFlags { + side_conversation_active: true, + ..all_enabled_flags() + }, + &[], + ) .into_iter() - .map(|(_, command)| command) + .map(|command| command.command().to_string()) .collect::>(); assert_eq!( commands, vec![ - SlashCommand::Copy, - SlashCommand::Diff, - SlashCommand::Mention, - SlashCommand::Status, + "copy".to_string(), + "diff".to_string(), + "mention".to_string(), + "status".to_string(), ] ); } @@ -176,14 +360,66 @@ mod tests { #[test] fn side_conversation_exact_lookup_still_resolves_hidden_commands_for_dispatch_error() { assert_eq!( - find_builtin_command( + find_command( "review", BuiltinCommandFlags { side_conversation_active: true, ..all_enabled_flags() }, + &[], ), - Some(SlashCommand::Review) + Some(SlashCommandAction::Builtin(SlashCommand::Review)) + ); + } + + #[test] + fn service_tier_command_uses_name_then_id_on_collision() { + let model = ModelPreset { + id: "test".to_string(), + model: "test".to_string(), + display_name: "Test".to_string(), + description: String::new(), + default_reasoning_effort: codex_protocol::openai_models::ReasoningEffort::Medium, + supported_reasoning_efforts: Vec::new(), + supports_personality: false, + service_tiers: vec![ + ModelServiceTier { + id: ServiceTier::priority(), + name: "Fast".to_string(), + description: "Fast tier".to_string(), + }, + ModelServiceTier { + id: ServiceTier::ultrafast(), + name: "Model".to_string(), + description: "Ultrafast tier".to_string(), + }, + ], + is_default: false, + upgrade: None, + show_in_picker: true, + availability_nux: None, + supported_in_api: true, + input_modalities: Vec::new(), + }; + + let commands = service_tier_commands_for_model(&model); + + assert_eq!( + commands, + vec![ + ServiceTierCommand { + service_tier: ServiceTier::priority(), + command: "fast".to_string(), + name: "Fast".to_string(), + description: "Fast tier".to_string(), + }, + ServiceTierCommand { + service_tier: ServiceTier::ultrafast(), + command: "ultrafast".to_string(), + name: "Model".to_string(), + description: "Ultrafast tier".to_string(), + }, + ] ); } } diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index ea522f3bf0..9268019cae 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -106,7 +106,7 @@ pub(crate) enum StatusLineItem { /// Full session UUID. SessionId, - /// Whether Fast mode is currently active. + /// Current service-tier selection. FastMode, /// Current thread title (if set by user). @@ -148,7 +148,7 @@ impl StatusLineItem { StatusLineItem::SessionId => { "Current session identifier (omitted until session starts)" } - StatusLineItem::FastMode => "Whether Fast mode is currently active", + StatusLineItem::FastMode => "Current service tier (or default when unset)", StatusLineItem::ThreadTitle => "Current thread title (omitted when unavailable)", StatusLineItem::TaskProgress => { "Latest task progress from update_plan (omitted until available)" diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index 93fca824ca..cab968fd75 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -47,7 +47,7 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::TotalInputTokens => "0 in", StatusSurfacePreviewItem::TotalOutputTokens => "0 out", StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4", - StatusSurfacePreviewItem::FastMode => "Fast on", + StatusSurfacePreviewItem::FastMode => "Tier Fast", StatusSurfacePreviewItem::Model => "gpt-5.2-codex", StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium", StatusSurfacePreviewItem::TaskProgress => "Tasks 0/0", diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs index a6e83a3ff9..e51b17a6f6 100644 --- a/codex-rs/tui/src/bottom_pane/title_setup.rs +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -73,7 +73,7 @@ pub(crate) enum TerminalTitleItem { TotalOutputTokens, /// Full session UUID. SessionId, - /// Whether Fast mode is currently active. + /// Current service-tier selection. FastMode, /// Current model name. #[strum(to_string = "model", serialize = "model-name")] @@ -117,7 +117,7 @@ impl TerminalTitleItem { TerminalTitleItem::SessionId => { "Current session identifier (omitted until session starts)" } - TerminalTitleItem::FastMode => "Whether Fast mode is currently active", + TerminalTitleItem::FastMode => "Current service tier (or default when unset)", TerminalTitleItem::Model => "Current model name", TerminalTitleItem::ModelWithReasoning => "Current model name with reasoning level", TerminalTitleItem::TaskProgress => { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index cdd31e5329..3d644b5c16 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -57,6 +57,7 @@ use crate::bottom_pane::StatusSurfacePreviewData; use crate::bottom_pane::StatusSurfacePreviewItem; use crate::bottom_pane::TerminalTitleItem; use crate::bottom_pane::TerminalTitleSetupView; +use crate::bottom_pane::slash_commands::ServiceTierCommand; use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME; use crate::legacy_core::config::Config; use crate::legacy_core::config::Constrained; @@ -2364,7 +2365,7 @@ impl ChatWidget { self.current_rollout_path = event.rollout_path.clone(); self.current_cwd = Some(event.cwd.to_path_buf()); self.config.cwd = event.cwd.clone(); - self.effective_service_tier = event.service_tier; + self.effective_service_tier = event.service_tier.clone(); if let Err(err) = self .config .permissions @@ -2406,6 +2407,7 @@ impl ChatWidget { self.refresh_model_display(); self.refresh_status_surfaces(); self.sync_fast_command_enabled(); + self.sync_service_tier_commands(); self.sync_personality_command_enabled(); self.sync_plugins_command_enabled(); self.sync_goal_command_enabled(); @@ -2413,7 +2415,7 @@ impl ChatWidget { if display == SessionConfiguredDisplay::Normal { let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = - self.should_show_fast_status(&model_for_header, event.service_tier); + self.should_show_fast_status(&model_for_header, event.service_tier.clone()); #[cfg(test)] let initial_messages = event.initial_messages.clone(); let session_info_cell = history_cell::new_session_info( @@ -5270,7 +5272,7 @@ 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; + let effective_service_tier = config.service_tier.clone(); let current_terminal_info = terminal_info(); let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok(); let default_keymap = RuntimeKeymap::defaults(); @@ -5451,6 +5453,7 @@ impl ChatWidget { .bottom_pane .set_collaboration_modes_enabled(/*enabled*/ true); widget.sync_fast_command_enabled(); + widget.sync_service_tier_commands(); widget.sync_personality_command_enabled(); widget.sync_plugins_command_enabled(); widget.sync_goal_command_enabled(); @@ -6236,7 +6239,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 { + 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, @@ -10358,18 +10361,18 @@ impl ChatWidget { self.config.personality = Some(personality); } - /// Set Fast mode in the widget's config copy. + /// Set the selected service tier in the widget's config copy. pub(crate) fn set_service_tier(&mut self, service_tier: Option) { - self.config.service_tier = service_tier; + self.config.service_tier = service_tier.clone(); self.effective_service_tier = service_tier; } pub(crate) fn current_service_tier(&self) -> Option { - self.effective_service_tier + self.effective_service_tier.clone() } pub(crate) fn configured_service_tier(&self) -> Option { - self.config.service_tier + self.config.service_tier.clone() } pub(crate) fn fast_default_opt_out(&self) -> Option { @@ -10412,7 +10415,7 @@ impl ChatWidget { service_tier: Option, ) -> bool { self.model_supports_fast_mode(model) - && matches!(service_tier, Some(ServiceTier::Fast)) + && service_tier.as_ref().is_some_and(ServiceTier::is_priority) && self.has_chatgpt_account } @@ -10505,6 +10508,11 @@ impl ChatWidget { .set_fast_command_enabled(self.fast_mode_enabled()); } + fn sync_service_tier_commands(&mut self) { + self.bottom_pane + .set_service_tier_commands(self.available_service_tier_commands()); + } + fn sync_personality_command_enabled(&mut self) { self.bottom_pane .set_personality_command_enabled(self.config.features.enabled(Feature::Personality)); @@ -10520,30 +10528,55 @@ impl ChatWidget { .set_goal_command_enabled(self.config.features.enabled(Feature::Goals)); } - fn current_model_supports_personality(&self) -> bool { - let model = self.current_model(); + fn model_preset(&self, model: &str) -> Option { self.model_catalog .try_list_models() .ok() - .and_then(|models| { - models - .into_iter() - .find(|preset| preset.model == model) - .map(|preset| preset.supports_personality) + .and_then(|models| models.into_iter().find(|preset| preset.model == model)) + } + + fn available_service_tier_commands(&self) -> Vec { + self.model_preset(self.current_model()) + .map(|model| { + crate::bottom_pane::slash_commands::service_tier_commands_for_model(&model) }) + .unwrap_or_default() + } + + pub(crate) fn service_tier_display_name( + &self, + model: &str, + service_tier: &ServiceTier, + ) -> String { + self.model_preset(model) + .and_then(|preset| { + preset + .service_tier(service_tier) + .map(|tier| tier.name.clone()) + }) + .unwrap_or_else(|| service_tier.to_string()) + } + + pub(crate) fn current_service_tier_name(&self) -> Option { + self.current_service_tier() + .map(|tier| self.service_tier_display_name(self.current_model(), &tier)) + } + + pub(crate) fn current_service_tier_status_label(&self) -> String { + self.current_service_tier_name() + .map(|name| format!("Tier {name}")) + .unwrap_or_else(|| "Tier default".to_string()) + } + + fn current_model_supports_personality(&self) -> bool { + self.model_preset(self.current_model()) + .map(|preset| preset.supports_personality) .unwrap_or(false) } fn model_supports_fast_mode(&self, model: &str) -> bool { - self.model_catalog - .try_list_models() - .ok() - .and_then(|models| { - models - .into_iter() - .find(|preset| preset.model == model) - .map(|preset| preset.supports_fast_mode()) - }) + self.model_preset(model) + .map(|preset| preset.supports_service_tier(&ServiceTier::priority())) .unwrap_or(false) } @@ -10552,16 +10585,8 @@ impl ChatWidget { /// We intentionally default to `true` when model metadata cannot be read so transient catalog /// failures do not hard-block user input in the UI. fn current_model_supports_images(&self) -> bool { - let model = self.current_model(); - self.model_catalog - .try_list_models() - .ok() - .and_then(|models| { - models - .into_iter() - .find(|preset| preset.model == model) - .map(|preset| preset.input_modalities.contains(&InputModality::Image)) - }) + self.model_preset(self.current_model()) + .map(|preset| preset.input_modalities.contains(&InputModality::Image)) .unwrap_or(true) } @@ -10699,6 +10724,7 @@ impl ChatWidget { /// (`refresh_status_line`). fn refresh_model_dependent_surfaces(&mut self) { self.refresh_model_display(); + self.sync_service_tier_commands(); self.refresh_status_line(); } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 82f366fbb7..4c29d1600f 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -9,6 +9,7 @@ use super::*; use crate::app_event::ThreadGoalSetMode; use crate::bottom_pane::prompt_args::parse_slash_name; use crate::bottom_pane::slash_commands; +use crate::bottom_pane::slash_commands::SlashCommandAction; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SlashCommandDispatchSource { @@ -38,9 +39,10 @@ impl ChatWidget { /// The composer stages history before returning `InputResult::Command`; this wrapper commits /// that staged entry after dispatch so slash-command recall follows the same "submitted input" /// rule as normal text. - pub(super) fn handle_slash_command_dispatch(&mut self, cmd: SlashCommand) { + pub(super) fn handle_slash_command_dispatch(&mut self, cmd: SlashCommandAction) { + let is_goal = cmd.as_builtin() == Some(SlashCommand::Goal); self.dispatch_command(cmd); - if cmd == SlashCommand::Goal { + if is_goal { self.bottom_pane.drain_pending_submission_state(); } self.bottom_pane.record_pending_slash_command_history(); @@ -53,7 +55,7 @@ impl ChatWidget { /// the only input-result entry point avoids double-recording commands with inline args. pub(super) fn handle_slash_command_with_args_dispatch( &mut self, - cmd: SlashCommand, + cmd: SlashCommandAction, args: String, text_elements: Vec, ) { @@ -103,11 +105,11 @@ impl ChatWidget { self.request_side_conversation(parent_thread_id, /*user_message*/ None); } - pub(super) fn dispatch_command(&mut self, cmd: SlashCommand) { - if !self.ensure_slash_command_allowed_in_side_conversation(cmd) { + pub(super) fn dispatch_command(&mut self, cmd: SlashCommandAction) { + if !self.ensure_slash_command_allowed_in_side_conversation(&cmd) { return; } - if !self.ensure_side_command_allowed_outside_review(cmd) { + if !self.ensure_side_command_allowed_outside_review(&cmd) { return; } if !cmd.available_during_task() && self.bottom_pane.is_task_running() { @@ -122,32 +124,40 @@ impl ChatWidget { } match cmd { - SlashCommand::Feedback => { + SlashCommandAction::ServiceTier(command) => { + let next_tier = + if self.current_service_tier().as_ref() == Some(&command.service_tier) { + None + } else { + Some(command.service_tier) + }; + self.set_service_tier_selection(next_tier); + } + SlashCommandAction::Builtin(SlashCommand::Feedback) => { if !self.config.feedback_enabled { let params = crate::bottom_pane::feedback_disabled_params(); self.bottom_pane.show_selection_view(params); self.request_redraw(); return; } - // Step 1: pick a category (UI built in feedback_view) let params = crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); self.bottom_pane.show_selection_view(params); self.request_redraw(); } - SlashCommand::New => { + SlashCommandAction::Builtin(SlashCommand::New) => { self.app_event_tx.send(AppEvent::NewSession); } - SlashCommand::Clear => { + SlashCommandAction::Builtin(SlashCommand::Clear) => { self.app_event_tx.send(AppEvent::ClearUi); } - SlashCommand::Resume => { + SlashCommandAction::Builtin(SlashCommand::Resume) => { self.app_event_tx.send(AppEvent::OpenResumePicker); } - SlashCommand::Fork => { + SlashCommandAction::Builtin(SlashCommand::Fork) => { self.app_event_tx.send(AppEvent::ForkCurrentSession); } - SlashCommand::Init => { + SlashCommandAction::Builtin(SlashCommand::Init) => { let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME); if init_target.exists() { let message = format!( @@ -159,33 +169,25 @@ impl ChatWidget { const INIT_PROMPT: &str = include_str!("../../prompt_for_init_command.md"); self.submit_user_message(INIT_PROMPT.to_string().into()); } - SlashCommand::Compact => { + SlashCommandAction::Builtin(SlashCommand::Compact) => { self.clear_token_usage(); if !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(/*running*/ true); } self.app_event_tx.compact(); } - SlashCommand::Review => { + SlashCommandAction::Builtin(SlashCommand::Review) => { self.open_review_popup(); } - SlashCommand::Rename => { + SlashCommandAction::Builtin(SlashCommand::Rename) => { self.session_telemetry .counter("codex.thread.rename", /*inc*/ 1, &[]); self.show_rename_prompt(); } - SlashCommand::Model => { + SlashCommandAction::Builtin(SlashCommand::Model) => { self.open_model_popup(); } - SlashCommand::Fast => { - let next_tier = if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) { - None - } else { - Some(ServiceTier::Fast) - }; - self.set_service_tier_selection(next_tier); - } - SlashCommand::Realtime => { + SlashCommandAction::Builtin(SlashCommand::Realtime) => { if !self.realtime_conversation_enabled() { return; } @@ -195,19 +197,19 @@ impl ChatWidget { self.start_realtime_conversation(); } } - SlashCommand::Settings => { + SlashCommandAction::Builtin(SlashCommand::Settings) => { if !self.realtime_audio_device_selection_enabled() { return; } self.open_realtime_audio_popup(); } - SlashCommand::Personality => { + SlashCommandAction::Builtin(SlashCommand::Personality) => { self.open_personality_popup(); } - SlashCommand::Plan => { + SlashCommandAction::Builtin(SlashCommand::Plan) => { self.apply_plan_slash_command(); } - SlashCommand::Goal => { + SlashCommandAction::Builtin(SlashCommand::Goal) => { if !self.config.features.enabled(Feature::Goals) { return; } @@ -221,7 +223,7 @@ impl ChatWidget { ); } } - SlashCommand::Collab => { + SlashCommandAction::Builtin(SlashCommand::Collab) => { if !self.collaboration_modes_enabled() { self.add_info_message( "Collaboration modes are disabled.".to_string(), @@ -231,22 +233,21 @@ impl ChatWidget { } self.open_collaboration_modes_popup(); } - SlashCommand::Side => { + SlashCommandAction::Builtin(SlashCommand::Side) => { self.request_empty_side_conversation(); } - SlashCommand::Agent | SlashCommand::MultiAgents => { + SlashCommandAction::Builtin(SlashCommand::Agent) + | SlashCommandAction::Builtin(SlashCommand::MultiAgents) => { self.app_event_tx.send(AppEvent::OpenAgentPicker); } - SlashCommand::Approvals => { + SlashCommandAction::Builtin(SlashCommand::Approvals) + | SlashCommandAction::Builtin(SlashCommand::Permissions) => { self.open_permissions_popup(); } - SlashCommand::Permissions => { - self.open_permissions_popup(); - } - SlashCommand::Keymap => { + SlashCommandAction::Builtin(SlashCommand::Keymap) => { self.open_keymap_picker(); } - SlashCommand::ElevateSandbox => { + SlashCommandAction::Builtin(SlashCommand::ElevateSandbox) => { #[cfg(target_os = "windows")] { let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); @@ -255,8 +256,6 @@ impl ChatWidget { if !windows_degraded_sandbox_enabled || !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED { - // This command should not be visible/recognized outside degraded mode, - // but guard anyway in case something dispatches it directly. return; } @@ -264,8 +263,6 @@ impl ChatWidget { .into_iter() .find(|preset| preset.id == "auto") else { - // Avoid panicking in interactive UI; treat this as a recoverable - // internal error. self.add_error_message( "Internal error: missing the 'auto' approval preset.".to_string(), ); @@ -293,36 +290,33 @@ impl ChatWidget { #[cfg(not(target_os = "windows"))] { let _ = &self.session_telemetry; - // Not supported; on non-Windows this command should never be reachable. } } - SlashCommand::SandboxReadRoot => { + SlashCommandAction::Builtin(SlashCommand::SandboxReadRoot) => { self.add_error_message( "Usage: /sandbox-add-read-dir ".to_string(), ); } - SlashCommand::Experimental => { + SlashCommandAction::Builtin(SlashCommand::Experimental) => { self.open_experimental_popup(); } - SlashCommand::AutoReview => { + SlashCommandAction::Builtin(SlashCommand::AutoReview) => { self.open_auto_review_denials_popup(); } - SlashCommand::Memories => { + SlashCommandAction::Builtin(SlashCommand::Memories) => { self.open_memories_popup(); } - SlashCommand::Quit | SlashCommand::Exit => { + SlashCommandAction::Builtin(SlashCommand::Quit) + | SlashCommandAction::Builtin(SlashCommand::Exit) => { self.request_quit_without_confirmation(); } - SlashCommand::Logout => { + SlashCommandAction::Builtin(SlashCommand::Logout) => { self.app_event_tx.send(AppEvent::Logout); } - // SlashCommand::Undo => { - // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); - // } - SlashCommand::Copy => { + SlashCommandAction::Builtin(SlashCommand::Copy) => { self.copy_last_agent_markdown(); } - SlashCommand::Diff => { + SlashCommandAction::Builtin(SlashCommand::Diff) => { self.add_diff_in_progress(); let tx = self.app_event_tx.clone(); tokio::spawn(async move { @@ -339,13 +333,13 @@ impl ChatWidget { tx.send(AppEvent::DiffResult(text)); }); } - SlashCommand::Mention => { + SlashCommandAction::Builtin(SlashCommand::Mention) => { self.insert_str("@"); } - SlashCommand::Skills => { + SlashCommandAction::Builtin(SlashCommand::Skills) => { self.open_skills_menu(); } - SlashCommand::Status => { + SlashCommandAction::Builtin(SlashCommand::Status) => { if self.should_prefetch_rate_limits() { let request_id = self.next_status_refresh_request_id; self.next_status_refresh_request_id = @@ -360,40 +354,38 @@ impl ChatWidget { ); } } - SlashCommand::DebugConfig => { + SlashCommandAction::Builtin(SlashCommand::DebugConfig) => { self.add_debug_config_output(); } - SlashCommand::Title => { + SlashCommandAction::Builtin(SlashCommand::Title) => { self.open_terminal_title_setup(); } - SlashCommand::Statusline => { + SlashCommandAction::Builtin(SlashCommand::Statusline) => { self.open_status_line_setup(); } - SlashCommand::Theme => { + SlashCommandAction::Builtin(SlashCommand::Theme) => { self.open_theme_picker(); } - SlashCommand::Ps => { + SlashCommandAction::Builtin(SlashCommand::Ps) => { self.add_ps_output(); } - SlashCommand::Stop => { + SlashCommandAction::Builtin(SlashCommand::Stop) => { self.clean_background_terminals(); } - SlashCommand::MemoryDrop => { + SlashCommandAction::Builtin(SlashCommand::MemoryDrop) + | SlashCommandAction::Builtin(SlashCommand::MemoryUpdate) => { self.add_app_server_stub_message("Memory maintenance"); } - SlashCommand::MemoryUpdate => { - self.add_app_server_stub_message("Memory maintenance"); - } - SlashCommand::Mcp => { + SlashCommandAction::Builtin(SlashCommand::Mcp) => { self.add_mcp_output(McpServerStatusDetail::ToolsAndAuthOnly); } - SlashCommand::Apps => { + SlashCommandAction::Builtin(SlashCommand::Apps) => { self.add_connectors_output(); } - SlashCommand::Plugins => { + SlashCommandAction::Builtin(SlashCommand::Plugins) => { self.add_plugins_output(); } - SlashCommand::Rollout => { + SlashCommandAction::Builtin(SlashCommand::Rollout) => { if let Some(path) = self.rollout_path() { self.add_info_message( format!("Current rollout path: {}", path.display()), @@ -406,7 +398,7 @@ impl ChatWidget { ); } } - SlashCommand::TestApproval => { + SlashCommandAction::Builtin(SlashCommand::TestApproval) => { use std::collections::HashMap; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; @@ -447,14 +439,14 @@ impl ChatWidget { /// path as well would make a single command appear twice during Up-arrow navigation. pub(super) fn dispatch_command_with_args( &mut self, - cmd: SlashCommand, + cmd: SlashCommandAction, args: String, text_elements: Vec, ) { - if !self.ensure_slash_command_allowed_in_side_conversation(cmd) { + if !self.ensure_slash_command_allowed_in_side_conversation(&cmd) { return; } - if !self.ensure_side_command_allowed_outside_review(cmd) { + if !self.ensure_side_command_allowed_outside_review(&cmd) { return; } if !cmd.supports_inline_args() { @@ -535,7 +527,7 @@ impl ChatWidget { fn dispatch_prepared_command_with_args( &mut self, - cmd: SlashCommand, + cmd: SlashCommandAction, prepared: PreparedSlashCommandArgs, ) { let PreparedSlashCommandArgs { @@ -547,33 +539,40 @@ impl ChatWidget { source, } = prepared; let trimmed = args.trim(); + let is_goal_command = cmd.as_builtin() == Some(SlashCommand::Goal); match cmd { - SlashCommand::Fast => { + SlashCommandAction::ServiceTier(command) => { match trimmed.to_ascii_lowercase().as_str() { - "on" => self.set_service_tier_selection(Some(ServiceTier::Fast)), + "on" => self.set_service_tier_selection(Some(command.service_tier.clone())), "off" => self.set_service_tier_selection(/*service_tier*/ None), "status" => { - let status = - if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) { - "on" - } else { - "off" - }; + let status = if self.current_service_tier().as_ref() + == Some(&command.service_tier) + { + "on" + } else { + "off" + }; self.add_info_message( - format!("Fast mode is {status}."), + format!("{} service tier is {status}.", command.name), /*hint*/ None, ); } _ => { - self.add_error_message("Usage: /fast [on|off|status]".to_string()); + self.add_error_message(format!( + "Usage: /{} [on|off|status]", + command.command + )); } } } - SlashCommand::Mcp => match trimmed.to_ascii_lowercase().as_str() { - "verbose" => self.add_mcp_output(McpServerStatusDetail::Full), - _ => self.add_error_message("Usage: /mcp [verbose]".to_string()), - }, - SlashCommand::Rename if !trimmed.is_empty() => { + SlashCommandAction::Builtin(SlashCommand::Mcp) => { + match trimmed.to_ascii_lowercase().as_str() { + "verbose" => self.add_mcp_output(McpServerStatusDetail::Full), + _ => self.add_error_message("Usage: /mcp [verbose]".to_string()), + } + } + SlashCommandAction::Builtin(SlashCommand::Rename) if !trimmed.is_empty() => { if !self.ensure_thread_rename_allowed() { return; } @@ -585,7 +584,7 @@ impl ChatWidget { }; self.app_event_tx.set_thread_name(name); } - SlashCommand::Plan if !trimmed.is_empty() => { + SlashCommandAction::Builtin(SlashCommand::Plan) if !trimmed.is_empty() => { if !self.apply_plan_slash_command() { return; } @@ -606,7 +605,7 @@ impl ChatWidget { self.queue_user_message(user_message); } } - SlashCommand::Goal if !trimmed.is_empty() => { + SlashCommandAction::Builtin(SlashCommand::Goal) if !trimmed.is_empty() => { if !self.config.features.enabled(Feature::Goals) { return; } @@ -687,7 +686,7 @@ impl ChatWidget { self.bottom_pane.drain_pending_submission_state(); } } - SlashCommand::Side if !trimmed.is_empty() => { + SlashCommandAction::Builtin(SlashCommand::Side) if !trimmed.is_empty() => { let Some(parent_thread_id) = self.thread_id else { self.add_error_message( "'/side' is unavailable before the session starts.".to_string(), @@ -704,23 +703,23 @@ impl ChatWidget { ); self.request_side_conversation(parent_thread_id, Some(user_message)); } - SlashCommand::Review if !trimmed.is_empty() => { + SlashCommandAction::Builtin(SlashCommand::Review) if !trimmed.is_empty() => { self.submit_op(AppCommand::review(ReviewRequest { target: ReviewTarget::Custom { instructions: args }, user_facing_hint: None, })); } - SlashCommand::Resume if !trimmed.is_empty() => { + SlashCommandAction::Builtin(SlashCommand::Resume) if !trimmed.is_empty() => { self.app_event_tx .send(AppEvent::ResumeSessionByIdOrName(args)); } - SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { + SlashCommandAction::Builtin(SlashCommand::SandboxReadRoot) if !trimmed.is_empty() => { self.app_event_tx .send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args }); } _ => self.dispatch_command(cmd), } - if source == SlashCommandDispatchSource::Live && cmd != SlashCommand::Goal { + if source == SlashCommandDispatchSource::Live && !is_goal_command { self.bottom_pane.drain_pending_submission_state(); } } @@ -755,8 +754,11 @@ impl ChatWidget { return QueueDrain::Stop; } - let Some(cmd) = slash_commands::find_builtin_command(name, self.builtin_command_flags()) - else { + let Some(cmd) = slash_commands::find_command( + name, + self.builtin_command_flags(), + &self.available_service_tier_commands(), + ) else { self.add_info_message( format!( r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# @@ -791,7 +793,7 @@ impl ChatWidget { &text_elements, ); self.dispatch_prepared_command_with_args( - cmd, + cmd.clone(), PreparedSlashCommandArgs { args: trimmed_rest.to_string(), text_elements: args_elements, @@ -827,60 +829,60 @@ impl ChatWidget { } } - fn queued_command_drain_result(&self, cmd: SlashCommand) -> QueueDrain { + fn queued_command_drain_result(&self, cmd: SlashCommandAction) -> QueueDrain { if self.is_user_turn_pending_or_running() || !self.bottom_pane.no_modal_or_popup_active() { return QueueDrain::Stop; } match cmd { - SlashCommand::Fast - | SlashCommand::Status - | SlashCommand::DebugConfig - | SlashCommand::Ps - | SlashCommand::Stop - | SlashCommand::MemoryDrop - | SlashCommand::MemoryUpdate - | SlashCommand::Mcp - | SlashCommand::Apps - | SlashCommand::Plugins - | SlashCommand::Rollout - | SlashCommand::Copy - | SlashCommand::Diff - | SlashCommand::Rename - | SlashCommand::TestApproval => QueueDrain::Continue, - SlashCommand::Feedback - | SlashCommand::New - | SlashCommand::Clear - | SlashCommand::Resume - | SlashCommand::Fork - | SlashCommand::Init - | SlashCommand::Compact - | SlashCommand::Review - | SlashCommand::Model - | SlashCommand::Realtime - | SlashCommand::Settings - | SlashCommand::Personality - | SlashCommand::Plan - | SlashCommand::Goal - | SlashCommand::Collab - | SlashCommand::Side - | SlashCommand::Keymap - | SlashCommand::Agent - | SlashCommand::MultiAgents - | SlashCommand::Approvals - | SlashCommand::Permissions - | SlashCommand::ElevateSandbox - | SlashCommand::SandboxReadRoot - | SlashCommand::Experimental - | SlashCommand::AutoReview - | SlashCommand::Memories - | SlashCommand::Quit - | SlashCommand::Exit - | SlashCommand::Logout - | SlashCommand::Mention - | SlashCommand::Skills - | SlashCommand::Title - | SlashCommand::Statusline - | SlashCommand::Theme => QueueDrain::Stop, + SlashCommandAction::ServiceTier(_) + | SlashCommandAction::Builtin(SlashCommand::Status) + | SlashCommandAction::Builtin(SlashCommand::DebugConfig) + | SlashCommandAction::Builtin(SlashCommand::Ps) + | SlashCommandAction::Builtin(SlashCommand::Stop) + | SlashCommandAction::Builtin(SlashCommand::MemoryDrop) + | SlashCommandAction::Builtin(SlashCommand::MemoryUpdate) + | SlashCommandAction::Builtin(SlashCommand::Mcp) + | SlashCommandAction::Builtin(SlashCommand::Apps) + | SlashCommandAction::Builtin(SlashCommand::Plugins) + | SlashCommandAction::Builtin(SlashCommand::Rollout) + | SlashCommandAction::Builtin(SlashCommand::Copy) + | SlashCommandAction::Builtin(SlashCommand::Diff) + | SlashCommandAction::Builtin(SlashCommand::Rename) + | SlashCommandAction::Builtin(SlashCommand::TestApproval) => QueueDrain::Continue, + SlashCommandAction::Builtin(SlashCommand::Feedback) + | SlashCommandAction::Builtin(SlashCommand::New) + | SlashCommandAction::Builtin(SlashCommand::Clear) + | SlashCommandAction::Builtin(SlashCommand::Resume) + | SlashCommandAction::Builtin(SlashCommand::Fork) + | SlashCommandAction::Builtin(SlashCommand::Init) + | SlashCommandAction::Builtin(SlashCommand::Compact) + | SlashCommandAction::Builtin(SlashCommand::Review) + | SlashCommandAction::Builtin(SlashCommand::Model) + | SlashCommandAction::Builtin(SlashCommand::Realtime) + | SlashCommandAction::Builtin(SlashCommand::Settings) + | SlashCommandAction::Builtin(SlashCommand::Personality) + | SlashCommandAction::Builtin(SlashCommand::Plan) + | SlashCommandAction::Builtin(SlashCommand::Goal) + | SlashCommandAction::Builtin(SlashCommand::Collab) + | SlashCommandAction::Builtin(SlashCommand::Side) + | SlashCommandAction::Builtin(SlashCommand::Keymap) + | SlashCommandAction::Builtin(SlashCommand::Agent) + | SlashCommandAction::Builtin(SlashCommand::MultiAgents) + | SlashCommandAction::Builtin(SlashCommand::Approvals) + | SlashCommandAction::Builtin(SlashCommand::Permissions) + | SlashCommandAction::Builtin(SlashCommand::ElevateSandbox) + | SlashCommandAction::Builtin(SlashCommand::SandboxReadRoot) + | SlashCommandAction::Builtin(SlashCommand::Experimental) + | SlashCommandAction::Builtin(SlashCommand::AutoReview) + | SlashCommandAction::Builtin(SlashCommand::Memories) + | SlashCommandAction::Builtin(SlashCommand::Quit) + | SlashCommandAction::Builtin(SlashCommand::Exit) + | SlashCommandAction::Builtin(SlashCommand::Logout) + | SlashCommandAction::Builtin(SlashCommand::Mention) + | SlashCommandAction::Builtin(SlashCommand::Skills) + | SlashCommandAction::Builtin(SlashCommand::Title) + | SlashCommandAction::Builtin(SlashCommand::Statusline) + | SlashCommandAction::Builtin(SlashCommand::Theme) => QueueDrain::Stop, } } @@ -909,7 +911,10 @@ impl ChatWidget { .collect() } - fn ensure_slash_command_allowed_in_side_conversation(&mut self, cmd: SlashCommand) -> bool { + fn ensure_slash_command_allowed_in_side_conversation( + &mut self, + cmd: &SlashCommandAction, + ) -> bool { if !self.active_side_conversation || cmd.available_in_side_conversation() { return true; } @@ -921,8 +926,8 @@ impl ChatWidget { false } - fn ensure_side_command_allowed_outside_review(&mut self, cmd: SlashCommand) -> bool { - if cmd != SlashCommand::Side || !self.is_review_mode { + fn ensure_side_command_allowed_outside_review(&mut self, cmd: &SlashCommandAction) -> bool { + if cmd.as_builtin() != Some(SlashCommand::Side) || !self.is_review_mode { return true; } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_fast_mode_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_fast_mode_footer.snap index ad644699c4..2ecbba618b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_fast_mode_footer.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_fast_mode_footer.snap @@ -6,4 +6,4 @@ expression: terminal.backend() " " "› Ask Codex to do anything " " " -" Fast on " +" Tier Fast " diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 6b590d7ea6..1477c024a5 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -559,13 +559,7 @@ impl ChatWidget { format_tokens_compact(self.status_line_total_usage().output_tokens) )), StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()), - StatusLineItem::FastMode => Some( - if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) { - "Fast on".to_string() - } else { - "Fast off".to_string() - }, - ), + StatusLineItem::FastMode => Some(self.current_service_tier_status_label()), StatusLineItem::ThreadTitle => self.thread_name.as_ref().and_then(|name| { let trimmed = name.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) @@ -679,13 +673,11 @@ impl ChatWidget { fn model_with_reasoning_display_name(&self) -> String { let label = Self::status_line_reasoning_effort_label(self.effective_reasoning_effort()); - let fast_label = - if self.should_show_fast_status(self.current_model(), self.current_service_tier()) { - " fast" - } else { - "" - }; - format!("{} {label}{fast_label}", self.model_display_name()) + let service_tier_label = self + .current_service_tier_name() + .map(|name| format!(" {}", name.to_ascii_lowercase())) + .unwrap_or_default(); + format!("{} {label}{service_tier_label}", self.model_display_name()) } /// Computes the compact runtime status label used by word-based status items. diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 015340bbb2..5312dce6a7 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -372,8 +372,12 @@ pub(crate) fn set_chatgpt_auth(chat: &mut ChatWidget) { } fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> ModelInfo { - let additional_speed_tiers = if supports_fast_mode { - vec![codex_protocol::openai_models::SPEED_TIER_FAST] + let service_tiers = if supports_fast_mode { + vec![json!({ + "id": "priority", + "name": "Fast", + "description": "Fast tier", + })] } else { Vec::new() }; @@ -387,7 +391,7 @@ fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> Model "visibility": "list", "supported_in_api": true, "priority": priority, - "additional_speed_tiers": additional_speed_tiers, + "service_tiers": service_tiers, "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 135d8dbaa4..c9ee215843 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2170,7 +2170,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() { description: "medium".to_string(), }], supports_personality: false, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), is_default: false, upgrade: None, show_in_picker, @@ -2392,7 +2392,7 @@ async fn single_reasoning_option_skips_selection() { default_reasoning_effort: ReasoningEffortConfig::High, supported_reasoning_efforts: single_effort, supports_personality: false, - additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), 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 54d45c8481..b372f52006 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1,4 +1,6 @@ use super::*; +use crate::bottom_pane::slash_commands::ServiceTierCommand; +use crate::bottom_pane::slash_commands::SlashCommandAction; use pretty_assertions::assert_eq; fn turn_complete_event(turn_id: &str, last_agent_message: Option<&str>) -> TurnCompleteEvent { @@ -23,6 +25,15 @@ fn queue_composer_text_with_tab(chat: &mut ChatWidget, text: &str) { chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); } +fn fast_service_tier_command() -> SlashCommandAction { + SlashCommandAction::ServiceTier(ServiceTierCommand { + service_tier: ServiceTier::priority(), + command: "fast".to_string(), + name: "Fast".to_string(), + description: "Fast tier".to_string(), + }) +} + fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String { chat.bottom_pane .set_composer_text(String::new(), Vec::new(), Vec::new()); @@ -2050,14 +2061,14 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); - chat.dispatch_command(SlashCommand::Fast); + chat.dispatch_command(fast_service_tier_command()); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( events.iter().any(|event| matches!( event, AppEvent::CodexOp(AppCommand::OverrideTurnContext { - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(ServiceTier::priority())), .. }) )), @@ -2067,7 +2078,7 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { events.iter().any(|event| matches!( event, AppEvent::PersistServiceTierSelection { - service_tier: Some(ServiceTier::Fast), + service_tier: Some(ServiceTier::priority()), } )), "expected fast-mode persistence app event; events: {events:?}" @@ -2083,7 +2094,7 @@ async fn user_turn_carries_service_tier_after_fast_toggle() { set_chatgpt_auth(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); - chat.dispatch_command(SlashCommand::Fast); + chat.dispatch_command(fast_service_tier_command()); let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); @@ -2093,7 +2104,7 @@ async fn user_turn_carries_service_tier_after_fast_toggle() { match next_submit_op(&mut op_rx) { Op::UserTurn { - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(ServiceTier::priority())), .. } => {} other => panic!("expected Op::UserTurn with fast service tier, got {other:?}"), @@ -2129,7 +2140,7 @@ async fn queued_fast_slash_applies_before_next_queued_message() { events.iter().any(|event| matches!( event, AppEvent::CodexOp(AppCommand::OverrideTurnContext { - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(ServiceTier::priority())), .. }) )), @@ -2139,7 +2150,7 @@ async fn queued_fast_slash_applies_before_next_queued_message() { match next_submit_op(&mut op_rx) { Op::UserTurn { items, - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(ServiceTier::priority())), .. } => assert_eq!( items, @@ -2159,10 +2170,10 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() { set_chatgpt_auth(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); - chat.dispatch_command(SlashCommand::Fast); + chat.dispatch_command(fast_service_tier_command()); let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); - chat.dispatch_command_with_args(SlashCommand::Fast, "off".to_string(), Vec::new()); + chat.dispatch_command_with_args(fast_service_tier_command(), "off".to_string(), Vec::new()); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( events.iter().any(|event| matches!( diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 530629b2ae..f21f26c807 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1037,14 +1037,14 @@ async fn commentary_completion_restores_status_indicator_before_exec_begin() { async fn fast_status_indicator_requires_chatgpt_auth() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); - chat.set_service_tier(Some(ServiceTier::Fast)); + assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); + chat.set_service_tier(Some(ServiceTier::priority())); assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); + assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); assert!(chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); } @@ -1053,11 +1053,17 @@ async fn fast_status_indicator_requires_chatgpt_auth() { async fn fast_status_indicator_is_hidden_for_models_without_fast_support() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; set_fast_mode_test_catalog(&mut chat); - assert!(!get_available_model(&chat, "gpt-5.3-codex").supports_fast_mode()); - chat.set_service_tier(Some(ServiceTier::Fast)); + assert!( + !get_available_model(&chat, "gpt-5.3-codex") + .supports_service_tier(&ServiceTier::priority()) + ); + chat.set_service_tier(Some(ServiceTier::priority())); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); - assert!(!get_available_model(&chat, "gpt-5.3-codex").supports_fast_mode()); + assert!( + !get_available_model(&chat, "gpt-5.3-codex") + .supports_service_tier(&ServiceTier::priority()) + ); assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); } @@ -1066,10 +1072,10 @@ async fn fast_status_indicator_is_hidden_for_models_without_fast_support() { async fn fast_status_indicator_is_hidden_when_fast_mode_is_off() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); + assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); + assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); } @@ -1438,11 +1444,11 @@ async fn status_line_fast_mode_renders_on_and_off() { chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]); chat.refresh_status_line(); - assert_eq!(status_line_text(&chat), Some("Fast off".to_string())); + assert_eq!(status_line_text(&chat), Some("Tier default".to_string())); - chat.set_service_tier(Some(ServiceTier::Fast)); + chat.set_service_tier(Some(ServiceTier::priority())); chat.refresh_status_line(); - assert_eq!(status_line_text(&chat), Some("Fast on".to_string())); + assert_eq!(status_line_text(&chat), Some("Tier Fast".to_string())); } #[tokio::test] @@ -1453,7 +1459,7 @@ async fn status_line_fast_mode_footer_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_welcome_banner = false; chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]); - chat.set_service_tier(Some(ServiceTier::Fast)); + chat.set_service_tier(Some(ServiceTier::priority())); chat.refresh_status_line(); let width = 80; @@ -1472,7 +1478,7 @@ async fn status_line_fast_mode_footer_snapshot() { async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); + assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); chat.config.cwd = test_project_path().abs(); chat.config.tui_status_line = Some(vec![ "model-with-reasoning".to_string(), @@ -1480,10 +1486,10 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models( "current-dir".to_string(), ]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); - chat.set_service_tier(Some(ServiceTier::Fast)); + chat.set_service_tier(Some(ServiceTier::priority())); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); + assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); chat.refresh_status_line(); let test_cwd = test_path_display("/tmp/project"); @@ -1617,7 +1623,7 @@ async fn status_line_model_with_reasoning_fast_footer_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); + assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); chat.show_welcome_banner = false; chat.config.cwd = test_project_path().abs(); chat.config.tui_status_line = Some(vec![ @@ -1626,10 +1632,10 @@ async fn status_line_model_with_reasoning_fast_footer_snapshot() { "current-dir".to_string(), ]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); - chat.set_service_tier(Some(ServiceTier::Fast)); + chat.set_service_tier(Some(ServiceTier::priority())); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); + assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); chat.refresh_status_line(); let width = 80; @@ -1651,7 +1657,7 @@ async fn status_line_model_with_reasoning_context_remaining_footer_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); + assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); chat.show_welcome_banner = false; chat.config.cwd = test_project_path().abs(); chat.config.tui_status_line = Some(vec![ @@ -1660,10 +1666,10 @@ async fn status_line_model_with_reasoning_context_remaining_footer_snapshot() { "current-dir".to_string(), ]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); - chat.set_service_tier(Some(ServiceTier::Fast)); + chat.set_service_tier(Some(ServiceTier::priority())); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); - assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); + assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority())); chat.refresh_status_line(); let width = 80; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 4f7714505c..d431bafda7 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -13,7 +13,6 @@ pub enum SlashCommand { // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so // more frequently used commands should be listed first. Model, - Fast, Approvals, Permissions, Keymap, @@ -101,9 +100,6 @@ impl SlashCommand { SlashCommand::MemoryDrop => "DO NOT USE", SlashCommand::MemoryUpdate => "DO NOT USE", SlashCommand::Model => "choose what model and reasoning effort to use", - SlashCommand::Fast => { - "toggle Fast mode to enable fastest inference with increased plan usage" - } SlashCommand::Personality => "choose a communication style for Codex", SlashCommand::Realtime => "toggle realtime voice mode (experimental)", SlashCommand::Settings => "configure realtime microphone/speaker", @@ -145,7 +141,6 @@ impl SlashCommand { | SlashCommand::Rename | SlashCommand::Plan | SlashCommand::Goal - | SlashCommand::Fast | SlashCommand::Mcp | SlashCommand::Side | SlashCommand::Resume @@ -171,7 +166,6 @@ impl SlashCommand { | SlashCommand::Compact // | SlashCommand::Undo | SlashCommand::Model - | SlashCommand::Fast | SlashCommand::Personality | SlashCommand::Approvals | SlashCommand::Permissions diff --git a/sdk/python/src/codex_app_server/generated/v2_all.py b/sdk/python/src/codex_app_server/generated/v2_all.py index 70c7009286..c1a19743b2 100644 --- a/sdk/python/src/codex_app_server/generated/v2_all.py +++ b/sdk/python/src/codex_app_server/generated/v2_all.py @@ -3343,9 +3343,16 @@ class ServerRequestResolvedNotification(BaseModel): thread_id: Annotated[str, Field(alias="threadId")] -class ServiceTier(Enum): - fast = "fast" - flex = "flex" +class ServiceTier(RootModel[str]): + model_config = ConfigDict( + populate_by_name=True, + ) + root: Annotated[ + str, + Field( + description="String-backed service tier identifier. Known values include `priority`, `flex`, and `ultrafast`, but other backend-provided ids are allowed." + ), + ] class SessionSourceValue(Enum): @@ -5854,13 +5861,19 @@ class MigrationDetails(BaseModel): plugins: list[PluginsMigration] +class ModelServiceTier(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + description: str + id: ServiceTier + name: str + + class Model(BaseModel): model_config = ConfigDict( populate_by_name=True, ) - additional_speed_tiers: Annotated[ - list[str] | None, Field(alias="additionalSpeedTiers") - ] = [] availability_nux: Annotated[ ModelAvailabilityNux | None, Field(alias="availabilityNux") ] = None @@ -5876,6 +5889,9 @@ class Model(BaseModel): ] = ["text", "image"] is_default: Annotated[bool, Field(alias="isDefault")] model: str + service_tiers: Annotated[ + list[ModelServiceTier] | None, Field(alias="serviceTiers") + ] = [] supported_reasoning_efforts: Annotated[ list[ReasoningEffortOption], Field(alias="supportedReasoningEfforts") ]