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));
}
}
+10 -5
View File
@@ -65,7 +65,7 @@ export const jaOverrides = {
'config.software.terminal.auto': '自動選択',
'agents.catalog.button': 'モデル一覧の設定',
'agents.catalog.title': 'Codex モデル一覧',
'agents.catalog.subtitle': '利用可能なモデルのパラメーターを編集し、生成される Codex モデル一覧に保存します。',
'agents.catalog.subtitle': 'コンテキストの既定値は CPA コア API から取得します。モデルのパラメーターを編集し、Codex モデル一覧に保存できます。',
'agents.catalog.search': 'モデル名または ID を検索',
'agents.catalog.official': '公式テンプレート',
'agents.catalog.fallback': '標準テンプレート',
@@ -76,6 +76,11 @@ export const jaOverrides = {
'agents.catalog.displayName': '表示名',
'agents.catalog.description': 'モデルの説明',
'agents.catalog.context': 'コンテキストウィンドウ(トークン)',
'agents.catalog.contextSource.definition': 'コンテキストの取得元:CPA コア API のモデル定義。',
'agents.catalog.contextSource.configuration': 'コンテキストの取得元:CPA コアでこのモデルに設定された max-context-length。',
'agents.catalog.contextSource.compatibility': 'CPA にこのモデル固有のコンテキスト定義がありません。現在の値は互換カタログの値であり、汎用テンプレートの既定値である可能性があります。',
'agents.catalog.contextSource.template': 'コアに有効なコンテキスト情報がありません。ソフトウェアのテンプレートの代替値を使用しています。',
'agents.catalog.contextSource.customized': 'コンテキストの取得元:このモデルのカスタム設定。',
'agents.catalog.maximum': '最大コンテキストウィンドウ(トークン)',
'agents.catalog.percent': '有効コンテキスト比率(%)',
'agents.catalog.compact': '自動圧縮のしきい値(トークン)',
@@ -89,10 +94,10 @@ export const jaOverrides = {
'agents.catalog.visible': 'Codex のモデル一覧に表示',
'agents.catalog.parallel': '並列ツール呼び出しを許可',
'agents.catalog.capabilityHint': 'Fast は常に選択可能です。推論・画像・ツール機能の動作は上流モデルの対応状況によります。',
'agents.catalog.resetModel': 'このテンプレートを復元',
'agents.catalog.resetAll': 'すべて公式テンプレートに復元',
'agents.catalog.resetHint': '専用テンプレートがあるモデルは公式設定に、それ以外はアプリの標準設定に戻ります。保存すると反映されます。',
'agents.catalog.restored': '下書きをテンプレートに戻しました。保存すると反映されます。',
'agents.catalog.resetModel': 'このモデルの既定値に戻す',
'agents.catalog.resetAll': 'すべて既定値に戻す',
'agents.catalog.resetHint': 'コンテキストは CPA コア API の値を既定値とし、有効なデータがない場合のみテンプレートの値を使用します。その他の項目はモデルのテンプレートに戻ります。保存後に適用されます。',
'agents.catalog.restored': '下書きを既定値に戻しました。保存すると適用されます。',
'agents.catalog.saved': '保存しました。管理対象の一覧が同期されます。Codex を再起動して変更を読み込んでください。',
'agents.catalog.syncFailed': '設定は保存しましたが、一覧の同期に失敗しました:{error}',
'agents.catalog.changed': 'モデル一覧またはテンプレートが更新されました。再読み込みしてから編集してください。',
+10 -5
View File
@@ -15,7 +15,7 @@ export const en: Record<MessageKey, string> = {
'usage.unit.tokens': 'Token',
'agents.catalog.button': 'Model list settings',
'agents.catalog.title': 'Codex model catalog',
'agents.catalog.subtitle': 'Customize parameters for currently available models and save them to the generated Codex catalog.',
'agents.catalog.subtitle': 'Default context settings come from the CPA core API. Customize model parameters and save them to the Codex catalog.',
'agents.catalog.search': 'Search model names or IDs',
'agents.catalog.official': 'Official template',
'agents.catalog.fallback': 'Default template',
@@ -26,6 +26,11 @@ export const en: Record<MessageKey, string> = {
'agents.catalog.displayName': 'Display name',
'agents.catalog.description': 'Description',
'agents.catalog.context': 'Context window (tokens)',
'agents.catalog.contextSource.definition': 'Context source: model definitions from the CPA core API.',
'agents.catalog.contextSource.configuration': 'Context source: max-context-length configured for this model in the CPA core.',
'agents.catalog.contextSource.compatibility': 'CPA provides no independent context definition for this model. The current value comes from its compatibility catalog and may be a generic template default.',
'agents.catalog.contextSource.template': 'The core provides no valid context data. The current value is a fallback from the application template.',
'agents.catalog.contextSource.customized': 'Context source: your custom settings for this model.',
'agents.catalog.maximum': 'Maximum context window (tokens)',
'agents.catalog.percent': 'Effective context (%)',
'agents.catalog.compact': 'Auto-compaction threshold (tokens)',
@@ -39,10 +44,10 @@ export const en: Record<MessageKey, string> = {
'agents.catalog.visible': 'Show in the Codex model list',
'agents.catalog.parallel': 'Allow parallel tool calls',
'agents.catalog.capabilityHint': 'Fast is always available. Reasoning, image and tool capabilities depend on the upstream model.',
'agents.catalog.resetModel': 'Restore this template',
'agents.catalog.resetAll': 'Restore all official templates',
'agents.catalog.resetHint': 'Models with dedicated templates return to official settings; other models return to application defaults. Save to apply.',
'agents.catalog.restored': 'Templates restored in the draft. Save to apply.',
'agents.catalog.resetModel': 'Restore model defaults',
'agents.catalog.resetAll': 'Restore all defaults',
'agents.catalog.resetHint': 'Context windows default to CPA core API values, with template values used only when valid data is missing. Other parameters revert to the model template. Save to apply.',
'agents.catalog.restored': 'Defaults restored in the draft. Save to apply.',
'agents.catalog.saved': 'Saved. Managed catalogs are synchronized; restart Codex to load the changes.',
'agents.catalog.syncFailed': 'Settings saved, but catalog synchronization failed: {error}',
'agents.catalog.changed': 'The model list or templates have changed. Reload before editing again.',
+10 -5
View File
@@ -14,7 +14,7 @@ export const zhCN = {
'usage.unit.tokens': 'Token',
'agents.catalog.button': '模型列表设置',
'agents.catalog.title': 'Codex 模型列表',
'agents.catalog.subtitle': '自定义当前可用模型的参数,保存后用于生成 Codex 模型列表。',
'agents.catalog.subtitle': '默认上下文参数读取自 CPA 内核 API;可自定义当前模型参数并保存到 Codex 模型列表。',
'agents.catalog.search': '搜索模型名称或 ID',
'agents.catalog.official': '官方模板',
'agents.catalog.fallback': '默认模板',
@@ -25,6 +25,11 @@ export const zhCN = {
'agents.catalog.displayName': '显示名称',
'agents.catalog.description': '模型说明',
'agents.catalog.context': '上下文窗口(tokens)',
'agents.catalog.contextSource.definition': '上下文来源:CPA 内核的模型定义 API。',
'agents.catalog.contextSource.configuration': '上下文来源:CPA 内核中为此模型配置的 max-context-length。',
'agents.catalog.contextSource.compatibility': 'CPA 未提供此模型的独立上下文定义,当前值来自兼容目录,可能是通用模板默认值。',
'agents.catalog.contextSource.template': '内核未提供有效的上下文数据,当前使用软件模板的后备值。',
'agents.catalog.contextSource.customized': '上下文来源:你为此模型保存或编辑的自定义配置。',
'agents.catalog.maximum': '最大上下文窗口(tokens)',
'agents.catalog.percent': '有效上下文比例(%)',
'agents.catalog.compact': '自动压缩阈值(tokens)',
@@ -38,10 +43,10 @@ export const zhCN = {
'agents.catalog.visible': '在 Codex 模型列表中显示',
'agents.catalog.parallel': '允许并行工具调用',
'agents.catalog.capabilityHint': 'Fast 始终可选;思考等级、图片与工具能力是否生效取决于上游模型。',
'agents.catalog.resetModel': '恢复此模型模板',
'agents.catalog.resetAll': '全部恢复官方模板',
'agents.catalog.resetHint': '有专属模板的模型恢复官方配置,其余恢复软件默认配置。恢复操作保存后生效。',
'agents.catalog.restored': '模板已恢复到草稿,点击保存后生效。',
'agents.catalog.resetModel': '恢复此模型默认值',
'agents.catalog.resetAll': '全部恢复默认值',
'agents.catalog.resetHint': '上下文窗口默认使用 CPA 内核 API 返回值,仅在缺少有效数据时使用模板值;其他参数恢复模型模板。保存后生效。',
'agents.catalog.restored': '默认值已恢复到草稿,点击保存后生效。',
'agents.catalog.saved': '已保存。已托管的模型目录会同步更新,重启 Codex 后加载新配置。',
'agents.catalog.syncFailed': '设置已保存,但目录同步失败:{error}',
'agents.catalog.changed': '模型列表或模板已更新,请重新加载后再编辑。',
+21 -15
View File
@@ -4,6 +4,7 @@ import { Check, LoaderCircle, RefreshCw, RotateCcw, Search, X } from 'lucide-rea
import { useI18n } from '../i18n';
import {
cloneCodexModelConfiguration,
codexContextSourceHint,
codexReasoningEfforts,
sameCodexModelConfiguration,
toggleCodexReasoningLevel,
@@ -36,23 +37,29 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [defaultsRestored, setDefaultsRestored] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
const load = useCallback(async () => {
const load = useCallback(async (restoreDefaults = false) => {
setLoading(true);
setError('');
setNotice('');
try {
const next = await invoke<CodexCatalogEditorSnapshot>('get_codex_model_catalog_editor');
setSnapshot(next);
setModels(cloneModels(next));
setModels(cloneModels(next).map((model) => restoreDefaults
? { ...model, configuration: cloneCodexModelConfiguration(model.defaults) }
: model));
setDefaultsRestored(restoreDefaults);
setSelectedSlug((current) => next.models.some((model) => model.slug === current)
? current
: next.models[0]?.slug ?? '');
return true;
} catch (requestError) {
setError(String(requestError));
return false;
} finally {
setLoading(false);
}
@@ -63,12 +70,13 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
}, [load]);
const dirty = useMemo(() => {
if (defaultsRestored) return true;
if (!snapshot || snapshot.models.length !== models.length) return false;
return models.some((model) => {
const saved = snapshot.models.find((candidate) => candidate.slug === model.slug);
return !saved || !sameCodexModelConfiguration(model.configuration, saved.configuration);
});
}, [models, snapshot]);
}, [defaultsRestored, models, snapshot]);
const filteredModels = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
@@ -130,17 +138,13 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
setNotice(t('agents.catalog.restored'));
};
const restoreAll = () => {
setModels((current) => current.map((model) => ({
...model,
configuration: cloneCodexModelConfiguration(model.defaults),
})));
setError('');
setNotice(t('agents.catalog.restored'));
const restoreAll = async () => {
if (loading || saving) return;
if (await load(true)) setNotice(t('agents.catalog.restored'));
};
const save = async () => {
if (!snapshot || saving) return;
if (!snapshot || loading || saving) return;
for (const model of models) {
const validationKey = validateCodexModelConfiguration(model.configuration);
if (validationKey) {
@@ -165,6 +169,7 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
});
setSnapshot(result.snapshot);
setModels(cloneModels(result.snapshot));
setDefaultsRestored(false);
setNotice(result.synchronizationError
? t('agents.catalog.syncFailed', { error: result.synchronizationError })
: t('agents.catalog.saved'));
@@ -231,7 +236,7 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
</aside>
<main className="codex-catalog-editor">
{activeModel ? (
{activeModel && !loading ? (
<>
<div className="codex-catalog-model-heading">
<div>
@@ -276,6 +281,7 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
<label className="codex-catalog-switch"><input type="checkbox" checked={activeModel.configuration.visibility === 'list'} onChange={(event) => updateField('visibility', event.currentTarget.checked ? 'list' : 'hide')} /><span>{t('agents.catalog.visible')}</span></label>
<label className="codex-catalog-switch"><input type="checkbox" checked={activeModel.configuration.supports_parallel_tool_calls} onChange={(event) => updateField('supports_parallel_tool_calls', event.currentTarget.checked)} /><span>{t('agents.catalog.parallel')}</span></label>
</div>
<p className="codex-catalog-hint" role="note">{t(codexContextSourceHint(activeModel))}</p>
<p className="codex-catalog-hint">{t('agents.catalog.capabilityHint')}</p>
</>
) : !loading ? <div className="codex-catalog-state">{t('agents.catalog.empty')}</div> : null}
@@ -289,11 +295,11 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
{!error && !notice ? <span>{dirty ? t('agents.catalog.unsaved') : t('agents.catalog.saveHint')}</span> : null}
</div>
<div>
<button type="button" className="secondary-button" onClick={restoreAll} disabled={!models.some((model) => !sameCodexModelConfiguration(model.configuration, model.defaults)) || saving}>
<RotateCcw size={15} />{t('agents.catalog.resetAll')}
<button type="button" className="secondary-button" onClick={() => void restoreAll()} disabled={loading || saving}>
{t('agents.catalog.resetAll')}
</button>
<button type="button" className="secondary-button" onClick={requestClose} disabled={saving}>{t('common.cancel')}</button>
<button type="button" className="primary-button" onClick={() => void save()} disabled={!snapshot || !dirty || saving}>
<button type="button" className="primary-button" onClick={() => void save()} disabled={!snapshot || !dirty || loading || saving}>
{saving ? <LoaderCircle size={16} className="spin" /> : null}{saving ? t('common.saving') : t('common.save')}
</button>
</div>
+9
View File
@@ -25,6 +25,7 @@ export type CodexModelConfiguration = {
export type CodexCatalogEditorModel = {
slug: string;
hasOfficialTemplate: boolean;
contextSource: "definition" | "configuration" | "compatibility" | "template";
customized: boolean;
configuration: CodexModelConfiguration;
defaults: CodexModelConfiguration;
@@ -35,6 +36,14 @@ export type CodexCatalogEditorSnapshot = {
models: CodexCatalogEditorModel[];
};
export function codexContextSourceHint(model: CodexCatalogEditorModel): MessageKey {
if (model.configuration.context_window !== model.defaults.context_window
|| model.configuration.max_context_window !== model.defaults.max_context_window) {
return "agents.catalog.contextSource.customized";
}
return `agents.catalog.contextSource.${model.contextSource ?? "template"}`;
}
export type CodexCatalogEditorSaveResult = {
snapshot: CodexCatalogEditorSnapshot;
synchronizationError: string | null;
+16
View File
@@ -1,10 +1,12 @@
import { describe, expect, test } from 'bun:test';
import {
cloneCodexModelConfiguration,
codexContextSourceHint,
sameCodexModelConfiguration,
toggleCodexReasoningLevel,
validateCodexModelConfiguration,
type CodexModelConfiguration,
type CodexCatalogEditorModel,
} from '../src/services/codexModelCatalog';
const configuration = (): CodexModelConfiguration => ({
@@ -26,6 +28,20 @@ const configuration = (): CodexModelConfiguration => ({
});
describe('Codex 模型列表编辑', () => {
test("区分内核模型定义与兼容目录的后备上下文", () => {
const model: CodexCatalogEditorModel = {
slug: "model-a", hasOfficialTemplate: true, customized: false,
contextSource: "definition", configuration: configuration(), defaults: configuration(),
};
expect(codexContextSourceHint(model)).toBe("agents.catalog.contextSource.definition");
expect(codexContextSourceHint({ ...model, contextSource: "compatibility" }))
.toBe("agents.catalog.contextSource.compatibility");
model.configuration.context_window = 64_000;
expect(codexContextSourceHint(model)).toBe("agents.catalog.contextSource.customized");
model.configuration = cloneCodexModelConfiguration(model.defaults);
expect(codexContextSourceHint(model)).toBe("agents.catalog.contextSource.definition");
});
test('克隆配置时隔离数组字段', () => {
const original = configuration();
const cloned = cloneCodexModelConfiguration(original);