fix(codex): preserve context defaults from the client model API

This commit is contained in:
lzt404 committed 2026-09-11 16:25:14 +08:00
1 parent 898cf080d3
commit ca76f344fb
8 files changed
+126 -171

No files matched your search

+8 -90
View File
@@ -135,7 +135,7 @@ pub(crate) async fn refresh_agent_config_statuses(
#[tauri::command]
pub(crate) async fn get_agent_models(
app: tauri::AppHandle,
_app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
client: String,
) -> Result<Vec<AgentModelOption>, String> {
@@ -151,11 +151,6 @@ pub(crate) async fn get_agent_models(
};
let config = gui_config_state.snapshot()?;
let prepared = fetch_prepared_agent_models(client, &config).await?;
if client == AgentClient::Codex {
if let Err(error) = sync_prepared_codex_model_catalog(&app, &config, &prepared) {
eprintln!("自动刷新已应用的 Codex 模型目录失败: {error}");
}
}
Ok(prepared.models)
}
@@ -794,53 +789,20 @@ 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.
// Codex client metadata distinguishes the default context from the maximum.
// Do not overwrite either with the generic model-definitions context_length.
pub(crate) async fn fetch_codex_catalog_runtime_models(
config: &GuiConfigFile,
) -> Result<Vec<codex_catalog::CodexRuntimeModel>, String> {
let (runtime, definitions, content) = tokio::join!(
let (runtime, 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)?;
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,
@@ -1267,56 +1229,12 @@ pub(crate) async fn update_agent_config(
gui_config_state: tauri::State<'_, GuiConfigState>,
client: String,
model: String,
oauth_configuration: bool,
claude_code_model_mappings: Option<ClaudeDesktopModelMappings>,
claude_desktop_model_mappings: Option<ClaudeDesktopModelMappings>,
) -> Result<AgentConfigActionResult, String> {
let client = AgentClient::parse(&client)?;
let home = app
.path()
.home_dir()
.map_err(|error| format!("无法获取用户目录: {error}"))?;
let config = gui_config_state.snapshot()?;
let port = config.port;
let api_key = effective_agent_api_key(&config);
let prepared = fetch_prepared_agent_models(client, &config).await?;
let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?;
let claude_code_model_mappings = resolve_claude_code_model_mappings(
client,
&prepared.models,
&model,
claude_code_model_mappings,
)?;
let claude_desktop_model_mappings = resolve_claude_desktop_model_mappings(
client,
&prepared.models,
&model,
claude_desktop_model_mappings,
)?;
if let Some(mappings) = claude_desktop_model_mappings.as_ref() {
ensure_claude_desktop_model_aliases(&config, mappings, &prepared.models).await?;
}
let _guard = AGENT_CONFIG_FILE_LOCK
.lock()
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
let oauth_configuration = if client == AgentClient::Codex {
current_codex_oauth_configuration(&home)?
} else {
false
};
apply_agent_configuration_with_oauth(
client,
&home,
port,
api_key,
&model,
AgentConfigurationOptions {
models: &prepared.models,
codex_catalog: prepared.codex_catalog.as_deref(),
oauth_configuration,
claude_code_model_mappings: claude_code_model_mappings.as_ref(),
claude_desktop_model_mappings: claude_desktop_model_mappings.as_ref(),
},
)
apply_agent_config(app, gui_config_state, client, model, oauth_configuration,
claude_code_model_mappings, claude_desktop_model_mappings).await
}
pub(crate) fn validate_agent_can_enable(
+2 -2
View File
@@ -9,7 +9,7 @@ pub(crate) use customizations::{
editor_snapshot, load_customizations, save_customizations, CatalogEditorRequest,
CatalogEditorSnapshot,
};
pub(crate) use runtime_context::{apply_configured_context_limits, merge_context_definitions};
pub(crate) use runtime_context::apply_configured_context_limits;
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");
@@ -165,7 +165,7 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeMo
context_window,
max_context_window,
context_source: if context_window.or(max_context_window).is_some() {
"compatibility"
"client"
} else {
"template"
},
+34 -60
View File
@@ -1,32 +1,5 @@
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,
@@ -85,32 +58,13 @@ 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":[
fn codex_client_context_defaults_are_preserved_in_editor_and_generated_catalog() {
let 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}
{"slug":"fast-alias","context_window":128000,"max_context_window":256000},
{"slug":"missing-context"}
]}))
.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,
@@ -119,19 +73,39 @@ mod tests {
};
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");
let prepared =
prepare_catalog_with_customizations(&runtime, &state.sources, &state.customizations)
.unwrap();
let catalog: Value = serde_json::from_str(&prepared.json).unwrap();
for (slug, context, maximum, source) in [
("gpt-5.6-sol", 272_000, 872_000, "client"),
("fast-alias", 128_000, 256_000, "client"),
("missing-context", 272_000, 272_000, "template"),
] {
let model = snapshot["models"]
.as_array()
.unwrap()
.iter()
.find(|model| model["slug"] == slug)
.unwrap();
for field in ["configuration", "defaults"] {
assert_eq!(model[field]["context_window"], context);
assert_eq!(model[field]["max_context_window"], maximum);
}
assert_eq!(model["contextSource"], source);
let generated = catalog["models"]
.as_array()
.unwrap()
.iter()
.find(|model| model["slug"] == slug)
.unwrap();
assert_eq!(generated["context_window"], context);
assert_eq!(generated["max_context_window"], maximum);
}
}
#[test]
fn core_configured_context_limit_takes_precedence_over_static_definitions() {
fn core_configured_context_limit_takes_precedence_over_client_defaults() {
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}
+25 -4
View File
@@ -76,9 +76,8 @@ export const jaOverrides = {
'agents.catalog.displayName': '表示名',
'agents.catalog.description': 'モデルの説明',
'agents.catalog.context': 'コンテキストウィンドウ(トークン)',
'agents.catalog.contextSource.definition': 'コンテキストの取得元:CPA コア API のモデル定義。',
'agents.catalog.contextSource.client': 'コンテキストの取得元:CPA コアの Codex クライアントモデル一覧 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': '最大コンテキストウィンドウ(トークン)',
@@ -810,6 +809,28 @@ export const jaOverrides = {
'agents.status.invalid': '設定が無効',
'agents.status.applied': '適用済み',
'agents.status.unconfigured': '未設定',
"agents.history.button": "設定履歴を復元",
"agents.history.description": "ソフトウェア管理の項目のみ復元し、他の設定を保持します。復元前に現在の設定を保存し、フォームを更新します。",
"agents.history.versions": "履歴",
"agents.history.empty": "履歴はありません。設定更新時に自動保存されます。",
"agents.history.files": "{count} ファイル",
"agents.history.preview": "復元プレビュー",
"agents.history.loading": "読み込み中…",
"agents.history.select": "履歴を選択して復元する項目を確認してください。",
"agents.history.unchanged": "設定は最新です。",
"agents.history.updated": "設定を更新し、履歴を保存しました。",
"agents.history.restored": "設定を復元しました。他の設定は保持されています。",
"agents.history.field": "項目",
"agents.history.current": "現在",
"agents.history.target": "履歴値",
"agents.history.restore": "この履歴を復元",
"agents.history.before": "{source}前のバックアップ",
"agents.history.source.update": "設定更新",
"agents.history.source.default": "既定設定",
"agents.history.source.clear": "設定消去",
"agents.history.source.restore": "復元",
"agents.history.source.sync": "自動同期",
"agents.history.source.legacy": "旧バージョンのバックアップ",
'agents.title': 'エージェント設定',
'agents.embedded.title': 'クライアントとモデルを選んで起動',
'agents.embedded.subtitle': 'ない場合は先にインストールしてください',
@@ -937,10 +958,10 @@ export const jaOverrides = {
'agents.pi.uninstall': 'Pi プラグインをアンインストール',
'agents.pi.uninstalling': 'プラグインをアンインストール中',
'agents.default.title': 'デフォルト設定にリセット',
'agents.default.description': '選択中のモデルを使って {name} の関連設定ファイルをすべて書き直し、その他のユーザー内容を削除します。バックアップは作成されず、元に戻せません。',
'agents.default.description': '選択中のモデルを使って {name} の関連設定ファイルをすべて書き直し、その他のユーザー内容を削除します。操作前に履歴を保存します。履歴から復元できるのは管理対象フィールドのみで、削除された独自設定は復元されません。',
'agents.default.confirm': 'デフォルト設定を書き込む',
'agents.clear.title': 'Codex 設定を消去',
'agents.clear.description': 'Codex 設定ディレクトリの auth.json と config.toml を完全に削除し、このアプリが記録した適用状態をリセットします。バックアップは作成されず、元に戻せません。',
'agents.clear.description': 'Codex 設定ディレクトリの auth.json と config.toml を完全に削除します。操作前に履歴を保存します。履歴から復元できるのは管理対象フィールドのみで、削除された独自設定は復元されません。',
'agents.clear.confirm': '設定を消去',
'agents.clear.success': 'Codex 設定を消去しました。',
'apiAccess.compatibleName': 'OpenAI 互換 {number}',
+25 -4
View File
@@ -26,9 +26,8 @@ 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.client': 'Context source: the CPA core Codex client model catalog 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)',
@@ -810,6 +809,28 @@ export const en: Record<MessageKey, string> = {
'agents.status.invalid': 'Invalid Configuration',
'agents.status.applied': 'Applied',
'agents.status.unconfigured': 'Not Configured',
"agents.history.button": "Restore configuration history",
"agents.history.description": "Restore only software-managed fields and preserve other settings. Current configuration is backed up first; form drafts refresh after restoring.",
"agents.history.versions": "Versions",
"agents.history.empty": "No history yet. Updating configuration saves versions automatically.",
"agents.history.files": "{count} files",
"agents.history.preview": "Restore preview",
"agents.history.loading": "Loading…",
"agents.history.select": "Select a version to preview the fields to restore.",
"agents.history.unchanged": "Configuration is already up to date.",
"agents.history.updated": "Configuration updated and history saved.",
"agents.history.restored": "Configuration restored. Other custom settings were preserved.",
"agents.history.field": "Field",
"agents.history.current": "Current",
"agents.history.target": "Historical",
"agents.history.restore": "Restore this version",
"agents.history.before": "Before {source}",
"agents.history.source.update": "Update",
"agents.history.source.default": "Default configuration",
"agents.history.source.clear": "Clear configuration",
"agents.history.source.restore": "Restore",
"agents.history.source.sync": "Automatic sync",
"agents.history.source.legacy": "Legacy backup",
'agents.title': 'Agent Configuration',
'agents.embedded.title': 'Select a client and model, then start',
'agents.embedded.subtitle': 'Install it first if it is not available',
@@ -937,10 +958,10 @@ export const en: Record<MessageKey, string> = {
'agents.pi.uninstall': 'Uninstall Pi Plugin',
'agents.pi.uninstalling': 'Uninstalling plugin',
'agents.default.title': 'Reset to Default Configuration',
'agents.default.description': 'This rewrites every related {name} configuration file using the selected model and deletes all other user content in those files. No backup is created and the operation cannot be undone.',
'agents.default.description': 'This rewrites every related {name} configuration file using the selected model and deletes all other user content in those files. A history snapshot is saved first. History recovery restores only managed fields, not deleted custom settings.',
'agents.default.confirm': 'Write Default Configuration',
'agents.clear.title': 'Clear Codex Configuration',
'agents.clear.description': 'This permanently deletes auth.json and config.toml from the Codex configuration directory and resets the application state tracked by this app. No backup is created and the operation cannot be undone.',
'agents.clear.description': 'This permanently deletes auth.json and config.toml from the Codex configuration directory. A history snapshot is saved first. History recovery restores only managed fields, not deleted custom settings.',
'agents.clear.confirm': 'Clear Configuration',
'agents.clear.success': 'The Codex configuration has been cleared.',
'apiAccess.compatibleName': 'OpenAI Compatible {number}',
+25 -4
View File
@@ -25,9 +25,8 @@ export const zhCN = {
'agents.catalog.displayName': '显示名称',
'agents.catalog.description': '模型说明',
'agents.catalog.context': '上下文窗口(tokens)',
'agents.catalog.contextSource.definition': '上下文来源:CPA 内核的模型定义 API。',
'agents.catalog.contextSource.client': '上下文来源:CPA 内核的 Codex 客户端模型列表 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)',
@@ -809,6 +808,28 @@ export const zhCN = {
'agents.status.invalid': '配置无效',
'agents.status.applied': '已应用',
'agents.status.unconfigured': '尚未配置',
"agents.history.button": "恢复历史配置",
"agents.history.description": "仅恢复软件管理的配置字段,保留其他自定义设置。恢复前会自动备份当前配置;当前表单草稿将按恢复结果刷新。",
"agents.history.versions": "历史版本",
"agents.history.empty": "暂无配置历史。更新配置后会自动保留版本。",
"agents.history.files": "{count} 个文件",
"agents.history.preview": "恢复变更预览",
"agents.history.loading": "正在读取…",
"agents.history.select": "选择一个历史版本,查看将恢复的字段。",
"agents.history.unchanged": "配置已是最新,无需更新。",
"agents.history.updated": "配置已更新,历史备份已保存。",
"agents.history.restored": "已恢复历史配置,其他自定义设置已保留。",
"agents.history.field": "配置字段",
"agents.history.current": "当前值",
"agents.history.target": "历史值",
"agents.history.restore": "恢复此版本",
"agents.history.before": "{source}前备份",
"agents.history.source.update": "更新配置",
"agents.history.source.default": "默认配置",
"agents.history.source.clear": "清空配置",
"agents.history.source.restore": "恢复配置",
"agents.history.source.sync": "自动同步",
"agents.history.source.legacy": "旧版备份",
'agents.title': '智能体配置',
'agents.embedded.title': '选择客户端和模型后启动',
'agents.embedded.subtitle': '如果没有客户端请自行安装',
@@ -936,10 +957,10 @@ export const zhCN = {
'agents.pi.uninstall': '卸载 Pi 插件',
'agents.pi.uninstalling': '正在卸载插件',
'agents.default.title': '重置为默认配置',
'agents.default.description': '这会使用当前选中模型重写 {name} 的全部相关配置文件,并删除其中其他用户内容。该操作没有备份且无法恢复。',
'agents.default.description': '这会使用当前选中模型重写 {name} 的全部相关配置文件,并删除其中其他用户内容。操作前会保存历史备份;历史恢复仅恢复软件管理的字段,不能恢复被清除的其他自定义内容。',
'agents.default.confirm': '确认写入默认配置',
'agents.clear.title': '清空 Codex 配置',
'agents.clear.description': '这会永久删除 Codex 配置目录中的 auth.json 和 config.toml,并重置软件记录的应用状态。该操作没有备份且无法恢复。',
'agents.clear.description': '这会永久删除 Codex 配置目录中的 auth.json 和 config.toml。操作前会保存历史备份;历史恢复仅恢复软件管理的字段,不能恢复被清除的其他自定义内容。',
'agents.clear.confirm': '确认清空配置',
'agents.clear.success': 'Codex 配置已清空。',
'apiAccess.compatibleName': 'OpenAI 兼容 {number}',
+1 -1
View File
@@ -25,7 +25,7 @@ export type CodexModelConfiguration = {
export type CodexCatalogEditorModel = {
slug: string;
hasOfficialTemplate: boolean;
contextSource: "definition" | "configuration" | "compatibility" | "template";
contextSource: "client" | "configuration" | "template";
customized: boolean;
configuration: CodexModelConfiguration;
defaults: CodexModelConfiguration;
+6 -6
View File
@@ -28,18 +28,18 @@ const configuration = (): CodexModelConfiguration => ({
});
describe('Codex 模型列表编辑', () => {
test("区分内核模型定义与兼容目录的后备上下文", () => {
test("区分 Codex 客户端 API、模板后备与自定义上下文", () => {
const model: CodexCatalogEditorModel = {
slug: "model-a", hasOfficialTemplate: true, customized: false,
contextSource: "definition", configuration: configuration(), defaults: configuration(),
contextSource: "client", configuration: configuration(), defaults: configuration(),
};
expect(codexContextSourceHint(model)).toBe("agents.catalog.contextSource.definition");
expect(codexContextSourceHint({ ...model, contextSource: "compatibility" }))
.toBe("agents.catalog.contextSource.compatibility");
expect(codexContextSourceHint(model)).toBe("agents.catalog.contextSource.client");
expect(codexContextSourceHint({ ...model, contextSource: "template" }))
.toBe("agents.catalog.contextSource.template");
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");
expect(codexContextSourceHint(model)).toBe("agents.catalog.contextSource.client");
});
test('克隆配置时隔离数组字段', () => {