Honor model catalog default service tiers

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
shijie-openai
2026-05-18 13:57:04 -07:00
co-authored by Codex
parent 1752f374a8
commit 40a66ab94a
43 changed files with 569 additions and 147 deletions
@@ -11144,6 +11144,14 @@
"defaultReasoningEffort": {
"$ref": "#/definitions/v2/ReasoningEffort"
},
"defaultServiceTier": {
"default": null,
"description": "Catalog default service tier id for this model, when one is configured.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
@@ -7693,6 +7693,14 @@
"defaultReasoningEffort": {
"$ref": "#/definitions/ReasoningEffort"
},
"defaultServiceTier": {
"default": null,
"description": "Catalog default service tier id for this model, when one is configured.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
@@ -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
@@ -186,7 +186,7 @@ Example with notification opt-out:
- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`.
- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
+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,
}
}
@@ -28,6 +28,7 @@ use codex_core::config::set_project_trust_level;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
@@ -484,7 +485,7 @@ model_reasoning_effort = "high"
}
#[tokio::test]
async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> {
async fn thread_start_drops_unsupported_service_tier_id() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
@@ -508,7 +509,39 @@ async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> {
.await??;
let ThreadStartResponse { service_tier, .. } = to_response::<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(ServiceTier::Default.request_value().to_string())),
..Default::default()
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await??;
let ThreadStartResponse { service_tier, .. } = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(
service_tier,
Some(ServiceTier::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
@@ -354,7 +354,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
@@ -667,7 +667,7 @@
"$ref": "#/definitions/SandboxMode"
},
"service_tier": {
"description": "Optional explicit service tier request id for new turns (for example `priority` or `flex`; legacy `fast` also works).",
"description": "Optional explicit service tier request id for new turns (for example `default`, `priority`, or `flex`; legacy `fast` also works).",
"type": "string"
},
"tools": {
@@ -4726,7 +4726,7 @@
"description": "Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`."
},
"service_tier": {
"description": "Optional explicit service tier request id for new turns (for example `priority` or `flex`; legacy `fast` also works).",
"description": "Optional explicit service tier request id for new turns (for example `default`, `priority`, or `flex`; legacy `fast` also works).",
"type": "string"
},
"shell_environment_policy": {
+1 -2
View File
@@ -741,8 +741,7 @@ impl ModelClient {
prompt.output_schema_strict,
);
let prompt_cache_key = Some(self.state.thread_id.to_string());
let service_tier =
service_tier.filter(|service_tier| model_info.supports_service_tier(service_tier));
let service_tier = model_info.service_tier_for_request(service_tier);
let request = ResponsesApiRequest {
model: model_info.slug.clone(),
instructions: instructions.clone(),
+28 -3
View File
@@ -7942,7 +7942,7 @@ alpha = "one\ntwo"
}
#[tokio::test]
async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std::io::Result<()> {
async fn explicit_null_service_tier_override_maps_to_default_service_tier() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
@@ -7956,8 +7956,33 @@ async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std:
)
.await?;
assert_eq!(config.service_tier, None);
assert_eq!(config.notices.fast_default_opt_out, Some(true));
assert_eq!(
config.service_tier,
Some(ServiceTier::Default.request_value().to_string())
);
assert_eq!(config.notices.fast_default_opt_out, None);
Ok(())
}
#[tokio::test]
async fn default_service_tier_override_uses_default_request_value() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
service_tier: Some(Some("default".to_string())),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(
config.service_tier,
Some(ServiceTier::Default.request_value().to_string())
);
Ok(())
}
+1 -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.
@@ -553,6 +551,7 @@ impl ConfigDocument {
&["service_tier"],
service_tier.as_ref().map(|service_tier| {
let config_value = match ServiceTier::from_request_value(service_tier) {
Some(ServiceTier::Default) => "default",
Some(ServiceTier::Fast) => "fast",
Some(ServiceTier::Flex) => "flex",
None => service_tier.as_str(),
@@ -574,11 +573,6 @@ impl ConfigDocument {
&[NOTICE_TABLE_KEY, "hide_world_writable_warning"],
value(*acknowledged),
)),
ConfigEdit::SetNoticeFastDefaultOptOut(opted_out) => Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, "fast_default_opt_out"],
value(*opted_out),
)),
ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"],
@@ -1182,12 +1176,6 @@ impl ConfigEditsBuilder {
self
}
pub fn set_fast_default_opt_out(mut self, opted_out: bool) -> Self {
self.edits
.push(ConfigEdit::SetNoticeFastDefaultOptOut(opted_out));
self
}
pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self {
self.edits
.push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged));
+14
View File
@@ -34,6 +34,20 @@ model_reasoning_effort = "high"
assert_eq!(contents, expected);
}
#[test]
fn set_service_tier_saves_default_as_default() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.set_service_tier(Some(ServiceTier::Default.request_value().to_string()))
.apply_blocking()
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
assert_eq!(contents, "service_tier = \"default\"\n");
}
#[test]
fn set_service_tier_saves_priority_as_fast() {
let tmp = tempdir().expect("tmpdir");
+4 -7
View File
@@ -515,6 +515,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.
@@ -3091,19 +3092,15 @@ impl Config {
let forced_login_method = cfg.forced_login_method;
let model = model.or(config_profile.model).or(cfg.model);
let mut notices = cfg.notice.unwrap_or_default();
let notices = cfg.notice.unwrap_or_default();
let service_tier = match service_tier_override {
Some(Some(service_tier)) => Some(service_tier),
Some(None) => {
// Preserve explicit standard/clear intent after the nested override
// collapses into `Config.service_tier = None`.
notices.fast_default_opt_out = Some(true);
None
}
Some(None) => Some(ServiceTier::Default.request_value().to_string()),
None => config_profile.service_tier.or(cfg.service_tier),
};
let service_tier = service_tier.and_then(|service_tier| {
match ServiceTier::from_request_value(&service_tier) {
Some(ServiceTier::Default) => Some(ServiceTier::Default.request_value().to_string()),
Some(ServiceTier::Fast) => features
.enabled(Feature::FastMode)
.then(|| ServiceTier::Fast.request_value().to_string()),
+9 -19
View File
@@ -74,13 +74,13 @@ use codex_otel::current_span_trace_id;
use codex_otel::current_span_w3c_trace_context;
use codex_otel::set_parent_from_w3c_trace_context;
use codex_protocol::ThreadId;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyRuleAction;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Settings;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolResponse;
@@ -320,7 +320,6 @@ use codex_otel::TelemetryAuthMode;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
@@ -598,14 +597,11 @@ impl Codex {
developer_instructions: None,
},
};
let account_plan_type = auth_manager
.auth_cached()
.and_then(|auth| auth.account_plan_type());
let service_tier = get_service_tier(
config.service_tier.clone(),
config.notices.fast_default_opt_out.unwrap_or(false),
account_plan_type,
config.features.enabled(Feature::FastMode),
&model_info,
);
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
@@ -808,16 +804,16 @@ impl Codex {
fn get_service_tier(
configured_service_tier: Option<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())
if fast_default_opt_out && configured_service_tier.is_none() {
return Some(ServiceTier::Default.request_value().to_string());
}
model_info.effective_service_tier(configured_service_tier)
}
fn session_permission_profile_state_from_config(
@@ -826,12 +822,6 @@ fn session_permission_profile_state_from_config(
Ok(config.permissions.permission_profile_state().clone())
}
fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool {
plan_type == AccountPlanType::Enterprise
|| plan_type.is_business_like()
|| plan_type.is_team_like()
}
#[cfg(test)]
pub(crate) fn completed_session_loop_termination() -> SessionLoopTermination {
futures::future::ready(()).boxed().shared()
+9 -6
View File
@@ -219,12 +219,15 @@ impl SessionConfiguration {
if let Some(service_tier) = updates.service_tier.clone() {
// TODO(aibrahim): Remove once v2 clients no longer send the legacy
// "fast" service tier value.
next_configuration.service_tier = service_tier.map(|service_tier| {
ServiceTier::from_request_value(&service_tier)
.map_or(service_tier, |service_tier| {
service_tier.request_value().to_string()
})
});
next_configuration.service_tier = match service_tier {
Some(service_tier) => Some(
ServiceTier::from_request_value(&service_tier)
.map_or(service_tier, |service_tier| {
service_tier.request_value().to_string()
}),
),
None => Some(ServiceTier::Default.request_value().to_string()),
};
}
if let Some(personality) = updates.personality {
next_configuration.personality = Some(personality);
+128 -48
View File
@@ -32,7 +32,6 @@ use codex_models_manager::test_support::get_model_offline_for_tests;
use codex_protocol::AgentPath;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::exec_output::ExecToolCallOutput;
@@ -43,6 +42,7 @@ use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::SandboxEnforcement;
use codex_protocol::openai_models::ModelServiceTier;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -3359,92 +3359,169 @@ fn session_telemetry(
)
}
fn model_with_default_service_tier(default_service_tier: Option<&str>) -> ModelInfo {
let mut model_info = model_info::model_info_from_slug("gpt-5.4");
model_info.service_tiers = vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "Fast".to_string(),
description: "Priority processing.".to_string(),
}];
model_info.default_service_tier = default_service_tier.map(str::to_string);
model_info
}
#[test]
fn get_service_tier_defaults_enterprise_accounts_to_fast() {
fn get_service_tier_uses_model_default_when_absent_and_fast_mode_enabled() {
let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value()));
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::Enterprise),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::EnterpriseCbpUsageBased),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::Business),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::Team),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::SelfServeBusinessUsageBased),
/*fast_mode_enabled*/ true,
&model_info,
),
Some(ServiceTier::Fast.request_value().to_string())
);
}
#[test]
fn get_service_tier_respects_fast_default_opt_out() {
fn get_service_tier_does_not_use_model_default_when_fast_mode_disabled() {
let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value()));
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
/*fast_mode_enabled*/ false,
&model_info,
),
None
);
}
#[test]
fn get_service_tier_respects_fast_default_opt_out_when_fast_mode_enabled() {
let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value()));
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ true,
Some(AccountPlanType::Enterprise),
/*fast_mode_enabled*/ true,
&model_info,
),
Some(ServiceTier::Default.request_value().to_string())
);
}
#[test]
fn get_service_tier_stale_fast_default_opt_out_does_not_override_explicit_tier() {
let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value()));
assert_eq!(
get_service_tier(
Some(ServiceTier::Fast.request_value().to_string()),
/*fast_default_opt_out*/ true,
/*fast_mode_enabled*/ true,
&model_info,
),
Some(ServiceTier::Fast.request_value().to_string())
);
}
#[test]
fn get_service_tier_does_not_default_when_model_has_no_default() {
let model_info = model_with_default_service_tier(/*default_service_tier*/ None);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
/*fast_mode_enabled*/ true,
&model_info,
),
None
);
}
#[test]
fn get_service_tier_does_not_default_non_enterprise_or_disabled_fast_mode() {
fn get_service_tier_drops_unsupported_configured_tier_when_fast_mode_enabled() {
let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value()));
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
Some("unsupported".to_string()),
/*fast_default_opt_out*/ false,
Some(AccountPlanType::Pro),
/*fast_mode_enabled*/ true,
&model_info,
),
None
);
assert_eq!(
get_service_tier(
Some(ServiceTier::Flex.request_value().to_string()),
/*fast_default_opt_out*/ false,
/*fast_mode_enabled*/ true,
&model_info,
),
None
);
assert_eq!(
get_service_tier(
Some(ServiceTier::Default.request_value().to_string()),
/*fast_default_opt_out*/ false,
/*fast_mode_enabled*/ true,
&model_info,
),
Some(ServiceTier::Default.request_value().to_string())
);
}
#[test]
fn get_service_tier_ignores_configured_tier_when_fast_mode_disabled() {
let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value()));
assert_eq!(
get_service_tier(
Some(ServiceTier::Fast.request_value().to_string()),
/*fast_default_opt_out*/ false,
/*fast_mode_enabled*/ false,
&model_info,
),
None
);
assert_eq!(
get_service_tier(
Some(ServiceTier::Default.request_value().to_string()),
/*fast_default_opt_out*/ false,
/*fast_mode_enabled*/ false,
&model_info,
),
None
);
assert_eq!(
get_service_tier(
Some("unsupported".to_string()),
/*fast_default_opt_out*/ false,
/*fast_mode_enabled*/ false,
&model_info,
),
None
);
assert_eq!(
get_service_tier(
/*configured_service_tier*/ None,
/*fast_default_opt_out*/ false,
Some(AccountPlanType::Enterprise),
/*fast_default_opt_out*/ true,
/*fast_mode_enabled*/ false,
&model_info,
),
None
);
}
#[tokio::test]
async fn session_settings_null_service_tier_update_clears_service_tier() {
async fn session_settings_null_service_tier_update_uses_default_service_tier() {
let session_configuration = make_session_configuration_for_tests().await;
let updated = session_configuration
@@ -3454,7 +3531,10 @@ async fn session_settings_null_service_tier_update_clears_service_tier() {
})
.expect("null service tier update should apply");
assert_eq!(updated.service_tier, None);
assert_eq!(
updated.service_tier,
Some(ServiceTier::Default.request_value().to_string())
);
}
#[tokio::test]
+6 -3
View File
@@ -576,9 +576,12 @@ impl Session {
));
let mut per_turn_config = per_turn_config;
per_turn_config.service_tier = per_turn_config
.service_tier
.filter(|service_tier| model_info.supports_service_tier(service_tier));
per_turn_config.service_tier = get_service_tier(
per_turn_config.service_tier,
/*fast_default_opt_out*/ false,
per_turn_config.features.enabled(Feature::FastMode),
&model_info,
);
let per_turn_config = Arc::new(per_turn_config);
let turn_metadata_state = Arc::new(TurnMetadataState::new(
session_id.to_string(),
@@ -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,
@@ -108,6 +108,7 @@ fn test_model_info(
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -387,6 +388,84 @@ async fn unsupported_service_tier_is_omitted_from_http_turn() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn default_service_tier_override_is_omitted_from_http_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let model_slug = "test-default-tier-model";
let mut model = test_model_info(
model_slug,
model_slug,
"has catalog default service tier",
default_input_modalities(),
);
model.service_tiers = vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "fast".to_string(),
description: "Fast processing.".to_string(),
}];
model.default_service_tier = Some(ServiceTier::Fast.request_value().to_string());
let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await;
let mut builder = test_codex()
.with_model(model_slug)
.with_config(move |config| {
config.model_catalog = Some(ModelsResponse {
models: vec![model],
});
});
let test = builder.build(&server).await?;
test.submit_turn_with_service_tier("default turn", Some(ServiceTier::Default))
.await?;
let request = resp_mock.single_request();
let body = request.body_json();
assert_eq!(body.get("service_tier"), None);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn null_service_tier_override_is_omitted_from_http_turn_with_catalog_default() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let model_slug = "test-null-default-tier-model";
let mut model = test_model_info(
model_slug,
model_slug,
"has catalog default service tier",
default_input_modalities(),
);
model.service_tiers = vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "fast".to_string(),
description: "Fast processing.".to_string(),
}];
model.default_service_tier = Some(ServiceTier::Fast.request_value().to_string());
let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await;
let mut builder = test_codex()
.with_model(model_slug)
.with_config(move |config| {
config.model_catalog = Some(ModelsResponse {
models: vec![model],
});
});
let test = builder.build(&server).await?;
test.submit_turn_with_service_tier("standard turn", /*service_tier*/ None)
.await?;
let request = resp_mock.single_request();
let body = request.body_json();
assert_eq!(body.get("service_tier"), None);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -874,6 +953,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -342,6 +342,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
priority,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
+2
View File
@@ -580,6 +580,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: Some(ModelMessages {
@@ -689,6 +690,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: Some(ModelMessages {
@@ -534,6 +534,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -791,6 +792,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: remote_base.to_string(),
model_messages: None,
@@ -1282,6 +1284,7 @@ fn test_remote_model_with_policy(
priority,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
+1
View File
@@ -1270,6 +1270,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -70,6 +70,7 @@ fn test_model_info(
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers,
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
+1
View File
@@ -1352,6 +1352,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -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(),
+3
View File
@@ -413,6 +413,7 @@ impl From<WebSearchToolConfig> for WebSearchConfig {
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ServiceTier {
Default,
Fast,
Flex,
}
@@ -420,6 +421,7 @@ pub enum ServiceTier {
impl ServiceTier {
pub const fn request_value(self) -> &'static str {
match self {
Self::Default => "default",
Self::Fast => "priority",
Self::Flex => "flex",
}
@@ -427,6 +429,7 @@ impl ServiceTier {
pub fn from_request_value(value: &str) -> Option<Self> {
match value {
"default" => Some(Self::Default),
"fast" | "priority" => Some(Self::Fast),
"flex" => Some(Self::Flex),
_ => None,
+152
View File
@@ -147,6 +147,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 +273,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 +460,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 +499,32 @@ impl ModelInfo {
.iter()
.any(|tier| tier.id == service_tier)
}
pub fn effective_service_tier(
&self,
configured_service_tier: Option<String>,
) -> Option<String> {
match configured_service_tier.as_deref() {
Some(service_tier) if service_tier == ServiceTier::Default.request_value() => {
configured_service_tier
}
Some(service_tier) if self.supports_service_tier(service_tier) => {
configured_service_tier
}
Some(_) => None,
None => self
.default_service_tier
.clone()
.filter(|service_tier| self.supports_service_tier(service_tier)),
}
}
pub fn service_tier_for_request(&self, service_tier: Option<String>) -> Option<String> {
service_tier.filter(|service_tier| {
service_tier != ServiceTier::Default.request_value()
&& self.supports_service_tier(service_tier)
})
}
}
impl ModelPreset {
@@ -576,6 +608,7 @@ mod tests {
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
availability_nux: None,
upgrade: None,
base_instructions: "base".to_string(),
@@ -846,6 +879,7 @@ mod tests {
message: "Try Spark.".to_string(),
}),
additional_speed_tiers: vec![SPEED_TIER_FAST.to_string()],
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
service_tiers: Vec::new(),
..test_model(/*spec*/ None)
});
@@ -857,6 +891,10 @@ mod tests {
})
);
assert!(preset.supports_fast_mode());
assert_eq!(
preset.default_service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
}
#[test]
@@ -872,4 +910,118 @@ mod tests {
assert!(preset.supports_fast_mode());
}
#[test]
fn service_tier_resolution_uses_catalog_default_when_absent() {
let model = ModelInfo {
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
service_tiers: vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "Fast".to_string(),
description: "Priority processing.".to_string(),
}],
..test_model(/*spec*/ None)
};
assert_eq!(
model.effective_service_tier(/*configured_service_tier*/ None),
Some(ServiceTier::Fast.request_value().to_string())
);
}
#[test]
fn service_tier_resolution_uses_supported_explicit_configured_tier() {
let model = ModelInfo {
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
service_tiers: vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "Fast".to_string(),
description: "Priority processing.".to_string(),
}],
..test_model(/*spec*/ None)
};
assert_eq!(
model.effective_service_tier(Some(ServiceTier::Fast.request_value().to_string())),
Some(ServiceTier::Fast.request_value().to_string())
);
}
#[test]
fn service_tier_resolution_drops_unsupported_configured_tier() {
let model = ModelInfo {
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
service_tiers: vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "Fast".to_string(),
description: "Priority processing.".to_string(),
}],
..test_model(/*spec*/ None)
};
assert_eq!(
model.effective_service_tier(Some("unsupported".to_string())),
None
);
}
#[test]
fn service_tier_resolution_preserves_explicit_default_tier() {
let model = ModelInfo {
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
service_tiers: vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "Fast".to_string(),
description: "Priority processing.".to_string(),
}],
..test_model(/*spec*/ None)
};
assert_eq!(
model.effective_service_tier(Some(ServiceTier::Default.request_value().to_string())),
Some(ServiceTier::Default.request_value().to_string())
);
assert_eq!(
model.service_tier_for_request(Some(ServiceTier::Default.request_value().to_string())),
None
);
}
#[test]
fn service_tier_for_request_filters_unsupported_tiers() {
let model = ModelInfo {
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
service_tiers: vec![ModelServiceTier {
id: ServiceTier::Fast.request_value().to_string(),
name: "Fast".to_string(),
description: "Priority processing.".to_string(),
}],
..test_model(/*spec*/ None)
};
assert_eq!(
model.service_tier_for_request(Some(ServiceTier::Fast.request_value().to_string())),
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(
model.service_tier_for_request(Some("unsupported".to_string())),
None
);
assert_eq!(model.service_tier_for_request(/*service_tier*/ None), None);
}
#[test]
fn service_tier_resolution_ignores_unavailable_catalog_default() {
let model = ModelInfo {
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
service_tiers: Vec::new(),
..test_model(/*spec*/ None)
};
assert_eq!(
model.effective_service_tier(/*configured_service_tier*/ None),
None
);
assert_eq!(model.service_tier_for_request(/*service_tier*/ None), None);
}
}
-3
View File
@@ -1315,9 +1315,6 @@ impl App {
profile,
service_tier.as_deref(),
);
if service_tier.is_none() {
self.config.notices.fast_default_opt_out = Some(true);
}
match crate::config_update::write_config_batch(app_server.request_handle(), edits)
.await
{
+6 -5
View File
@@ -106,6 +106,7 @@ use codex_app_server_protocol::UserInput;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
use codex_protocol::approvals::GuardianAssessmentEvent;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
@@ -1090,6 +1091,7 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset {
description: service_tier.description,
})
.collect(),
default_service_tier: model.default_service_tier,
is_default: model.is_default,
upgrade,
show_in_picker: !model.hidden,
@@ -1150,11 +1152,10 @@ fn config_request_overrides_from_config(
}
fn service_tier_override_from_config(config: &Config) -> Option<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(ServiceTier::Default.request_value().to_string()))
})
}
fn sandbox_mode_from_permission_profile(
@@ -1,6 +1,7 @@
//! User-message and shell-prompt submission behavior for `ChatWidget`.
use super::*;
use codex_protocol::config_types::ServiceTier;
impl ChatWidget {
pub(super) fn user_message_from_submission(
@@ -333,7 +334,9 @@ impl ChatWidget {
.filter(|_| self.current_model_supports_personality());
let service_tier = match self.config.service_tier.clone() {
Some(service_tier) => Some(Some(service_tier)),
None if self.config.notices.fast_default_opt_out == Some(true) => Some(None),
None if self.config.notices.fast_default_opt_out == Some(true) => {
Some(Some(ServiceTier::Default.request_value().to_string()))
}
None => None,
};
let active_permission_profile = self.config.permissions.active_permission_profile();
+2 -5
View File
@@ -50,7 +50,7 @@ impl ChatWidget {
return;
};
let next_tier = if self.current_service_tier() == Some(fast_tier.id.as_str()) {
None
Some(ServiceTier::Default.request_value().to_string())
} else {
Some(fast_tier.id)
};
@@ -59,7 +59,7 @@ impl ChatWidget {
pub(crate) fn toggle_service_tier_from_ui(&mut self, command: ServiceTierCommand) {
let next_tier = if self.current_service_tier() == Some(command.id.as_str()) {
None
Some(ServiceTier::Default.request_value().to_string())
} else {
Some(command.id)
};
@@ -98,9 +98,6 @@ impl ChatWidget {
}
fn set_service_tier_selection(&mut self, service_tier: Option<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(
+6 -6
View File
@@ -247,15 +247,14 @@ pub(crate) fn set_chatgpt_auth(chat: &mut ChatWidget) {
}
fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> ModelInfo {
let service_tiers = if supports_fast_mode {
vec![json!({
let mut service_tiers = Vec::new();
if supports_fast_mode {
service_tiers.push(json!({
"id": ServiceTier::Fast.request_value(),
"name": "fast",
"description": "Fastest inference with increased plan usage"
})]
} else {
Vec::new()
};
}));
}
serde_json::from_value(json!({
"slug": slug,
"display_name": slug,
@@ -268,6 +267,7 @@ fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> Model
"priority": priority,
"additional_speed_tiers": [],
"service_tiers": service_tiers,
"default_service_tier": null,
"availability_nux": null,
"upgrade": null,
"base_instructions": "base instructions",
@@ -2472,6 +2472,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() {
supports_personality: false,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
is_default: false,
upgrade: None,
show_in_picker,
@@ -2693,6 +2694,7 @@ async fn single_reasoning_option_skips_selection() {
supports_personality: false,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
is_default: false,
upgrade: None,
show_in_picker: true,
@@ -79,8 +79,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!(
@@ -2144,18 +2155,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 == ServiceTier::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 == ServiceTier::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
@@ -2164,10 +2177,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 == ServiceTier::Default.request_value() => {}
other => panic!("expected Op::UserTurn with default service tier override, got {other:?}"),
}
}
+2 -8
View File
@@ -69,6 +69,7 @@ pub(crate) fn build_service_tier_selection_edits(
|service_tier| {
let config_value =
match codex_protocol::config_types::ServiceTier::from_request_value(service_tier) {
Some(codex_protocol::config_types::ServiceTier::Default) => "default",
Some(codex_protocol::config_types::ServiceTier::Fast) => "fast",
Some(codex_protocol::config_types::ServiceTier::Flex) => "flex",
None => service_tier,
@@ -79,14 +80,7 @@ pub(crate) fn build_service_tier_selection_edits(
)
},
);
let mut edits = vec![service_tier_edit];
if service_tier.is_none() {
edits.push(replace_config_value(
"notice.fast_default_opt_out",
serde_json::json!(true),
));
}
edits
vec![service_tier_edit]
}
pub(crate) async fn write_config_batch(