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:
1 parent
ce9944c316
commit
40ba85d6f0
10 files changed
+452
-66
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user