Honor client-resolved service tier defaults (#23537)

## Why

Model catalog responses can now advertise a nullable
`default_service_tier` for each model. Codex needs to preserve three
distinct states all the way from config/app-server inputs to inference:

- no explicit service tier, so the client may apply the current model
catalog default when FastMode is enabled
- explicit `default`, meaning the user intentionally wants standard
routing
- explicit catalog tier ids such as `priority`, `flex`, or future tiers

Keeping those states distinct prevents the UI from showing one tier
while core sends another, especially after model switches or app-server
`thread/start` / `turn/start` updates.

## What Changed

- Plumbed `default_service_tier` through model catalog protocol types,
app-server model responses, generated schemas, model cache fixtures, and
provider/model-manager conversions.
- Added the request-only `default` service tier sentinel and normalized
legacy config spelling so `fast` in `config.toml` still materializes as
the runtime/request id `priority`.
- Moved catalog default resolution to the TUI/client side, including
recomputing the effective service tier when model/FastMode-dependent
surfaces change.
- Updated app-server thread lifecycle config construction so
`serviceTier: null` preserves explicit standard-routing intent by
mapping to `default` instead of internal `None`.
- Kept core responsible for validating explicit tiers against the
current model and stripping `default` before `/v1/responses`, without
applying catalog defaults itself.

## Validation

- `CARGO_INCREMENTAL=0 cargo build -p codex-cli`
- `CARGO_INCREMENTAL=0 cargo test -p codex-app-server model_list`
- `cargo test -p codex-tui service_tier`
- `cargo test -p codex-protocol service_tier_for_request`
- `cargo test -p codex-core get_service_tier`
- `RUST_MIN_STACK=8388608 CARGO_INCREMENTAL=0 cargo test -p codex-core
service_tier`
This commit is contained in:
Shijie Rao
2026-05-20 15:57:50 -07:00
committed by GitHub
Unverified
parent 0e9d222178
commit 370b13afc9
54 changed files with 682 additions and 192 deletions
@@ -11346,6 +11346,14 @@
"defaultReasoningEffort": {
"$ref": "#/definitions/v2/ReasoningEffort"
},
"defaultServiceTier": {
"default": null,
"description": "Catalog default service tier id for this model, when one is configured.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
@@ -7875,6 +7875,14 @@
"defaultReasoningEffort": {
"$ref": "#/definitions/ReasoningEffort"
},
"defaultServiceTier": {
"default": null,
"description": "Catalog default service tier id for this model, when one is configured.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
@@ -43,6 +43,14 @@
"defaultReasoningEffort": {
"$ref": "#/definitions/ReasoningEffort"
},
"defaultServiceTier": {
"default": null,
"description": "Catalog default service tier id for this model, when one is configured.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
+5 -1
View File
@@ -12,4 +12,8 @@ export type Model = { id: string, model: string, upgrade: string | null, upgrade
/**
* Deprecated: use `serviceTiers` instead.
*/
additionalSpeedTiers: Array<string>, serviceTiers: Array<ModelServiceTier>, isDefault: boolean, };
additionalSpeedTiers: Array<string>, serviceTiers: Array<ModelServiceTier>,
/**
* Catalog default service tier id for this model, when one is configured.
*/
defaultServiceTier: string | null, isDefault: boolean, };
@@ -98,6 +98,9 @@ pub struct Model {
pub additional_speed_tiers: Vec<String>,
#[serde(default)]
pub service_tiers: Vec<ModelServiceTier>,
/// Catalog default service tier id for this model, when one is configured.
#[serde(default)]
pub default_service_tier: Option<String>,
// Only one model should be marked as default.
pub is_default: bool,
}
+1 -1
View File
@@ -188,7 +188,7 @@ Example with notification opt-out:
- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`.
- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
+1
View File
@@ -53,6 +53,7 @@ fn model_from_preset(preset: ModelPreset) -> Model {
description: service_tier.description,
})
.collect(),
default_service_tier: preset.default_service_tier,
is_default: preset.is_default,
}
}
@@ -30,6 +30,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
priority,
additional_speed_tiers: preset.additional_speed_tiers.clone(),
service_tiers: preset.service_tiers.clone(),
default_service_tier: preset.default_service_tier.clone(),
upgrade: preset.upgrade.as_ref().map(Into::into),
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -69,6 +69,7 @@ fn model_from_preset(preset: &ModelPreset) -> Model {
description: service_tier.description.clone(),
})
.collect(),
default_service_tier: preset.default_service_tier.clone(),
is_default: preset.is_default,
}
}
@@ -22,6 +22,7 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::test_support::all_model_presets;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::Value;
@@ -136,7 +137,7 @@ async fn thread_settings_update_while_turn_is_active_emits_notification() -> Res
}
#[tokio::test]
async fn thread_settings_update_clears_service_tier() -> Result<()> {
async fn thread_settings_update_null_service_tier_uses_default() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(vec![
create_final_assistant_message_sse_response("done")?,
])
@@ -181,7 +182,10 @@ async fn thread_settings_update_clears_service_tier() -> Result<()> {
let clear_updated = read_thread_settings_updated(&mut mcp).await?;
assert_eq!(clear_updated.thread_id, thread.id);
assert_eq!(clear_updated.thread_settings.model, model_id);
assert_eq!(clear_updated.thread_settings.service_tier, None);
assert_eq!(
clear_updated.thread_settings.service_tier.as_deref(),
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE)
);
start_text_turn(&mut mcp, thread.id).await?;
timeout(
@@ -28,6 +28,7 @@ use codex_core::config::set_project_trust_level;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
@@ -484,7 +485,7 @@ model_reasoning_effort = "high"
}
#[tokio::test]
async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> {
async fn thread_start_drops_unsupported_service_tier_id() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
@@ -508,7 +509,39 @@ async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> {
.await??;
let ThreadStartResponse { service_tier, .. } = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(service_tier, Some(service_tier_id));
// Unsupported catalog ids are dropped at session config time instead of echoed back.
assert_eq!(service_tier, None);
Ok(())
}
#[tokio::test]
async fn thread_start_accepts_default_service_tier() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
.send_thread_start_request(ThreadStartParams {
service_tier: Some(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())),
..Default::default()
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await??;
let ThreadStartResponse { service_tier, .. } = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(
service_tier,
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
);
Ok(())
}
@@ -76,6 +76,7 @@ async fn models_client_hits_models_endpoint() {
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
+1 -1
View File
@@ -359,7 +359,7 @@ pub struct ConfigToml {
pub personality: Option<Personality>,
/// Optional explicit service tier request id for new turns (for example
/// `priority` or `flex`; legacy `fast` also works).
/// `default`, `priority`, or `flex`; legacy `fast` also works).
pub service_tier: Option<String>,
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
+1 -1
View File
@@ -24,7 +24,7 @@ use codex_protocol::protocol::AskForApproval;
pub struct ConfigProfile {
pub model: Option<String>,
/// Optional explicit service tier request id for new turns (for example
/// `priority` or `flex`; legacy `fast` also works).
/// `default`, `priority`, or `flex`; legacy `fast` also works).
pub service_tier: Option<String>,
/// The key in the `model_providers` map identifying the
/// [`ModelProviderInfo`] to use.
+2 -2
View File
@@ -686,7 +686,7 @@
"$ref": "#/definitions/SandboxMode"
},
"service_tier": {
"description": "Optional explicit service tier request id for new turns (for example `priority` or `flex`; legacy `fast` also works).",
"description": "Optional explicit service tier request id for new turns (for example `default`, `priority`, or `flex`; legacy `fast` also works).",
"type": "string"
},
"tools": {
@@ -4783,7 +4783,7 @@
"description": "Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`."
},
"service_tier": {
"description": "Optional explicit service tier request id for new turns (for example `priority` or `flex`; legacy `fast` also works).",
"description": "Optional explicit service tier request id for new turns (for example `default`, `priority`, or `flex`; legacy `fast` also works).",
"type": "string"
},
"shell_environment_policy": {
+1 -2
View File
@@ -748,8 +748,7 @@ impl ModelClient {
prompt.output_schema_strict,
);
let prompt_cache_key = Some(self.state.thread_id.to_string());
let service_tier =
service_tier.filter(|service_tier| model_info.supports_service_tier(service_tier));
let service_tier = model_info.service_tier_for_request(service_tier);
let request = ResponsesApiRequest {
model: model_info.slug.clone(),
instructions: instructions.clone(),
+29 -3
View File
@@ -70,6 +70,7 @@ use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
use codex_model_provider_info::WireApi;
use codex_models_manager::bundled_models_response;
use codex_network_proxy::NetworkMode;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
@@ -8277,7 +8278,7 @@ alpha = "one\ntwo"
}
#[tokio::test]
async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std::io::Result<()> {
async fn explicit_null_service_tier_override_maps_to_default_service_tier() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
@@ -8291,8 +8292,33 @@ async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std:
)
.await?;
assert_eq!(config.service_tier, None);
assert_eq!(config.notices.fast_default_opt_out, Some(true));
assert_eq!(
config.service_tier,
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
);
assert_eq!(config.notices.fast_default_opt_out, None);
Ok(())
}
#[tokio::test]
async fn default_service_tier_override_uses_default_request_value() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
service_tier: Some(Some("default".to_string())),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(
config.service_tier,
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
);
Ok(())
}
+2 -13
View File
@@ -40,8 +40,6 @@ pub enum ConfigEdit {
SetNoticeHideFullAccessWarning(bool),
/// Toggle the Windows world-writable directories warning acknowledgement flag.
SetNoticeHideWorldWritableWarning(bool),
/// Toggle the opt-out marker for Codex-managed fast defaults.
SetNoticeFastDefaultOptOut(bool),
/// Toggle the rate limit model nudge acknowledgement flag.
SetNoticeHideRateLimitModelNudge(bool),
/// Toggle the model migration prompt acknowledgement flag.
@@ -552,6 +550,8 @@ impl ConfigDocument {
ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_profile_value(
&["service_tier"],
service_tier.as_ref().map(|service_tier| {
// Keep the legacy config spelling stable. Runtime values use
// `priority`, but config.toml continues to store it as `fast`.
let config_value = match ServiceTier::from_request_value(service_tier) {
Some(ServiceTier::Fast) => "fast",
Some(ServiceTier::Flex) => "flex",
@@ -574,11 +574,6 @@ impl ConfigDocument {
&[NOTICE_TABLE_KEY, "hide_world_writable_warning"],
value(*acknowledged),
)),
ConfigEdit::SetNoticeFastDefaultOptOut(opted_out) => Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, "fast_default_opt_out"],
value(*opted_out),
)),
ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"],
@@ -1182,12 +1177,6 @@ impl ConfigEditsBuilder {
self
}
pub fn set_fast_default_opt_out(mut self, opted_out: bool) -> Self {
self.edits
.push(ConfigEdit::SetNoticeFastDefaultOptOut(opted_out));
self
}
pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self {
self.edits
.push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged));
+15
View File
@@ -4,6 +4,7 @@ use codex_config::types::McpServerOAuthConfig;
use codex_config::types::McpServerToolConfig;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::SessionPickerViewMode;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
@@ -34,6 +35,20 @@ model_reasoning_effort = "high"
assert_eq!(contents, expected);
}
#[test]
fn set_service_tier_saves_default_as_default() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.set_service_tier(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()))
.apply_blocking()
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
assert_eq!(contents, "service_tier = \"default\"\n");
}
#[test]
fn set_service_tier_saves_priority_as_fast() {
let tmp = tempdir().expect("tmpdir");
+4 -7
View File
@@ -81,6 +81,7 @@ use codex_protocol::config_types::AutoCompactTokenLimitScope;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::ShellEnvironmentPolicy;
@@ -552,6 +553,7 @@ pub struct Config {
pub model: Option<String>,
/// Effective service tier request id preference for new turns.
/// `default` means the user explicitly selected standard routing.
pub service_tier: Option<String>,
/// Model used specifically for review sessions.
@@ -3169,15 +3171,10 @@ impl Config {
let forced_login_method = cfg.forced_login_method;
let model = model.or(config_profile.model).or(cfg.model);
let mut notices = cfg.notice.unwrap_or_default();
let notices = cfg.notice.unwrap_or_default();
let service_tier = match service_tier_override {
Some(Some(service_tier)) => Some(service_tier),
Some(None) => {
// Preserve explicit standard/clear intent after the nested override
// collapses into `Config.service_tier = None`.
notices.fast_default_opt_out = Some(true);
None
}
Some(None) => Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()),
None => config_profile.service_tier.or(cfg.service_tier),
};
let service_tier = service_tier.and_then(|service_tier| {
+9 -21
View File
@@ -72,7 +72,6 @@ use codex_otel::current_span_trace_id;
use codex_otel::current_span_w3c_trace_context;
use codex_otel::set_parent_from_w3c_trace_context;
use codex_protocol::ThreadId;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyAmendment;
@@ -80,6 +79,7 @@ use codex_protocol::approvals::NetworkPolicyRuleAction;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::AutoCompactTokenLimitScope;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::Settings;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolResponse;
@@ -320,7 +320,6 @@ use codex_otel::TelemetryAuthMode;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
@@ -589,14 +588,10 @@ impl Codex {
developer_instructions: None,
},
};
let account_plan_type = auth_manager
.auth_cached()
.and_then(|auth| auth.account_plan_type());
let service_tier = get_service_tier(
config.service_tier.clone(),
config.notices.fast_default_opt_out.unwrap_or(false),
account_plan_type,
config.features.enabled(Feature::FastMode),
&model_info,
);
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
@@ -798,17 +793,16 @@ impl Codex {
fn get_service_tier(
configured_service_tier: Option<String>,
fast_default_opt_out: bool,
account_plan_type: Option<AccountPlanType>,
fast_mode_enabled: bool,
model_info: &ModelInfo,
) -> Option<String> {
if configured_service_tier.is_some() || fast_default_opt_out || !fast_mode_enabled {
return configured_service_tier;
if !fast_mode_enabled {
return None;
}
account_plan_type
.is_some_and(is_enterprise_default_service_tier_plan)
.then_some(ServiceTier::Fast.request_value().to_string())
configured_service_tier.filter(|service_tier| {
service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE
|| model_info.supports_service_tier(service_tier)
})
}
fn session_permission_profile_state_from_config(
@@ -817,12 +811,6 @@ fn session_permission_profile_state_from_config(
Ok(config.permissions.permission_profile_state().clone())
}
fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool {
plan_type == AccountPlanType::Enterprise
|| plan_type.is_business_like()
|| plan_type.is_team_like()
}
#[cfg(test)]
pub(crate) fn completed_session_loop_termination() -> SessionLoopTermination {
futures::future::ready(()).boxed().shared()
+10 -6
View File
@@ -5,6 +5,7 @@ use crate::goals::GoalRuntimeState;
use crate::skills::SkillError;
use crate::state::ActiveTurn;
use codex_protocol::SessionId;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSpecialPath;
@@ -223,12 +224,15 @@ impl SessionConfiguration {
if let Some(service_tier) = updates.service_tier.clone() {
// TODO(aibrahim): Remove once v2 clients no longer send the legacy
// "fast" service tier value.
next_configuration.service_tier = service_tier.map(|service_tier| {
ServiceTier::from_request_value(&service_tier)
.map_or(service_tier, |service_tier| {
service_tier.request_value().to_string()
})
});
next_configuration.service_tier = match service_tier {
Some(service_tier) => Some(
ServiceTier::from_request_value(&service_tier)
.map_or(service_tier, |service_tier| {
service_tier.request_value().to_string()
}),
),
None => Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()),
};
}
if let Some(personality) = updates.personality {
next_configuration.personality = Some(personality);
+120 -65
View File
@@ -32,7 +32,7 @@ use codex_models_manager::test_support::get_model_offline_for_tests;
use codex_protocol::AgentPath;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::exec_output::ExecToolCallOutput;
@@ -43,6 +43,7 @@ use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::SandboxEnforcement;
use codex_protocol::openai_models::ModelServiceTier;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -3390,92 +3391,143 @@ fn session_telemetry(
)
}
#[test]
fn get_service_tier_defaults_enterprise_accounts_to_fast() {
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::Enterprise),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::EnterpriseCbpUsageBased),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::Business),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::Team),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::SelfServeBusinessUsageBased),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast.request_value().to_string())
);
fn model_with_default_service_tier(default_service_tier: Option<&str>) -> ModelInfo {
let mut model_info = model_info::model_info_from_slug("gpt-5.4");
model_info.service_tiers = vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "Fast".to_string(),
description: "Priority processing.".to_string(),
}];
model_info.default_service_tier = default_service_tier.map(str::to_string);
model_info
}
#[test]
fn get_service_tier_respects_fast_default_opt_out() {
fn get_service_tier_does_not_use_model_default_when_absent_and_fast_mode_enabled() {
let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value()));
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ true,
Some(AccountPlanType::Enterprise),
/*fast_mode_enabled*/ true,
&model_info,
),
None
);
}
#[test]
fn get_service_tier_does_not_default_non_enterprise_or_disabled_fast_mode() {
fn get_service_tier_does_not_use_model_default_when_fast_mode_disabled() {
let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value()));
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::Pro),
/*fast_mode_enabled*/ true,
),
None
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::Enterprise),
/*fast_mode_enabled*/ false,
&model_info,
),
None
);
}
#[test]
fn get_service_tier_keeps_supported_explicit_tier() {
let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value()));
assert_eq!(
get_service_tier(
Some(ServiceTier::Fast.request_value().to_string()),
/*fast_mode_enabled*/ true,
&model_info,
),
Some(ServiceTier::Fast.request_value().to_string())
);
}
#[test]
fn get_service_tier_does_not_default_when_model_has_no_default() {
let model_info = model_with_default_service_tier(/*default_service_tier*/ None);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_mode_enabled*/ true,
&model_info,
),
None
);
}
#[test]
fn get_service_tier_drops_unsupported_configured_tier_when_fast_mode_enabled() {
let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value()));
assert_eq!(
get_service_tier(
Some("unsupported".to_string()),
/*fast_mode_enabled*/ true,
&model_info,
),
None
);
assert_eq!(
get_service_tier(
Some(ServiceTier::Flex.request_value().to_string()),
/*fast_mode_enabled*/ true,
&model_info,
),
None
);
assert_eq!(
get_service_tier(
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()),
/*fast_mode_enabled*/ true,
&model_info,
),
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
);
}
#[test]
fn get_service_tier_ignores_configured_tier_when_fast_mode_disabled() {
let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value()));
assert_eq!(
get_service_tier(
Some(ServiceTier::Fast.request_value().to_string()),
/*fast_mode_enabled*/ false,
&model_info,
),
None
);
assert_eq!(
get_service_tier(
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()),
/*fast_mode_enabled*/ false,
&model_info,
),
None
);
assert_eq!(
get_service_tier(
Some("unsupported".to_string()),
/*fast_mode_enabled*/ false,
&model_info,
),
None
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_mode_enabled*/ false,
&model_info,
),
None
);
}
#[tokio::test]
async fn session_settings_null_service_tier_update_clears_service_tier() {
async fn session_settings_null_service_tier_update_uses_default_service_tier() {
let session_configuration = make_session_configuration_for_tests().await;
let updated = session_configuration
@@ -3485,7 +3537,10 @@ async fn session_settings_null_service_tier_update_clears_service_tier() {
})
.expect("null service tier update should apply");
assert_eq!(updated.service_tier, None);
assert_eq!(
updated.service_tier,
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
);
}
#[tokio::test]
+5 -3
View File
@@ -473,9 +473,11 @@ impl Session {
);
let mut per_turn_config = per_turn_config;
per_turn_config.service_tier = per_turn_config
.service_tier
.filter(|service_tier| model_info.supports_service_tier(service_tier));
per_turn_config.service_tier = get_service_tier(
per_turn_config.service_tier,
per_turn_config.features.enabled(Feature::FastMode),
&model_info,
);
let per_turn_config = Arc::new(per_turn_config);
let turn_metadata_state = Arc::new(TurnMetadataState::new(
session_id.to_string(),
@@ -26,6 +26,7 @@ fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset {
name: "Fast".to_string(),
description: "1.5x speed, increased usage".to_string(),
}],
default_service_tier: None,
is_default: false,
upgrade: None,
show_in_picker,
+2 -3
View File
@@ -28,7 +28,6 @@ use codex_login::CodexAuth;
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::built_in_model_providers;
use codex_models_manager::bundled_models_response;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::protocol::AskForApproval;
@@ -667,13 +666,13 @@ impl TestCodex {
pub async fn submit_turn_with_service_tier(
&self,
prompt: &str,
service_tier: Option<ServiceTier>,
service_tier: Option<&str>,
) -> Result<()> {
self.submit_turn_with_permission_profile_context(
prompt,
AskForApproval::Never,
PermissionProfile::Disabled,
Some(service_tier.map(|service_tier| service_tier.request_value().to_string())),
Some(service_tier.map(str::to_string)),
/*environments*/ None,
)
.await
+2 -2
View File
@@ -269,7 +269,7 @@ async fn websocket_v2_first_turn_uses_updated_fast_tier_after_startup_prewarm()
assert_eq!(warmup["generate"].as_bool(), Some(false));
assert_eq!(warmup.get("service_tier"), None);
test.submit_turn_with_service_tier("hello", Some(ServiceTier::Fast))
test.submit_turn_with_service_tier("hello", Some(ServiceTier::Fast.request_value()))
.await?;
assert_eq!(server.handshakes().len(), 1);
@@ -385,7 +385,7 @@ async fn websocket_v2_next_turn_uses_updated_service_tier() -> Result<()> {
assert_eq!(warmup["generate"].as_bool(), Some(false));
assert_eq!(warmup.get("service_tier"), None);
test.submit_turn_with_service_tier("first", Some(ServiceTier::Fast))
test.submit_turn_with_service_tier("first", Some(ServiceTier::Fast.request_value()))
.await?;
test.submit_turn_with_service_tier("second", /*service_tier*/ None)
.await?;
+84 -3
View File
@@ -4,6 +4,7 @@ use codex_features::Feature;
use codex_login::CodexAuth;
use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ConfigShellToolType;
@@ -113,6 +114,7 @@ fn test_model_info(
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -289,7 +291,7 @@ async fn service_tier_change_is_applied_on_next_http_turn() -> Result<()> {
let test = test_codex().build(&server).await?;
test.submit_turn_with_service_tier("fast turn", Some(ServiceTier::Fast))
test.submit_turn_with_service_tier("fast turn", Some(ServiceTier::Fast.request_value()))
.await?;
test.submit_turn_with_service_tier("standard turn", /*service_tier*/ None)
.await?;
@@ -334,7 +336,7 @@ async fn flex_service_tier_is_applied_to_http_turn() -> Result<()> {
});
let test = builder.build(&server).await?;
test.submit_turn_with_service_tier("flex turn", Some(ServiceTier::Flex))
test.submit_turn_with_service_tier("flex turn", Some(ServiceTier::Flex.request_value()))
.await?;
let request = resp_mock.single_request();
@@ -367,7 +369,85 @@ async fn unsupported_service_tier_is_omitted_from_http_turn() -> Result<()> {
});
let test = builder.build(&server).await?;
test.submit_turn_with_service_tier("fast turn", Some(ServiceTier::Fast))
test.submit_turn_with_service_tier("fast turn", Some(ServiceTier::Fast.request_value()))
.await?;
let request = resp_mock.single_request();
let body = request.body_json();
assert_eq!(body.get("service_tier"), None);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn default_service_tier_override_is_omitted_from_http_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let model_slug = "test-default-tier-model";
let mut model = test_model_info(
model_slug,
model_slug,
"has catalog default service tier",
default_input_modalities(),
);
model.service_tiers = vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "fast".to_string(),
description: "Fast processing.".to_string(),
}];
model.default_service_tier = Some(ServiceTier::Fast.request_value().to_string());
let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await;
let mut builder = test_codex()
.with_model(model_slug)
.with_config(move |config| {
config.model_catalog = Some(ModelsResponse {
models: vec![model],
});
});
let test = builder.build(&server).await?;
test.submit_turn_with_service_tier("default turn", Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE))
.await?;
let request = resp_mock.single_request();
let body = request.body_json();
assert_eq!(body.get("service_tier"), None);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn null_service_tier_override_is_omitted_from_http_turn_with_catalog_default() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let model_slug = "test-null-default-tier-model";
let mut model = test_model_info(
model_slug,
model_slug,
"has catalog default service tier",
default_input_modalities(),
);
model.service_tiers = vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "fast".to_string(),
description: "Fast processing.".to_string(),
}];
model.default_service_tier = Some(ServiceTier::Fast.request_value().to_string());
let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await;
let mut builder = test_codex()
.with_model(model_slug)
.with_config(move |config| {
config.model_catalog = Some(ModelsResponse {
models: vec![model],
});
});
let test = builder.build(&server).await?;
test.submit_turn_with_service_tier("standard turn", /*service_tier*/ None)
.await?;
let request = resp_mock.single_request();
@@ -864,6 +944,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -347,6 +347,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
priority,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
+2
View File
@@ -562,6 +562,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: Some(ModelMessages {
@@ -671,6 +672,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: Some(ModelMessages {
@@ -475,6 +475,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -722,6 +723,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: remote_base.to_string(),
model_messages: None,
@@ -1203,6 +1205,7 @@ fn test_remote_model_with_policy(
priority,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
+1
View File
@@ -1275,6 +1275,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -63,6 +63,7 @@ fn test_model_info(
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers,
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
+1
View File
@@ -1357,6 +1357,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -55,6 +55,7 @@ fn gpt_5_4_cmb_bedrock_model(priority: i32) -> ModelInfo {
name: SPEED_TIER_FAST.to_string(),
description: "Fastest inference with increased plan usage".to_string(),
}],
default_service_tier: None,
availability_nux: None,
upgrade: None,
base_instructions: BASE_INSTRUCTIONS.to_string(),
@@ -96,6 +97,7 @@ fn bedrock_oss_model(slug: &str, display_name: &str, priority: i32) -> ModelInfo
priority,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
availability_nux: None,
upgrade: None,
base_instructions: BASE_INSTRUCTIONS.to_string(),
@@ -77,6 +77,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo {
priority: 99,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
availability_nux: None,
upgrade: None,
base_instructions: BASE_INSTRUCTIONS.to_string(),
+6
View File
@@ -432,6 +432,12 @@ pub enum ServiceTier {
Flex,
}
/// Request/config sentinel for explicit standard routing.
///
/// This is not a catalog service tier id. It means the user intentionally
/// selected no service tier, so model catalog defaults should not apply.
pub const SERVICE_TIER_DEFAULT_REQUEST_VALUE: &str = "default";
impl ServiceTier {
pub const fn request_value(self) -> &'static str {
match self {
+76
View File
@@ -17,6 +17,7 @@ use ts_rs::TS;
use crate::config_types::Personality;
use crate::config_types::ReasoningSummary;
use crate::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use crate::config_types::ServiceTier;
use crate::config_types::Verbosity;
@@ -147,6 +148,9 @@ pub struct ModelPreset {
/// Service tiers this model can run with.
#[serde(default)]
pub service_tiers: Vec<ModelServiceTier>,
/// Catalog default service tier id for this model.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_service_tier: Option<String>,
/// Whether this is the default model for new users.
pub is_default: bool,
/// recommended upgrade model
@@ -270,6 +274,8 @@ pub struct ModelInfo {
pub additional_speed_tiers: Vec<String>,
#[serde(default)]
pub service_tiers: Vec<ModelServiceTier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_service_tier: Option<String>,
pub availability_nux: Option<ModelAvailabilityNux>,
pub upgrade: Option<ModelInfoUpgrade>,
pub base_instructions: String,
@@ -455,6 +461,7 @@ impl From<ModelInfo> for ModelPreset {
supports_personality,
additional_speed_tiers: info.additional_speed_tiers,
service_tiers: info.service_tiers,
default_service_tier: info.default_service_tier,
is_default: false, // default is the highest priority available model
upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade {
id: upgrade.model.clone(),
@@ -493,6 +500,13 @@ impl ModelInfo {
.iter()
.any(|tier| tier.id == service_tier)
}
pub fn service_tier_for_request(&self, service_tier: Option<String>) -> Option<String> {
service_tier.filter(|service_tier| {
service_tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE
&& self.supports_service_tier(service_tier)
})
}
}
impl ModelPreset {
@@ -576,6 +590,7 @@ mod tests {
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
availability_nux: None,
upgrade: None,
base_instructions: "base".to_string(),
@@ -846,6 +861,7 @@ mod tests {
message: "Try Spark.".to_string(),
}),
additional_speed_tiers: vec![SPEED_TIER_FAST.to_string()],
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
service_tiers: Vec::new(),
..test_model(/*spec*/ None)
});
@@ -857,6 +873,10 @@ mod tests {
})
);
assert!(preset.supports_fast_mode());
assert_eq!(
preset.default_service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
}
#[test]
@@ -872,4 +892,60 @@ mod tests {
assert!(preset.supports_fast_mode());
}
#[test]
fn service_tier_for_request_omits_explicit_default_tier() {
let model = ModelInfo {
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
service_tiers: vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "Fast".to_string(),
description: "Priority processing.".to_string(),
}],
..test_model(/*spec*/ None)
};
assert_eq!(
model.service_tier_for_request(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())),
None
);
}
#[test]
fn service_tier_for_request_filters_unsupported_tiers() {
let model = ModelInfo {
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
service_tiers: vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "Fast".to_string(),
description: "Priority processing.".to_string(),
}],
..test_model(/*spec*/ None)
};
assert_eq!(
model.service_tier_for_request(Some(ServiceTier::Fast.request_value().to_string())),
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(
model.service_tier_for_request(Some("unsupported".to_string())),
None
);
assert_eq!(model.service_tier_for_request(/*service_tier*/ None), None);
}
#[test]
fn service_tier_for_request_does_not_apply_catalog_default() {
let model = ModelInfo {
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
service_tiers: vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "Fast".to_string(),
description: "Priority processing.".to_string(),
}],
..test_model(/*spec*/ None)
};
assert_eq!(model.service_tier_for_request(/*service_tier*/ None), None);
}
}
+1
View File
@@ -22,6 +22,7 @@ fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo {
priority: 0,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
availability_nux: None,
upgrade: None,
base_instructions: String::new(),
+2 -3
View File
@@ -767,6 +767,8 @@ impl App {
self.chat_widget.set_model(&model);
self.sync_active_thread_model_setting(app_server, model)
.await;
self.sync_active_thread_service_tier_to_cached_session()
.await;
}
AppEvent::UpdatePersonality(personality) => {
self.on_update_personality(personality);
@@ -1327,9 +1329,6 @@ impl App {
profile,
service_tier.as_deref(),
);
if service_tier.is_none() {
self.config.notices.fast_default_opt_out = Some(true);
}
match crate::config_update::write_config_batch(app_server.request_handle(), edits)
.await
{
@@ -656,7 +656,6 @@ impl App {
pub(super) fn fresh_session_config(&self) -> Config {
let mut config = self.config.clone();
config.service_tier = self.chat_widget.configured_service_tier();
config.notices.fast_default_opt_out = self.chat_widget.fast_default_opt_out();
config
}
pub(super) async fn resume_target_session(
-1
View File
@@ -464,7 +464,6 @@ impl App {
}
fork_config.model_reasoning_effort = self.chat_widget.current_reasoning_effort();
fork_config.service_tier = self.chat_widget.configured_service_tier();
fork_config.notices.fast_default_opt_out = self.chat_widget.fast_default_opt_out();
fork_config.ephemeral = true;
fork_config.developer_instructions = Some(Self::side_developer_instructions(
fork_config.developer_instructions.as_deref(),
+45 -8
View File
@@ -6,6 +6,7 @@
use crate::bottom_pane::FeedbackAudience;
use crate::legacy_core::config::Config;
use crate::permission_compat::legacy_compatible_permission_profile;
use crate::service_tier_resolution;
use crate::session_state::MessageHistoryMetadata;
use crate::session_state::ThreadSessionState;
use crate::status::StatusAccountDisplay;
@@ -107,6 +108,7 @@ use codex_app_server_protocol::UserInput;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
use codex_protocol::approvals::GuardianAssessmentEvent;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
@@ -164,6 +166,8 @@ pub(crate) struct AppServerSession {
remote_cwd_override: Option<PathBuf>,
thread_params_mode: ThreadParamsMode,
thread_settings_update_supported: bool,
default_model: Option<String>,
available_models: Vec<ModelPreset>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -205,6 +209,8 @@ impl AppServerSession {
remote_cwd_override: None,
thread_params_mode,
thread_settings_update_supported: true,
default_model: None,
available_models: Vec::new(),
}
}
@@ -254,6 +260,8 @@ impl AppServerSession {
})
.or_else(|| available_models.first().map(|model| model.model.clone()))
.wrap_err("model/list returned no models for TUI bootstrap")?;
self.default_model = Some(default_model.clone());
self.available_models = available_models.clone();
let (
account_email,
@@ -365,12 +373,13 @@ impl AppServerSession {
session_start_source: Option<ThreadStartSource>,
) -> Result<AppServerStartedThread> {
let request_id = self.next_request_id();
let session_config = self.session_config_with_effective_service_tier(config);
let response: ThreadStartResponse = self
.client
.request_typed(ClientRequest::ThreadStart {
request_id,
params: thread_start_params_from_config(
config,
&session_config,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
session_start_source,
@@ -389,12 +398,13 @@ impl AppServerSession {
thread_id: ThreadId,
) -> Result<AppServerStartedThread> {
let request_id = self.next_request_id();
let session_config = self.session_config_with_effective_service_tier(&config);
let response: ThreadResumeResponse = self
.client
.request_typed(ClientRequest::ThreadResume {
request_id,
params: thread_resume_params_from_config(
config.clone(),
session_config,
thread_id,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
@@ -420,12 +430,13 @@ impl AppServerSession {
thread_id: ThreadId,
) -> Result<AppServerStartedThread> {
let request_id = self.next_request_id();
let session_config = self.session_config_with_effective_service_tier(&config);
let response: ThreadForkResponse = self
.client
.request_typed(ClientRequest::ThreadFork {
request_id,
params: thread_fork_params_from_config(
config.clone(),
session_config,
thread_id,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
@@ -448,6 +459,32 @@ impl AppServerSession {
self.thread_params_mode
}
fn session_config_with_effective_service_tier(&self, config: &Config) -> Config {
let Some(model) = config.model.as_deref().or(self.default_model.as_deref()) else {
return config.clone();
};
let mut session_config = config.clone();
match service_tier_resolution::service_tier_update_for_core(
config,
model,
&self.available_models,
) {
Some(Some(service_tier)) => {
session_config.service_tier = Some(service_tier);
session_config.notices.fast_default_opt_out = None;
}
Some(None) => {
session_config.service_tier = Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string());
session_config.notices.fast_default_opt_out = None;
}
None => {
session_config.service_tier = None;
session_config.notices.fast_default_opt_out = None;
}
}
session_config
}
async fn fork_parent_title_from_app_server(
&mut self,
forked_from_id: Option<&str>,
@@ -1159,6 +1196,7 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset {
description: service_tier.description,
})
.collect(),
default_service_tier: model.default_service_tier,
is_default: model.is_default,
upgrade,
show_in_picker: !model.hidden,
@@ -1219,11 +1257,10 @@ fn config_request_overrides_from_config(
}
fn service_tier_override_from_config(config: &Config) -> Option<Option<String>> {
config
.service_tier
.clone()
.map(Some)
.or_else(|| (config.notices.fast_default_opt_out == Some(true)).then_some(None))
config.service_tier.clone().map(Some).or_else(|| {
(config.notices.fast_default_opt_out == Some(true))
.then(|| Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()))
})
}
fn sandbox_mode_from_permission_profile(
+5 -1
View File
@@ -64,7 +64,11 @@ impl ChatWidget {
let active_cell = Some(Self::placeholder_session_header_cell(&config));
let current_cwd = Some(config.cwd.to_path_buf());
let effective_service_tier = config.service_tier.clone();
let effective_service_tier = crate::service_tier_resolution::effective_service_tier(
&config,
&header_model,
&model_catalog.try_list_models().unwrap_or_default(),
);
let current_terminal_info = terminal_info();
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok();
let default_keymap = RuntimeKeymap::defaults();
@@ -331,11 +331,7 @@ impl ChatWidget {
.personality
.filter(|_| self.config.features.enabled(Feature::Personality))
.filter(|_| self.current_model_supports_personality());
let service_tier = match self.config.service_tier.clone() {
Some(service_tier) => Some(Some(service_tier)),
None if self.config.notices.fast_default_opt_out == Some(true) => Some(None),
None => None,
};
let service_tier = self.service_tier_update_for_core();
let active_permission_profile = self.config.permissions.active_permission_profile();
let op = AppCommand::user_turn(
items,
+20 -9
View File
@@ -4,14 +4,16 @@ use super::ChatWidget;
use crate::app_command::AppCommand;
use crate::app_event::AppEvent;
use crate::bottom_pane::slash_commands::ServiceTierCommand;
use crate::service_tier_resolution;
use codex_features::Feature;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::SPEED_TIER_FAST;
impl ChatWidget {
pub(crate) fn set_service_tier(&mut self, service_tier: Option<String>) {
self.config.service_tier = service_tier.clone();
self.effective_service_tier = service_tier;
self.config.service_tier = service_tier;
self.refresh_effective_service_tier();
self.refresh_model_dependent_surfaces();
}
@@ -23,8 +25,12 @@ impl ChatWidget {
self.config.service_tier.clone()
}
pub(crate) fn fast_default_opt_out(&self) -> Option<bool> {
self.config.notices.fast_default_opt_out
pub(crate) fn service_tier_update_for_core(&self) -> Option<Option<String>> {
service_tier_resolution::service_tier_update_for_core(
&self.config,
self.current_model(),
&self.model_catalog.try_list_models().unwrap_or_default(),
)
}
pub(crate) fn should_show_fast_status(&self, model: &str, service_tier: Option<&str>) -> bool {
@@ -50,7 +56,7 @@ impl ChatWidget {
return;
};
let next_tier = if self.current_service_tier() == Some(fast_tier.id.as_str()) {
None
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
} else {
Some(fast_tier.id)
};
@@ -59,7 +65,7 @@ impl ChatWidget {
pub(crate) fn toggle_service_tier_from_ui(&mut self, command: ServiceTierCommand) {
let next_tier = if self.current_service_tier() == Some(command.id.as_str()) {
None
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
} else {
Some(command.id)
};
@@ -98,9 +104,6 @@ impl ChatWidget {
}
fn set_service_tier_selection(&mut self, service_tier: Option<String>) {
if service_tier.is_none() {
self.config.notices.fast_default_opt_out = Some(true);
}
self.set_service_tier(service_tier.clone());
self.app_event_tx
.send(AppEvent::CodexOp(AppCommand::override_turn_context(
@@ -144,4 +147,12 @@ impl ChatWidget {
.into_iter()
.find(|tier| tier.name.eq_ignore_ascii_case(SPEED_TIER_FAST))
}
pub(super) fn refresh_effective_service_tier(&mut self) {
self.effective_service_tier = service_tier_resolution::effective_service_tier(
&self.config,
self.current_model(),
&self.model_catalog.try_list_models().unwrap_or_default(),
);
}
}
+3
View File
@@ -66,6 +66,7 @@ impl ChatWidget {
}
}
if feature == Feature::FastMode {
self.refresh_effective_service_tier();
self.sync_service_tier_commands();
}
if feature == Feature::Personality {
@@ -238,6 +239,7 @@ impl ChatWidget {
{
mask.model = Some(model.to_string());
}
self.refresh_effective_service_tier();
self.refresh_model_dependent_surfaces();
}
@@ -519,6 +521,7 @@ impl ChatWidget {
settings.collaboration_mode.settings.model = settings.model;
settings.collaboration_mode.settings.reasoning_effort = settings.effort;
self.set_effective_collaboration_mode(settings.collaboration_mode);
self.refresh_effective_service_tier();
self.refresh_status_surfaces();
self.sync_service_tier_commands();
self.sync_personality_command_enabled();
+1
View File
@@ -144,6 +144,7 @@ pub(super) use codex_protocol::approvals::GuardianUserAuthorization;
pub(super) use codex_protocol::config_types::CollaborationMode;
pub(super) use codex_protocol::config_types::ModeKind;
pub(super) use codex_protocol::config_types::Personality;
pub(super) use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
pub(super) use codex_protocol::config_types::ServiceTier;
pub(super) use codex_protocol::models::ActivePermissionProfile;
pub(super) use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
+6 -6
View File
@@ -246,15 +246,14 @@ pub(crate) fn set_chatgpt_auth(chat: &mut ChatWidget) {
}
fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> ModelInfo {
let service_tiers = if supports_fast_mode {
vec![json!({
let mut service_tiers = Vec::new();
if supports_fast_mode {
service_tiers.push(json!({
"id": ServiceTier::Fast.request_value(),
"name": "fast",
"description": "Fastest inference with increased plan usage"
})]
} else {
Vec::new()
};
}));
}
serde_json::from_value(json!({
"slug": slug,
"display_name": slug,
@@ -267,6 +266,7 @@ fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> Model
"priority": priority,
"additional_speed_tiers": [],
"service_tiers": service_tiers,
"default_service_tier": null,
"availability_nux": null,
"upgrade": null,
"base_instructions": "base instructions",
@@ -2279,6 +2279,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() {
supports_personality: false,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
is_default: false,
upgrade: None,
show_in_picker,
@@ -2500,6 +2501,7 @@ async fn single_reasoning_option_skips_selection() {
supports_personality: false,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
is_default: false,
upgrade: None,
show_in_picker: true,
@@ -91,8 +91,19 @@ fn next_add_to_history_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEv
async fn service_tier_commands_lowercase_catalog_names() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
let mut preset = get_available_model(&chat, "gpt-5.4");
let expected_description = preset.service_tiers[0].description.clone();
preset.service_tiers[0].name = "Fast".to_string();
let expected_description = preset
.service_tiers
.iter()
.find(|tier| tier.id == ServiceTier::Fast.request_value())
.expect("fast tier")
.description
.clone();
preset
.service_tiers
.iter_mut()
.find(|tier| tier.id == ServiceTier::Fast.request_value())
.expect("fast tier")
.name = "Fast".to_string();
chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset]));
assert_eq!(
@@ -2135,6 +2146,47 @@ async fn user_turn_carries_service_tier_after_fast_toggle() {
}
}
#[tokio::test]
async fn model_switch_recomputes_catalog_default_service_tier() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.thread_id = Some(ThreadId::new());
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
let mut models = chat.model_catalog.try_list_models().expect("test catalog");
let default_model = models
.iter_mut()
.find(|model| model.model == "gpt-5.4")
.expect("gpt-5.4 test model");
default_model.default_service_tier = Some(ServiceTier::Fast.request_value().to_string());
chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(models));
chat.refresh_effective_service_tier();
assert_eq!(chat.current_service_tier(), None);
chat.set_model("gpt-5.4");
assert_eq!(
chat.current_service_tier(),
Some(ServiceTier::Fast.request_value())
);
chat.set_model("gpt-5.3-codex");
assert_eq!(chat.current_service_tier(), None);
chat.bottom_pane
.set_composer_text("hello".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match next_submit_op(&mut op_rx) {
Op::UserTurn {
service_tier: Some(Some(service_tier)),
..
} if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE => {}
other => panic!("expected Op::UserTurn with default service tier override, got {other:?}"),
}
}
#[tokio::test]
async fn queued_fast_slash_applies_before_next_queued_message() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
@@ -2194,18 +2246,20 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() {
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(Op::OverrideTurnContext {
service_tier: Some(None),
service_tier: Some(Some(service_tier)),
..
})
}) if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE
)),
"expected fast-mode off override app event; events: {events:?}"
"expected fast-mode off default service tier app event; events: {events:?}"
);
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::PersistServiceTierSelection { service_tier: None }
AppEvent::PersistServiceTierSelection {
service_tier: Some(service_tier)
} if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE
)),
"expected fast-mode opt-out persistence app event; events: {events:?}"
"expected default service tier persistence app event; events: {events:?}"
);
chat.bottom_pane
@@ -2214,10 +2268,10 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() {
match next_submit_op(&mut op_rx) {
Op::UserTurn {
service_tier: Some(None),
service_tier: Some(Some(service_tier)),
..
} => {}
other => panic!("expected Op::UserTurn with standard service tier override, got {other:?}"),
} if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE => {}
other => panic!("expected Op::UserTurn with default service tier override, got {other:?}"),
}
}
+7 -10
View File
@@ -13,6 +13,7 @@ use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SkillsConfigWriteParams;
use codex_app_server_protocol::SkillsConfigWriteResponse;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_utils_absolute_path::AbsolutePathBuf;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
@@ -75,26 +76,22 @@ pub(crate) fn build_service_tier_selection_edits(
let service_tier_edit = service_tier.map_or_else(
|| clear_config_value(profile_scoped_key_path(profile, "service_tier")),
|service_tier| {
let config_value =
let config_value = if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE {
SERVICE_TIER_DEFAULT_REQUEST_VALUE
} else {
match codex_protocol::config_types::ServiceTier::from_request_value(service_tier) {
Some(codex_protocol::config_types::ServiceTier::Fast) => "fast",
Some(codex_protocol::config_types::ServiceTier::Flex) => "flex",
None => service_tier,
};
}
};
replace_config_value(
profile_scoped_key_path(profile, "service_tier"),
serde_json::json!(config_value),
)
},
);
let mut edits = vec![service_tier_edit];
if service_tier.is_none() {
edits.push(replace_config_value(
"notice.fast_default_opt_out",
serde_json::json!(true),
));
}
edits
vec![service_tier_edit]
}
pub(crate) async fn write_config_batch(
+1
View File
@@ -164,6 +164,7 @@ mod render;
mod resize_reflow_cap;
mod resume_picker;
mod selection_list;
mod service_tier_resolution;
mod session_log;
mod session_resume;
mod session_state;
@@ -0,0 +1,64 @@
use crate::legacy_core::config::Config;
use codex_features::Feature;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::openai_models::ModelPreset;
pub(crate) fn configured_service_tier(config: &Config) -> Option<String> {
config.service_tier.clone().or_else(|| {
(config.notices.fast_default_opt_out == Some(true))
.then(|| SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
})
}
pub(crate) fn effective_service_tier(
config: &Config,
model: &str,
models: &[ModelPreset],
) -> Option<String> {
if !config.features.enabled(Feature::FastMode) {
return None;
}
let configured = configured_service_tier(config);
let Some(preset) = models.iter().find(|preset| preset.model == model) else {
return configured;
};
match configured.as_deref() {
Some(service_tier) if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE => configured,
Some(service_tier) if model_supports_service_tier(preset, service_tier) => configured,
Some(_) => None,
None => preset
.default_service_tier
.clone()
.filter(|service_tier| model_supports_service_tier(preset, service_tier)),
}
}
pub(crate) fn service_tier_update_for_core(
config: &Config,
model: &str,
models: &[ModelPreset],
) -> Option<Option<String>> {
if !config.features.enabled(Feature::FastMode) {
return None;
}
let effective = effective_service_tier(config, model, models);
if let Some(service_tier) = effective {
return Some(Some(service_tier));
}
if !models.iter().any(|preset| preset.model == model) {
return None;
}
Some(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()))
}
pub(crate) fn model_supports_service_tier(model: &ModelPreset, service_tier: &str) -> bool {
model
.service_tiers
.iter()
.any(|tier| tier.id == service_tier)
}