Files
codex/codex-rs/tui/src/config_update.rs
T
Shijie Rao 370b13afc9 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`
2026-05-20 15:57:50 -07:00

158 lines
5.1 KiB
Rust

//! App-server-backed config update helpers for the TUI.
//!
//! This module centralizes the small typed update helpers the TUI uses
//! when a config mutation must be owned by the app server rather than written
//! to the local `config.toml` directly.
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigEdit;
use codex_app_server_protocol::ConfigWriteResponse;
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;
use serde_json::Value as JsonValue;
use uuid::Uuid;
pub(crate) fn replace_config_value(key_path: impl Into<String>, value: JsonValue) -> ConfigEdit {
ConfigEdit {
key_path: key_path.into(),
value,
merge_strategy: MergeStrategy::Replace,
}
}
pub(crate) fn clear_config_value(key_path: impl Into<String>) -> ConfigEdit {
replace_config_value(key_path, JsonValue::Null)
}
pub(crate) fn profile_scoped_key_path(profile: Option<&str>, key_path: &str) -> String {
if let Some(profile) = profile {
let profile = serde_json::Value::String(profile.to_string()).to_string();
format!("profiles.{profile}.{key_path}")
} else {
key_path.to_string()
}
}
pub(crate) fn app_scoped_key_path(app_id: &str, key_path: &str) -> String {
let app_id = serde_json::Value::String(app_id.to_string()).to_string();
format!("apps.{app_id}.{key_path}")
}
pub(crate) fn build_model_selection_edits(
profile: Option<&str>,
model: &str,
effort: Option<impl ToString>,
) -> Vec<ConfigEdit> {
let effort_edit = effort.map_or_else(
|| clear_config_value(profile_scoped_key_path(profile, "model_reasoning_effort")),
|effort| {
replace_config_value(
profile_scoped_key_path(profile, "model_reasoning_effort"),
serde_json::json!(effort.to_string()),
)
},
);
vec![
replace_config_value(
profile_scoped_key_path(profile, "model"),
serde_json::json!(model),
),
effort_edit,
]
}
pub(crate) fn build_service_tier_selection_edits(
profile: Option<&str>,
service_tier: Option<&str>,
) -> Vec<ConfigEdit> {
let service_tier_edit = service_tier.map_or_else(
|| clear_config_value(profile_scoped_key_path(profile, "service_tier")),
|service_tier| {
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),
)
},
);
vec![service_tier_edit]
}
pub(crate) async fn write_config_batch(
request_handle: AppServerRequestHandle,
edits: Vec<ConfigEdit>,
) -> Result<()> {
let request_id = RequestId::String(format!("tui-config-write-{}", Uuid::new_v4()));
let _: ConfigWriteResponse = request_handle
.request_typed(ClientRequest::ConfigBatchWrite {
request_id,
params: ConfigBatchWriteParams {
edits,
file_path: None,
expected_version: None,
reload_user_config: true,
},
})
.await
.wrap_err("config/batchWrite failed in TUI")?;
Ok(())
}
pub(crate) async fn write_skill_enabled(
request_handle: AppServerRequestHandle,
path: AbsolutePathBuf,
enabled: bool,
) -> Result<()> {
let request_id = RequestId::String(format!("tui-skill-config-write-{}", Uuid::new_v4()));
let _: SkillsConfigWriteResponse = request_handle
.request_typed(ClientRequest::SkillsConfigWrite {
request_id,
params: SkillsConfigWriteParams {
path: Some(path),
name: None,
enabled,
},
})
.await
.wrap_err("skills/config/write failed in TUI")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn profile_scoped_key_path_quotes_dotted_profile_names() {
assert_eq!(
profile_scoped_key_path(Some("team.prod"), "model"),
"profiles.\"team.prod\".model"
);
}
#[test]
fn app_scoped_key_path_quotes_dotted_app_ids() {
assert_eq!(
app_scoped_key_path("plugin.linear", "enabled"),
"apps.\"plugin.linear\".enabled"
);
}
}