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

+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));
}
}