fix(codex): load context defaults from CPA model definitions

- Resolve context limits from core model definitions, aliases, and configuration
- Preserve user overrides and display context sources and fallback hints
- Refresh defaults from CPA when restoring all model settings
- Keep restore-all available without customizations and remove its icon
- Allow saving restored defaults even when values remain unchanged
- Add regression coverage for context resolution and default restoration
This commit is contained in:
lzt404 committed 2026-09-07 19:28:21 +08:00
1 parent ce9944c316
commit 40ba85d6f0
10 files changed
+452 -66

No files matched your search

+51 -7
View File
@@ -414,8 +414,7 @@ pub(crate) async fn get_codex_model_catalog_editor(
) -> Result<codex_catalog::CatalogEditorSnapshot, String> {
let _sync_guard = CODEX_CATALOG_SYNC_LOCK.lock().await;
let config = gui_config_state.snapshot()?;
let runtime_models =
fetch_codex_runtime_models(config.port, effective_agent_api_key(&config)).await?;
let runtime_models = fetch_codex_catalog_runtime_models(&config).await?;
codex_catalog::editor_snapshot(&runtime_models)
}
@@ -434,8 +433,7 @@ pub(crate) async fn save_codex_model_catalog_editor(
) -> Result<CodexCatalogEditorSaveResult, String> {
let _sync_guard = CODEX_CATALOG_SYNC_LOCK.lock().await;
let config = gui_config_state.snapshot()?;
let runtime_models =
fetch_codex_runtime_models(config.port, effective_agent_api_key(&config)).await?;
let runtime_models = fetch_codex_catalog_runtime_models(&config).await?;
let snapshot = codex_catalog::save_customizations(
&codex_model_customizations_path(&app)?,
&runtime_models,
@@ -796,13 +794,60 @@ pub(crate) async fn fetch_codex_runtime_models(
Err("本地内核不支持 Codex 模型列表接口".to_string())
}
// The client_version response contains synthesized Codex templates; its context
// fields are not the raw core model definitions. Resolve defaults through the
// management APIs before generating or editing the Codex catalog.
pub(crate) async fn fetch_codex_catalog_runtime_models(
config: &GuiConfigFile,
) -> Result<Vec<codex_catalog::CodexRuntimeModel>, String> {
let (runtime, definitions, content) = tokio::join!(
fetch_codex_runtime_models(config.port, effective_agent_api_key(config)),
fetch_codex_context_definitions(config),
fetch_management_config_yaml(config),
);
let mut runtime = runtime?;
let definitions = definitions?;
let content = content?;
let mut aliases = runtime.iter().map(|model| AgentModelOption {
name: model.slug.clone(), alias: None, is_alias: false, context_window: None,
}).collect::<Vec<_>>();
mark_configured_agent_model_aliases(&mut aliases, &content)?;
codex_catalog::merge_context_definitions(&mut runtime, &definitions, &aliases);
codex_catalog::apply_configured_context_limits(&mut runtime, &content)?;
Ok(runtime)
}
async fn fetch_codex_context_definitions(
config: &GuiConfigFile,
) -> Result<Vec<CodexModelDefinition>, String> {
// API-key sources may expose models from other channels, so do not filter by
// active OAuth credentials. Model IDs still come only from the available list.
let channels = ["gemini", "vertex", "aistudio", "antigravity", "claude", "codex", "kimi", "xai"];
let results = futures_util::future::join_all(channels.iter().map(|channel|
fetch_oauth_channel_model_definitions(config, channel)
)).await;
let mut definitions = Vec::new();
let mut successes = 0;
let mut first_error = None;
for result in results {
match result {
Ok(models) => { successes += 1; definitions.extend(models); }
Err(error) => { first_error.get_or_insert(error); }
}
}
if successes == 0 {
return Err(format!("读取 CPA 模型上下文定义失败: {}", first_error.unwrap_or_default()));
}
Ok(definitions)
}
pub(crate) async fn fetch_prepared_agent_models(
client: AgentClient,
config: &GuiConfigFile,
) -> Result<PreparedAgentModels, String> {
let api_key = effective_agent_api_key(config);
if client == AgentClient::Codex {
let runtime_models = fetch_codex_runtime_models(config.port, api_key).await?;
let runtime_models = fetch_codex_catalog_runtime_models(config).await?;
prepare_codex_agent_models(&runtime_models)
} else {
let mut models = fetch_agent_models(config.port, api_key).await?;
@@ -872,8 +917,7 @@ pub(crate) async fn refresh_applied_codex_model_catalog(
config: &GuiConfigFile,
) -> Result<bool, String> {
let _sync_guard = CODEX_CATALOG_SYNC_LOCK.lock().await;
let runtime_models =
fetch_codex_runtime_models(config.port, effective_agent_api_key(config)).await?;
let runtime_models = fetch_codex_catalog_runtime_models(config).await?;
let prepared = prepare_codex_agent_models(&runtime_models)?;
sync_prepared_codex_model_catalog(app, config, &prepared)
}
+99 -27
View File
@@ -4,10 +4,12 @@ use std::collections::{HashMap, HashSet};
use std::sync::{OnceLock, RwLock};
mod customizations;
mod runtime_context;
pub(crate) use customizations::{
editor_snapshot, load_customizations, save_customizations, CatalogEditorRequest,
CatalogEditorSnapshot,
};
pub(crate) use runtime_context::{apply_configured_context_limits, merge_context_definitions};
const MODEL_CATALOG_JSON: &str = include_str!("../resources/codex_models/model-catalog.json");
const FALLBACK_MODEL_JSON: &str = include_str!("../resources/codex_models/fallback-model.json");
@@ -21,6 +23,7 @@ pub(crate) struct CodexRuntimeModel {
description: Option<String>,
context_window: Option<u64>,
max_context_window: Option<u64>,
context_source: &'static str,
input_modalities: Option<Vec<String>>,
default_reasoning_level: Option<String>,
hidden: bool,
@@ -138,8 +141,15 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeMo
"contextLength",
],
);
let max_context_window =
positive_u64_field(value, &["max_context_window", "maxContextWindow"]);
let max_context_window = positive_u64_field(
value,
&[
"max_context_window",
"maxContextWindow",
"max_context_length",
"maxContextLength",
],
);
let input_modalities = parse_modalities(value);
let default_reasoning_level =
optional_string(value, &["default_reasoning_level", "defaultReasoningLevel"])
@@ -154,6 +164,11 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeMo
description: description.clone(),
context_window,
max_context_window,
context_source: if context_window.or(max_context_window).is_some() {
"compatibility"
} else {
"template"
},
input_modalities: input_modalities.clone(),
default_reasoning_level: default_reasoning_level.clone(),
hidden,
@@ -414,6 +429,7 @@ fn prepare_catalog_with_customizations(
"display_name".to_string(),
Value::String(runtime.slug.clone()),
);
apply_runtime_context_windows(&mut value, runtime);
enable_fast_mode(&mut value);
entries.push(CatalogEntry {
value,
@@ -565,6 +581,25 @@ fn normalize_fallback_model(model: &mut Map<String, Value>) {
}
}
// Both official and fallback templates take context defaults from the CPA API.
// Template values are used only when the API provides no valid context metadata.
fn apply_runtime_context_windows(model: &mut Map<String, Value>, runtime: &CodexRuntimeModel) {
if let Some(context_window) = runtime.context_window.or(runtime.max_context_window) {
let max_context_window = runtime
.max_context_window
.unwrap_or(context_window)
.max(context_window);
model.insert(
"context_window".to_string(),
Value::Number(context_window.into()),
);
model.insert(
"max_context_window".to_string(),
Value::Number(max_context_window.into()),
);
}
}
fn apply_runtime_metadata(model: &mut Map<String, Value>, runtime: &CodexRuntimeModel) {
model.insert("slug".to_string(), Value::String(runtime.slug.clone()));
model.insert(
@@ -579,26 +614,7 @@ fn apply_runtime_metadata(model: &mut Map<String, Value>, runtime: &CodexRuntime
);
}
if let Some(context_window) = runtime.context_window {
let max_context_window = runtime
.max_context_window
.unwrap_or(context_window)
.max(context_window);
model.insert(
"context_window".to_string(),
Value::Number(context_window.into()),
);
model.insert(
"max_context_window".to_string(),
Value::Number(max_context_window.into()),
);
} else if let Some(max_context_window) = runtime.max_context_window {
let context_window = positive_u64_value(model.get("context_window")).unwrap_or(128_000);
model.insert(
"max_context_window".to_string(),
Value::Number(max_context_window.max(context_window).into()),
);
}
apply_runtime_context_windows(model, runtime);
if let Some(modalities) = runtime.input_modalities.as_ref() {
model.insert(
@@ -1096,7 +1112,8 @@ mod tests {
["b", "C"]
);
assert_eq!(models[0]["base_instructions"], "Known B");
assert_eq!(models[0]["context_window"], 300_000);
assert_eq!(models[0]["context_window"], 1);
assert_eq!(models[0]["max_context_window"], 1);
assert_eq!(
models[1]["base_instructions"],
"You are Codex, a model-neutral coding agent."
@@ -1106,6 +1123,54 @@ mod tests {
assert!(models[1]["priority"].as_i64().unwrap() > 20);
}
#[test]
fn api_context_defaults_apply_to_official_and_fallback_models() {
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
let runtime = runtime(serde_json::json!({"models":[
{"slug":"gpt-6-astra","context_window":372000,"max_context_window":872000},
{"slug":"gpt-5.6-sol","context_window":921000,"max_context_window":1000000},
{"slug":"deepseek-v4-flash","context_length":1000000,"max_context_length":1048576},
{"slug":"other-model","context_window":128000}
]}));
let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap();
let output = output_models(&catalog);
for (slug, context, maximum) in [
("gpt-6-astra", 372_000, 872_000),
("gpt-5.6-sol", 921_000, 1_000_000),
("deepseek-v4-flash", 1_000_000, 1_048_576),
("other-model", 128_000, 128_000),
] {
let model = output.iter().find(|model| model["slug"] == slug).unwrap();
assert_eq!(model["context_window"], context, "{slug}");
assert_eq!(model["max_context_window"], maximum, "{slug}");
let option = catalog
.models
.iter()
.find(|model| model.name == slug)
.unwrap();
assert_eq!(option.context_window, Some(context));
}
}
#[test]
fn max_only_runtime_context_does_not_inherit_an_unrelated_default() {
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
for (slug, maximum) in [
("gpt-6-astra", 64_000),
("gpt-6-astra", 1_000_000),
("max-only", 64_000),
("max-only", 1_000_000),
] {
let runtime = runtime(serde_json::json!({"models":[{
"slug": slug, "max_context_window": maximum
}]}));
let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap();
let model = &output_models(&catalog)[0];
assert_eq!(model["context_window"], maximum);
assert_eq!(model["max_context_window"], maximum);
}
}
#[test]
fn known_template_preserves_unrelated_capabilities() {
let sources = test_sources();
@@ -1121,7 +1186,12 @@ mod tests {
for (key, expected) in template {
if !matches!(
key.as_str(),
"slug" | "display_name" | "service_tiers" | "additional_speed_tiers"
"slug"
| "display_name"
| "context_window"
| "max_context_window"
| "service_tiers"
| "additional_speed_tiers"
) {
assert_eq!(model.get(key), Some(expected), "changed field {key}");
}
@@ -1129,6 +1199,8 @@ mod tests {
assert_eq!(model["slug"], "a");
assert_eq!(model["display_name"], "a");
assert_eq!(model["nested"]["unknown"], true);
assert_eq!(model["context_window"], 1);
assert_eq!(model["max_context_window"], 1);
assert_eq!(
model["service_tiers"],
serde_json::json!([{
@@ -1182,7 +1254,7 @@ mod tests {
}
#[test]
fn gpt_6_astra_generation_uses_official_template_and_optional_fast_mode() {
fn gpt_6_astra_uses_api_context_with_official_capabilities_and_fast_mode() {
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
let runtime = runtime(serde_json::json!({"models":[{
"id":"gpt-6-astra",
@@ -1193,8 +1265,8 @@ mod tests {
let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap();
let model = &output_models(&catalog)[0];
assert_eq!(model["context_window"], 272_000);
assert_eq!(model["max_context_window"], 872_000);
assert_eq!(model["context_window"], 1_048_576);
assert_eq!(model["max_context_window"], 1_048_576);
assert_eq!(model["default_reasoning_level"], "low");
assert_eq!(
reasoning_efforts(model),
+80 -2
View File
@@ -39,6 +39,7 @@ pub(crate) struct CatalogEditorSnapshot {
struct CatalogEditorModel {
slug: String,
has_official_template: bool,
context_source: &'static str,
customized: bool,
configuration: Map<String, Value>,
defaults: Map<String, Value>,
@@ -64,7 +65,15 @@ fn editable_configuration(model: &Map<String, Value>) -> Map<String, Value> {
.map(|field| {
(
field.to_string(),
model.get(*field).cloned().unwrap_or(Value::Null),
model.get(*field).cloned().unwrap_or_else(|| {
// Codex defaults this optional field to 95 when it is omitted.
// The editor needs the same value to validate and save context changes.
if *field == "effective_context_window_percent" {
Value::from(95)
} else {
Value::Null
}
}),
)
})
.collect()
@@ -198,7 +207,7 @@ pub(super) fn apply_customizations(
Ok(())
}
fn snapshot_for_state(
pub(super) fn snapshot_for_state(
runtime_models: &[CodexRuntimeModel],
state: &CatalogState,
) -> Result<CatalogEditorSnapshot, String> {
@@ -222,6 +231,10 @@ fn snapshot_for_state(
models.push(CatalogEditorModel {
slug,
has_official_template: state.sources.templates.contains_key(&key),
context_source: runtime_models
.iter()
.find(|runtime| normalize_id(&runtime.slug) == key)
.map_or("template", |runtime| runtime.context_source),
customized: state.customizations.contains_key(&key),
configuration: editable_configuration(&model),
defaults,
@@ -379,6 +392,7 @@ mod tests {
description: None,
context_window: None,
max_context_window: None,
context_source: "template",
input_modalities: None,
default_reasoning_level: None,
hidden: false,
@@ -460,6 +474,70 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[test]
fn editor_context_defaults_follow_api_updates_and_preserve_explicit_overrides() {
let mut state = CatalogState {
sources: parse_sources(MODEL_CATALOG_JSON).unwrap(),
json: MODEL_CATALOG_JSON.to_string(),
customizations: Default::default(),
};
let mut runtime = runtime_model("gpt-6-astra");
runtime.context_window = Some(128_000);
runtime.max_context_window = Some(256_000);
let snapshot = snapshot_for_state(&[runtime.clone()], &state).unwrap();
assert_eq!(snapshot.models[0].configuration["context_window"], 128_000);
assert_eq!(snapshot.models[0].defaults["max_context_window"], 256_000);
assert_eq!(
snapshot.models[0].defaults["effective_context_window_percent"],
95
);
let mut configuration = snapshot.models[0].configuration.clone();
configuration.insert("context_window".to_string(), serde_json::json!(192_000));
state.customizations = customizations_from_request(
&snapshot,
CatalogEditorRequest {
revision: snapshot.revision.clone(),
models: vec![CatalogEditorModelRequest {
slug: runtime.slug.clone(),
configuration,
}],
},
)
.unwrap();
runtime.context_window = Some(512_000);
runtime.max_context_window = Some(1_000_000);
let updated = snapshot_for_state(&[runtime.clone()], &state).unwrap();
assert_ne!(updated.revision, snapshot.revision);
assert_eq!(updated.models[0].defaults["context_window"], 512_000);
assert_eq!(updated.models[0].defaults["max_context_window"], 1_000_000);
assert_eq!(updated.models[0].configuration["context_window"], 192_000);
assert_eq!(
updated.models[0].configuration["max_context_window"],
256_000
);
// Restoring defaults drops the saved override and follows the current API response.
state.customizations = customizations_from_request(
&updated,
CatalogEditorRequest {
revision: updated.revision.clone(),
models: vec![CatalogEditorModelRequest {
slug: runtime.slug.clone(),
configuration: updated.models[0].defaults.clone(),
}],
},
)
.unwrap();
assert!(state.customizations.is_empty());
let generated =
prepare_catalog_with_customizations(&[runtime], &state.sources, &state.customizations)
.unwrap();
let model: Value = serde_json::from_str(&generated.json).unwrap();
assert_eq!(model["models"][0]["context_window"], 512_000);
assert_eq!(model["models"][0]["max_context_window"], 1_000_000);
}
#[test]
fn stale_editor_revision_is_rejected_without_changing_state() {
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
@@ -0,0 +1,146 @@
use super::*;
pub(crate) fn merge_context_definitions(
runtime_models: &mut [CodexRuntimeModel],
definitions: &[crate::CodexModelDefinition],
aliases: &[AgentModelOption],
) {
for runtime in runtime_models {
let source = aliases
.iter()
.find(|model| model.is_alias && model.name.eq_ignore_ascii_case(&runtime.slug))
.and_then(|model| model.alias.as_deref())
.unwrap_or(&runtime.slug);
// These are core model definitions, not the synthesized Codex client catalog.
let context = definitions
.iter()
.filter(|definition| definition.id.eq_ignore_ascii_case(source))
.filter_map(|definition| definition.context_window)
.filter(|value| *value > 0)
.min();
if let Some(context) = context {
runtime.context_window = Some(context);
runtime.max_context_window =
Some(runtime.max_context_window.unwrap_or(context).max(context));
runtime.context_source = "definition";
}
}
}
pub(crate) fn apply_configured_context_limits(
runtime_models: &mut [CodexRuntimeModel],
content: &str,
) -> Result<(), String> {
let document: serde_norway::Value = serde_norway::from_str(content)
.map_err(|error| format!("解析内核模型上下文配置失败: {error}"))?;
let root = document
.as_mapping()
.ok_or("内核配置顶层必须是 YAML 映射")?;
let mut limits: HashMap<String, u64> = HashMap::new();
for section in crate::MODEL_ALIAS_CONFIG_SECTIONS {
let Some(providers) =
crate::yaml_mapping_value(root, section).and_then(serde_norway::Value::as_sequence)
else {
continue;
};
for provider in providers {
let Some(models) = provider
.as_mapping()
.and_then(|provider| crate::yaml_mapping_value(provider, "models"))
.and_then(serde_norway::Value::as_sequence)
else {
continue;
};
for model in models {
let Some((_, public_name, _)) = crate::configured_model_identity(model) else {
continue;
};
let Some(limit) = model
.as_mapping()
.and_then(|model| crate::yaml_mapping_value(model, "max-context-length"))
.and_then(serde_norway::Value::as_u64)
.filter(|value| *value > 0)
else {
continue;
};
limits
.entry(normalize_id(&public_name))
.and_modify(|existing| *existing = (*existing).min(limit))
.or_insert(limit);
}
}
}
for runtime in runtime_models {
if let Some(&limit) = limits.get(&normalize_id(&runtime.slug)) {
runtime.context_window = Some(limit);
runtime.max_context_window = Some(limit);
runtime.context_source = "configuration";
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raw_core_definitions_replace_synthesized_272k_for_models_and_aliases() {
let mut runtime = parse_runtime_models(&serde_json::json!({"models":[
{"slug":"gpt-5.6-sol","context_window":272000,"max_context_window":872000},
{"slug":"gemini-3.8-flash-high","context_window":272000,"max_context_window":272000},
{"slug":"fast-alias","context_window":272000,"max_context_window":272000},
{"slug":"no-definition","context_window":272000,"max_context_window":272000}
]}))
.unwrap();
let definitions = crate::parse_codex_model_definitions(&serde_json::json!({"models":[
{"id":"gpt-5.6-sol","context_length":921000},
{"id":"gemini-3.8-flash-high","context_length":1048576}
]}))
.unwrap();
let aliases = vec![AgentModelOption {
name: "fast-alias".to_string(),
alias: Some("GPT-5.6-SOL".to_string()),
is_alias: true,
context_window: None,
}];
merge_context_definitions(&mut runtime, &definitions, &aliases);
assert_eq!(runtime[0].context_window, Some(921_000));
assert_eq!(runtime[0].max_context_window, Some(921_000));
assert_eq!(runtime[1].context_window, Some(1_048_576));
assert_eq!(runtime[2].context_window, Some(921_000));
assert_eq!(runtime[2].context_source, "definition");
assert_eq!(runtime[3].context_source, "compatibility");
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
let state = CatalogState {
sources,
json: MODEL_CATALOG_JSON.to_string(),
customizations: Default::default(),
};
let snapshot = customizations::snapshot_for_state(&runtime, &state).unwrap();
let snapshot = serde_json::to_value(snapshot).unwrap();
let model = snapshot["models"]
.as_array()
.unwrap()
.iter()
.find(|model| model["slug"] == "gpt-5.6-sol")
.unwrap();
assert_eq!(model["configuration"]["context_window"], 921_000);
assert_eq!(model["defaults"]["context_window"], 921_000);
assert_eq!(model["contextSource"], "definition");
}
#[test]
fn core_configured_context_limit_takes_precedence_over_static_definitions() {
let mut runtime = parse_runtime_models(&serde_json::json!({"models":[
{"slug":"custom-alias","context_window":921000,"max_context_window":921000},
{"slug":"unmodified","context_window":128000,"max_context_window":128000}
]}))
.unwrap();
apply_configured_context_limits(&mut runtime, "codex-api-key:\n - models:\n - name: upstream-model\n alias: custom-alias\n max-context-length: 64000\n").unwrap();
assert_eq!(runtime[0].context_window, Some(64_000));
assert_eq!(runtime[0].max_context_window, Some(64_000));
assert_eq!(runtime[0].context_source, "configuration");
assert_eq!(runtime[1].context_window, Some(128_000));
}
}