diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index e9da5f5..2602145 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -414,8 +414,7 @@ pub(crate) async fn get_codex_model_catalog_editor( ) -> Result { 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 { 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, 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::>(); + 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, 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 { 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 { 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) } diff --git a/src-tauri/src/codex_catalog.rs b/src-tauri/src/codex_catalog.rs index 233b998..ab64260 100644 --- a/src-tauri/src/codex_catalog.rs +++ b/src-tauri/src/codex_catalog.rs @@ -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, context_window: Option, max_context_window: Option, + context_source: &'static str, input_modalities: Option>, default_reasoning_level: Option, hidden: bool, @@ -138,8 +141,15 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result Result) { } } +// 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, 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, 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, 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), diff --git a/src-tauri/src/codex_catalog/customizations.rs b/src-tauri/src/codex_catalog/customizations.rs index f0d9908..7fbac06 100644 --- a/src-tauri/src/codex_catalog/customizations.rs +++ b/src-tauri/src/codex_catalog/customizations.rs @@ -39,6 +39,7 @@ pub(crate) struct CatalogEditorSnapshot { struct CatalogEditorModel { slug: String, has_official_template: bool, + context_source: &'static str, customized: bool, configuration: Map, defaults: Map, @@ -64,7 +65,15 @@ fn editable_configuration(model: &Map) -> Map { .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 { @@ -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(); diff --git a/src-tauri/src/codex_catalog/runtime_context.rs b/src-tauri/src/codex_catalog/runtime_context.rs new file mode 100644 index 0000000..bb40392 --- /dev/null +++ b/src-tauri/src/codex_catalog/runtime_context.rs @@ -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 = 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)); + } +} diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 19dd79c..c3888f8 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -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': 'モデル一覧またはテンプレートが更新されました。再読み込みしてから編集してください。', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index f37c966..6665b83 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -15,7 +15,7 @@ export const en: Record = { '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 = { '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 = { '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.', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 0c4f5fe..ca04983 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -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': '模型列表或模板已更新,请重新加载后再编辑。', diff --git a/src/pages/CodexModelCatalogDialog.tsx b/src/pages/CodexModelCatalogDialog.tsx index f64c878..6eeea9c 100644 --- a/src/pages/CodexModelCatalogDialog.tsx +++ b/src/pages/CodexModelCatalogDialog.tsx @@ -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('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
- {activeModel ? ( + {activeModel && !loading ? ( <>
@@ -276,6 +281,7 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
+

{t(codexContextSourceHint(activeModel))}

{t('agents.catalog.capabilityHint')}

) : !loading ?
{t('agents.catalog.empty')}
: null} @@ -289,11 +295,11 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD {!error && !notice ? {dirty ? t('agents.catalog.unsaved') : t('agents.catalog.saveHint')} : null}
- -
diff --git a/src/services/codexModelCatalog.ts b/src/services/codexModelCatalog.ts index f77020b..f188105 100644 --- a/src/services/codexModelCatalog.ts +++ b/src/services/codexModelCatalog.ts @@ -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; diff --git a/tests/codexModelCatalog.test.ts b/tests/codexModelCatalog.test.ts index c0060fd..d67a56d 100644 --- a/tests/codexModelCatalog.test.ts +++ b/tests/codexModelCatalog.test.ts @@ -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);