diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index dfadb5609..230bf249e 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -6,12 +6,15 @@ use crate::config::{ write_text_file, }; use crate::error::AppError; -use serde_json::Value; +use serde_json::{json, Value}; use std::fs; use std::path::Path; +use std::process::Command; use toml_edit::DocumentMut; pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch"; +pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json"; +const CODEX_MODEL_CATALOG_TEMPLATE_SLUG: &str = "gpt-5.5"; /// Reserved built-in provider IDs from OpenAI Codex's config/model-provider /// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy @@ -44,6 +47,10 @@ pub fn get_codex_config_path() -> PathBuf { get_codex_config_dir().join("config.toml") } +pub fn get_codex_model_catalog_path() -> PathBuf { + get_codex_config_dir().join(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME) +} + /// 获取 Codex 供应商配置文件路径 #[allow(dead_code)] pub fn get_codex_provider_paths( @@ -413,6 +420,264 @@ pub fn write_codex_live_atomic_with_stable_provider( } } +fn parse_codex_positive_u64(value: Option<&Value>) -> Option { + match value { + Some(Value::Number(n)) => n.as_u64().filter(|v| *v > 0), + Some(Value::String(s)) => s.trim().parse::().ok().filter(|v| *v > 0), + _ => None, + } +} + +fn extract_codex_top_level_u64(config_text: &str, field: &str) -> Option { + let doc = config_text.parse::().ok()?; + doc.get(field) + .and_then(|value| value.as_integer()) + .and_then(|value| u64::try_from(value).ok()) + .filter(|value| *value > 0) +} + +fn codex_catalog_model_entry( + template: &Value, + model: &str, + display_name: &str, + context_window: u64, + priority: usize, +) -> Value { + let mut entry = template.clone(); + let Some(entry_obj) = entry.as_object_mut() else { + return json!({}); + }; + + entry_obj.insert("slug".to_string(), json!(model)); + entry_obj.insert("display_name".to_string(), json!(display_name)); + entry_obj.insert("description".to_string(), json!(display_name)); + entry_obj.insert("context_window".to_string(), json!(context_window)); + entry_obj.insert("max_context_window".to_string(), json!(context_window)); + entry_obj.insert("priority".to_string(), json!(1000 + priority)); + entry_obj.insert("additional_speed_tiers".to_string(), json!([])); + entry_obj.insert("service_tiers".to_string(), json!([])); + entry_obj.insert("availability_nux".to_string(), Value::Null); + entry_obj.insert("upgrade".to_string(), Value::Null); + + entry +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CodexCatalogModelSpec { + model: String, + display_name: String, + context_window: u64, +} + +fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec { + let Some(models) = settings + .get("modelCatalog") + .and_then(|catalog| catalog.get("models")) + .and_then(|models| models.as_array()) + else { + return Vec::new(); + }; + + let default_context_window = + extract_codex_top_level_u64(config_text, "model_context_window").unwrap_or(128_000); + let mut seen = std::collections::HashSet::new(); + let mut specs = Vec::new(); + + for model_config in models { + let Some(model) = model_config + .get("model") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|model| !model.is_empty()) + else { + continue; + }; + + if !seen.insert(model.to_string()) { + continue; + } + + let display_name = model_config + .get("displayName") + .or_else(|| model_config.get("display_name")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(model); + let context_window = parse_codex_positive_u64( + model_config + .get("contextWindow") + .or_else(|| model_config.get("context_window")), + ) + .unwrap_or(default_context_window); + + specs.push(CodexCatalogModelSpec { + model: model.to_string(), + display_name: display_name.to_string(), + context_window, + }); + } + + specs +} + +fn find_codex_model_template(catalog: &Value) -> Option { + catalog + .get("models") + .and_then(|models| models.as_array()) + .and_then(|models| { + models.iter().find(|model| { + model.get("slug").and_then(|slug| slug.as_str()) + == Some(CODEX_MODEL_CATALOG_TEMPLATE_SLUG) + }) + }) + .cloned() +} + +fn load_codex_model_template_from_cache() -> Result, AppError> { + let path = get_codex_config_dir().join("models_cache.json"); + if !path.exists() { + return Ok(None); + } + + let text = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + let catalog: Value = serde_json::from_str(&text).map_err(|e| AppError::json(&path, e))?; + Ok(find_codex_model_template(&catalog)) +} + +fn load_codex_model_template_from_bundled() -> Result, AppError> { + let output = match Command::new("codex") + .args(["debug", "models", "--bundled"]) + .output() + { + Ok(output) => output, + Err(err) => { + log::debug!("failed to run `codex debug models --bundled`: {err}"); + return Ok(None); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + log::debug!("`codex debug models --bundled` failed: {stderr}"); + return Ok(None); + } + + let catalog: Value = serde_json::from_slice(&output.stdout).map_err(|e| { + AppError::Message(format!( + "Failed to parse `codex debug models --bundled` output: {e}" + )) + })?; + Ok(find_codex_model_template(&catalog)) +} + +fn load_codex_model_catalog_template() -> Result { + if let Some(template) = load_codex_model_template_from_cache()? { + return Ok(template); + } + if let Some(template) = load_codex_model_template_from_bundled()? { + return Ok(template); + } + + Err(AppError::Message(format!( + "Codex model catalog template `{CODEX_MODEL_CATALOG_TEMPLATE_SLUG}` not found. Please start Codex once so models_cache.json is available, or ensure the `codex` CLI is on PATH." + ))) +} + +fn codex_model_catalog_from_specs(specs: &[CodexCatalogModelSpec], template: &Value) -> Value { + let entries: Vec = specs + .iter() + .enumerate() + .map(|(index, spec)| { + codex_catalog_model_entry( + template, + &spec.model, + &spec.display_name, + spec.context_window, + index, + ) + }) + .collect(); + + json!({ "models": entries }) +} + +fn codex_model_catalog_from_settings( + settings: &Value, + config_text: &str, +) -> Result, AppError> { + let specs = codex_catalog_model_specs(settings, config_text); + if specs.is_empty() { + return Ok(None); + } + + let template = load_codex_model_catalog_template()?; + Ok(Some(codex_model_catalog_from_specs(&specs, &template))) +} + +fn set_codex_model_catalog_json_field( + config_text: &str, + catalog_path: Option<&Path>, +) -> Result { + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + let generated_path = get_codex_model_catalog_path(); + + match catalog_path { + Some(path) => { + doc["model_catalog_json"] = toml_edit::value(path.to_string_lossy().as_ref()); + } + None => { + let should_remove = doc + .get("model_catalog_json") + .and_then(|item| item.as_str()) + .map(|path| { + path == generated_path.to_string_lossy().as_ref() + || Path::new(path).file_name().and_then(|name| name.to_str()) + == Some(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME) + }) + .unwrap_or(false); + if should_remove { + doc.as_table_mut().remove("model_catalog_json"); + } + } + } + + Ok(doc.to_string()) +} + +/// Generate Codex `model_catalog_json` from provider settings and inject/remove +/// the top-level TOML field that points Codex to the generated file. +pub fn prepare_codex_config_text_with_model_catalog( + settings: &Value, + config_text: &str, +) -> Result { + let catalog_path = get_codex_model_catalog_path(); + + if let Some(catalog) = codex_model_catalog_from_settings(settings, config_text)? { + let config_text = set_codex_model_catalog_json_field(config_text, Some(&catalog_path))?; + write_json_file(&catalog_path, &catalog)?; + Ok(config_text) + } else { + set_codex_model_catalog_json_field(config_text, None) + } +} + +/// Unified helper: write Codex live config with model catalog preparation. +/// Replaces scattered `prepare_codex_config_text_with_model_catalog` calls. +pub fn write_codex_live_with_catalog( + settings: &Value, + auth: &Value, + config_text: Option<&str>, +) -> Result<(), AppError> { + let prepared_config = config_text + .map(|text| prepare_codex_config_text_with_model_catalog(settings, text)) + .transpose()?; + + write_codex_live_atomic_with_stable_provider(auth, prepared_config.as_deref()) +} + /// Update a field in Codex config.toml using toml_edit (syntax-preserving). /// /// Supported fields: @@ -420,7 +685,7 @@ pub fn write_codex_live_atomic_with_stable_provider( /// otherwise falls back to top-level `base_url`. /// - `"wire_api"`: writes to `[model_providers.].wire_api` if `model_provider` exists, /// otherwise falls back to top-level `wire_api`. -/// - `"model"`: writes to top-level `model` field. +/// - `"model"` / `"model_catalog_json"`: writes to top-level field. /// /// Empty value removes the field. pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Result { @@ -467,11 +732,11 @@ pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Resu doc[field] = toml_edit::value(trimmed); } } - "model" => { + "model" | "model_catalog_json" => { if trimmed.is_empty() { - doc.as_table_mut().remove("model"); + doc.as_table_mut().remove(field); } else { - doc["model"] = toml_edit::value(trimmed); + doc[field] = toml_edit::value(trimmed); } } _ => return Err(format!("unsupported field: {field}")), @@ -1010,4 +1275,130 @@ base_url = "https://production.api/v1" .and_then(|v| v.as_str()); assert_eq!(base_url, Some("https://production.api/v1")); } + + #[test] + fn codex_model_catalog_uses_provider_models_and_context() { + let template = json!({ + "slug": "gpt-5.5", + "display_name": "GPT-5.5", + "description": "Frontier model", + "base_instructions": "gpt-5.5 base instructions", + "model_messages": { + "instructions_template": "gpt-5.5 instructions template", + "instructions_variables": { + "personality_default": "", + "personality_friendly": "", + "personality_pragmatic": "" + } + }, + "additional_speed_tiers": ["fast"], + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "1.5x speed, increased usage" + } + ], + "availability_nux": { + "message": "GPT-5.5 is now available." + }, + "upgrade": { + "target": "gpt-5.5" + }, + "context_window": 272000, + "max_context_window": 272000 + }); + let settings = json!({ + "modelCatalog": { + "models": [ + { + "model": "deepseek-v4-flash", + "displayName": "DeepSeek V4 Flash", + "contextWindow": "64000" + }, + { + "model": "kimi-k2", + "display_name": "Kimi K2" + } + ] + } + }); + let specs = codex_catalog_model_specs(&settings, r#"model_context_window = 128000"#); + let catalog = codex_model_catalog_from_specs(&specs, &template); + let models = catalog + .get("models") + .and_then(|value| value.as_array()) + .expect("models should be an array"); + + assert_eq!(models.len(), 2); + assert_eq!( + models[0].get("slug").and_then(|value| value.as_str()), + Some("deepseek-v4-flash") + ); + assert_eq!( + models[0] + .get("context_window") + .and_then(|value| value.as_u64()), + Some(64_000) + ); + assert_eq!( + models[1] + .get("context_window") + .and_then(|value| value.as_u64()), + Some(128_000) + ); + assert!( + models[0].get("model_messages").is_some(), + "Codex requires model_messages in custom catalogs" + ); + assert_eq!( + models[0] + .get("base_instructions") + .and_then(|value| value.as_str()), + Some("gpt-5.5 base instructions") + ); + assert_eq!( + models[0].get("model_messages"), + template.get("model_messages"), + "custom catalog entries should keep the gpt-5.5 agent template" + ); + assert_eq!( + models[0].get("additional_speed_tiers"), + Some(&json!([])), + "generated third-party entries should not inherit OpenAI speed tiers" + ); + assert!( + models[0] + .get("availability_nux") + .is_some_and(|value| value.is_null()), + "generated third-party entries should not inherit GPT-5.5 launch messaging" + ); + } + + #[test] + fn model_catalog_json_field_operates_on_top_level() { + let input = r#"model_provider = "any" + +[model_providers.any] +name = "any" +"#; + let catalog_path = Path::new("/tmp/cc-switch-model-catalog.json"); + + let result = set_codex_model_catalog_json_field(input, Some(catalog_path)).unwrap(); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + assert_eq!( + parsed + .get("model_catalog_json") + .and_then(|value| value.as_str()), + Some("/tmp/cc-switch-model-catalog.json") + ); + assert!( + parsed + .get("model_providers") + .and_then(|value| value.get("any")) + .and_then(|value| value.get("model_catalog_json")) + .is_none(), + "model_catalog_json should stay top-level" + ); + } } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index a56ef57d5..6dbbdb7f7 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -159,7 +159,11 @@ impl ConfigService { } let cfg_text = settings.get("config").and_then(Value::as_str); - crate::codex_config::write_codex_live_atomic_with_stable_provider(auth, cfg_text)?; + crate::codex_config::write_codex_live_with_catalog( + &provider.settings_config, + auth, + cfg_text, + )?; // 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用 // sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空 // MCP 的启用/禁用应通过 McpService::toggle_app 进行 diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 0086b2bb6..1fbc7db59 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -8,9 +8,7 @@ use serde_json::{json, Value}; use toml_edit::{DocumentMut, Item, TableLike}; use crate::app_config::AppType; -use crate::codex_config::{ - get_codex_auth_path, get_codex_config_path, write_codex_live_atomic_with_stable_provider, -}; +use crate::codex_config::{get_codex_auth_path, get_codex_config_path}; use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file}; use crate::database::Database; use crate::error::AppError; @@ -728,11 +726,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re let auth = obj .get("auth") .ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?; - let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| { - AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string()) - })?; + let config_str = obj.get("config").and_then(|v| v.as_str()); - write_codex_live_atomic_with_stable_provider(auth, Some(config_str))?; + crate::codex_config::write_codex_live_with_catalog( + &provider.settings_config, + auth, + config_str, + )?; } AppType::Gemini => { // Delegate to write_gemini_live which handles env file writing correctly diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 434e203bb..2e52a1c91 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -320,6 +320,7 @@ impl ProxyService { Some(provider), ); effective_settings["config"] = json!(updated_config); + Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider)); self.write_codex_live(&effective_settings)?; Ok(()) @@ -1148,6 +1149,10 @@ impl ProxyService { codex_provider.as_ref(), ); live_config["config"] = json!(updated_config); + Self::attach_codex_model_catalog_from_provider( + &mut live_config, + codex_provider.as_ref(), + ); self.write_codex_live(&live_config)?; log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}"); @@ -1209,6 +1214,10 @@ impl ProxyService { codex_provider.as_ref(), ); live_config["config"] = json!(updated_config); + Self::attach_codex_model_catalog_from_provider( + &mut live_config, + codex_provider.as_ref(), + ); self.write_codex_live(&live_config)?; log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}"); @@ -1283,6 +1292,10 @@ impl ProxyService { codex_provider.as_ref(), ); live_config["config"] = json!(updated_config); + Self::attach_codex_model_catalog_from_provider( + &mut live_config, + codex_provider.as_ref(), + ); let _ = self.write_codex_live(&live_config); } @@ -1930,17 +1943,7 @@ impl ProxyService { crate::codex_config::update_codex_toml_field(&updated, "wire_api", "responses") .unwrap_or(updated); - if provider - .map(crate::proxy::providers::codex_provider_uses_chat_completions) - .unwrap_or(false) - { - updated = crate::codex_config::update_codex_toml_field( - &updated, - "model", - crate::proxy::providers::CODEX_CHAT_CLIENT_MODEL, - ) - .unwrap_or(updated); - } else if let Some(upstream_model) = + if let Some(upstream_model) = provider.and_then(crate::proxy::providers::codex_provider_upstream_model) { updated = @@ -1951,6 +1954,25 @@ impl ProxyService { updated } + fn attach_codex_model_catalog_from_provider( + live_config: &mut Value, + provider: Option<&Provider>, + ) { + let Some(provider) = provider else { + return; + }; + + let model_catalog = provider + .settings_config + .get("modelCatalog") + .cloned() + .unwrap_or_else(|| json!({ "models": [] })); + + if let Some(root) = live_config.as_object_mut() { + root.insert("modelCatalog".to_string(), model_catalog); + } + } + fn read_claude_live(&self) -> Result { let path = get_claude_settings_path(); if !path.exists() { @@ -2014,9 +2036,7 @@ impl ProxyService { } fn write_codex_live(&self, config: &Value) -> Result<(), String> { - use crate::codex_config::{ - get_codex_auth_path, get_codex_config_path, write_codex_live_atomic, - }; + use crate::codex_config::{get_codex_auth_path, get_codex_config_path}; let auth = config.get("auth"); let config_str = config.get("config").and_then(|v| v.as_str()); @@ -2024,8 +2044,11 @@ impl ProxyService { // Proxy restore writes saved live backups verbatim. Provider-driven writes go // through write_live_with_common_config(), which normalizes Codex provider ids. match (auth, config_str) { - (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg)) - .map_err(|e| format!("写入 Codex 配置失败: {e}"))?, + (Some(auth), Some(cfg)) => { + // Use unified helper to prepare catalog if present + crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg)) + .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + } (Some(auth), None) => { let auth_path = get_codex_auth_path(); write_json_file(&auth_path, auth) @@ -2420,7 +2443,7 @@ wire_api = "chat" } #[test] - fn apply_codex_proxy_toml_config_uses_safe_client_model_for_chat_provider() { + fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() { let input = r#" model_provider = "deepseek" model = "deepseek-v4-flash" @@ -2454,7 +2477,7 @@ wire_api = "responses" assert_eq!( parsed.get("model").and_then(|v| v.as_str()), - Some(crate::proxy::providers::CODEX_CHAT_CLIENT_MODEL) + Some("deepseek-v4-flash") ); assert_eq!( parsed @@ -3409,7 +3432,7 @@ requires_openai_auth = true #[tokio::test] #[serial] - async fn hot_switch_codex_chat_provider_uses_safe_model_without_changing_live_provider() { + async fn hot_switch_codex_chat_provider_uses_upstream_model_without_changing_live_provider() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); @@ -3508,7 +3531,7 @@ requires_openai_auth = true ); assert_eq!( parsed_live.get("model").and_then(|v| v.as_str()), - Some(crate::proxy::providers::CODEX_CHAT_CLIENT_MODEL) + Some("deepseek-v4-flash") ); assert_eq!( live.get("auth") diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 2e470768d..1ee22c299 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -1,24 +1,23 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { FormLabel } from "@/components/ui/form"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { toast } from "sonner"; -import { Download, Loader2 } from "lucide-react"; +import { Download, Loader2, Plus, Trash2 } from "lucide-react"; import EndpointSpeedTest from "./EndpointSpeedTest"; -import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared"; +import { ApiKeySection, EndpointField, ModelDropdown } from "./shared"; import { fetchModelsForConfig, showFetchModelsError, type FetchedModel, } from "@/lib/api/model-fetch"; -import type { CodexApiFormat, ProviderCategory } from "@/types"; +import type { + CodexApiFormat, + CodexCatalogModel, + ProviderCategory, +} from "@/types"; interface EndpointCandidate { url: string; @@ -48,18 +47,46 @@ interface CodexFormFieldsProps { onAutoSelectChange: (checked: boolean) => void; // API Format + // Note: wire_api is always "responses" for Codex; apiFormat controls proxy-layer conversion apiFormat: CodexApiFormat; onApiFormatChange: (format: CodexApiFormat) => void; - // Model Name - shouldShowModelField?: boolean; - modelName?: string; - onModelNameChange?: (model: string) => void; + // Model Catalog + catalogModels?: CodexCatalogModel[]; + onCatalogModelsChange?: (models: CodexCatalogModel[]) => void; // Speed Test Endpoints speedTestEndpoints: EndpointCandidate[]; } +type CodexCatalogRow = CodexCatalogModel & { rowId: string }; + +function createCatalogRow(seed?: Partial): CodexCatalogRow { + return { + rowId: crypto.randomUUID(), + model: seed?.model ?? "", + displayName: seed?.displayName ?? "", + contextWindow: seed?.contextWindow ?? "", + }; +} + +// Compares rows (with rowId) to incoming models (without) by data fields only, +// so both sync effects can use the same equality definition. +function catalogRowsMatchModels( + rows: Array>, + models: CodexCatalogModel[], +): boolean { + if (rows.length !== models.length) return false; + return rows.every((row, i) => { + const incoming = models[i]; + return ( + row.model === (incoming.model ?? "") && + (row.displayName ?? "") === (incoming.displayName ?? "") && + String(row.contextWindow ?? "") === String(incoming.contextWindow ?? "") + ); + }); +} + export function CodexFormFields({ providerId, codexApiKey, @@ -81,16 +108,46 @@ export function CodexFormFields({ onAutoSelectChange, apiFormat, onApiFormatChange, - shouldShowModelField = true, - modelName = "", - onModelNameChange, + catalogModels = [], + onCatalogModelsChange, speedTestEndpoints, }: CodexFormFieldsProps) { const { t } = useTranslation(); const [fetchedModels, setFetchedModels] = useState([]); const [isFetchingModels, setIsFetchingModels] = useState(false); - const isChatApiFormat = apiFormat === "openai_chat"; + const needsLocalRouting = apiFormat === "openai_chat"; + const canEditCatalog = Boolean(onCatalogModelsChange); + + const [catalogRows, setCatalogRows] = useState(() => + catalogModels.map((m) => createCatalogRow(m)), + ); + + // 父 → 子:仅当 prop 数据真的变化(预设切换 / 编辑加载)时才重建 rowId; + // 同 shape 时保留现有 rowId,避免编辑过程中焦点丢失。 + useEffect(() => { + setCatalogRows((current) => { + if (catalogRowsMatchModels(current, catalogModels)) return current; + return catalogModels.map((m) => createCatalogRow(m)); + }); + }, [catalogModels]); + + // 子 → 父:rowId 是视图层概念,不应进入持久化数据;剥离后再回传。 + useEffect(() => { + if (!onCatalogModelsChange) return; + if (catalogRowsMatchModels(catalogRows, catalogModels)) return; + const next: CodexCatalogModel[] = catalogRows.map( + ({ rowId: _rowId, ...rest }) => rest, + ); + onCatalogModelsChange(next); + }, [catalogRows, catalogModels, onCatalogModelsChange]); + + const handleLocalRoutingChange = useCallback( + (checked: boolean) => { + onApiFormatChange(checked ? "openai_chat" : "openai_responses"); + }, + [onApiFormatChange], + ); const handleFetchModels = useCallback(() => { if (!codexBaseUrl || !codexApiKey) { @@ -119,6 +176,54 @@ export function CodexFormFields({ .finally(() => setIsFetchingModels(false)); }, [codexBaseUrl, codexApiKey, isFullUrl, t]); + const handleAddCatalogRow = useCallback(() => { + if (!onCatalogModelsChange) return; + setCatalogRows((current) => [...current, createCatalogRow()]); + }, [onCatalogModelsChange]); + + const handleUpdateCatalogRow = useCallback( + (index: number, patch: Partial) => { + setCatalogRows((current) => + current.map((row, i) => (i === index ? { ...row, ...patch } : row)), + ); + }, + [], + ); + + const handleRemoveCatalogRow = useCallback((index: number) => { + setCatalogRows((current) => current.filter((_, i) => i !== index)); + }, []); + + const renderCatalogActionButtons = (onAdd: () => void, addLabel: string) => ( +
+ + +
+ ); + return ( <> {/* Codex API Key 输入框 */} @@ -158,97 +263,168 @@ export function CodexFormFields({ /> )} - {/* Codex API 格式选择 */} {shouldShowSpeedTest && ( -
- - {t("providerForm.apiFormat", { defaultValue: "API 格式" })} - - -

- {t("providerForm.codexApiFormatHint", { - defaultValue: - "选择供应商真实支持的 Codex API 格式;Chat Completions 会通过本地路由自动转换为 Responses。", - })} -

+ +

+ {needsLocalRouting + ? t("codexConfig.localRoutingOnHint", { + defaultValue: + "适合 DeepSeek、Kimi 等自定义模型或仅支持 Chat Completions 的供应商;使用时请保持本地路由开启。", + }) + : t("codexConfig.localRoutingOffHint", { + defaultValue: + "适合供应商原生支持 OpenAI Responses API 且模型在 Codex 中可直接使用的场景,请求可由 Codex 直连供应商。", + })} +

+
+ + )} - {/* Codex Model Name 输入框 */} - {shouldShowModelField && onModelNameChange && ( -
-
- - +
+

+ {t("codexConfig.modelMappingHint", { + defaultValue: + "选择模型角色后,CC Switch 会自动生成 Codex 兼容路由;菜单显示名可以填 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。", + })} +

- onModelNameChange!(v)} - placeholder={t("codexConfig.modelNamePlaceholder", { - defaultValue: "例如: gpt-5.4", - })} - fetchedModels={fetchedModels} - isLoading={isFetchingModels} - /> -

- {isChatApiFormat - ? t("codexConfig.upstreamModelNameHint", { - defaultValue: - "Chat 格式下这里填写真实上游模型;接管时 Codex 本地会使用兼容模型,并由路由映射回该模型。", - }) - : modelName.trim() - ? t("codexConfig.modelNameHint", { - defaultValue: "指定使用的模型,将自动更新到 config.toml 中", - }) - : t("providerForm.modelHint", { - defaultValue: "💡 留空将使用供应商的默认模型", + + {catalogRows.length > 0 && ( +

+ {/* 列头:md+ 显示 */} +
+ + {t("codexConfig.catalogColumnDisplay", { + defaultValue: "菜单显示名", })} -

+
+ + {t("codexConfig.catalogColumnModel", { + defaultValue: "实际请求模型", + })} + + + {t("codexConfig.catalogColumnContext", { + defaultValue: "上下文窗口", + })} + + +
+ + {catalogRows.map((row, index) => ( +
+ + handleUpdateCatalogRow(index, { + displayName: event.target.value, + }) + } + placeholder={t( + "codexConfig.catalogDisplayNamePlaceholder", + { + defaultValue: "例如: DeepSeek V4 Flash", + }, + )} + aria-label={t("codexConfig.catalogColumnDisplay", { + defaultValue: "菜单显示名", + })} + /> +
+ + handleUpdateCatalogRow(index, { + model: event.target.value, + }) + } + placeholder={t("codexConfig.catalogModelPlaceholder", { + defaultValue: "例如: deepseek-v4-flash", + })} + aria-label={t("codexConfig.catalogColumnModel", { + defaultValue: "实际请求模型", + })} + className="flex-1" + /> + {fetchedModels.length > 0 && ( + + handleUpdateCatalogRow(index, { + model: id, + displayName: row.displayName?.trim() + ? row.displayName + : id, + }) + } + /> + )} +
+ + handleUpdateCatalogRow(index, { + contextWindow: event.target.value.replace(/[^\d]/g, ""), + }) + } + placeholder={t("codexConfig.contextWindowPlaceholder", { + defaultValue: "例如: 128000", + })} + aria-label={t("codexConfig.catalogColumnContext", { + defaultValue: "上下文窗口", + })} + /> + +
+ ))} +
+ )} )} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 44ee06c21..ffa0a7967 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -15,6 +15,7 @@ import type { ProviderTestConfig, ClaudeApiFormat, CodexApiFormat, + CodexCatalogModel, ClaudeApiKeyField, } from "@/types"; import { @@ -55,6 +56,7 @@ import { mergeProviderMeta } from "@/utils/providerMetaUtils"; import { extractCodexWireApi, setCodexWireApi, + setCodexModelName as setCodexModelNameInConfig, } from "@/utils/providerConfigUtils"; import { isNonNegativeDecimalString } from "@/types/usage"; import { getCodexCustomTemplate } from "@/config/codexTemplates"; @@ -142,6 +144,36 @@ const codexApiFormatFromWireApi = ( } }; +export const normalizeCodexCatalogModelsForSave = ( + models: CodexCatalogModel[], +): CodexCatalogModel[] => { + const seen = new Set(); + const normalized: CodexCatalogModel[] = []; + + for (const item of models) { + const model = item.model.trim(); + if (!model || seen.has(model)) continue; + seen.add(model); + + const displayName = item.displayName?.trim(); + const rawContextWindow = String(item.contextWindow ?? "").replace( + /[^\d]/g, + "", + ); + const contextWindow = rawContextWindow + ? Number.parseInt(rawContextWindow, 10) + : undefined; + + normalized.push({ + model, + ...(displayName ? { displayName } : {}), + ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}), + }); + } + + return normalized; +}; + export interface ProviderFormProps { appId: AppId; providerId?: string; @@ -440,13 +472,13 @@ function ProviderFormFull({ codexConfig, codexApiKey, codexBaseUrl, - codexModelName, + codexCatalogModels, codexAuthError, setCodexAuth, setCodexConfig, + setCodexCatalogModels, handleCodexApiKeyChange, handleCodexBaseUrlChange, - handleCodexModelNameChange, handleCodexConfigChange: originalHandleCodexConfigChange, resetCodexConfig, } = useCodexConfigState({ initialData }); @@ -476,10 +508,6 @@ function ProviderFormFull({ const handleCodexConfigChange = useCallback( (value: string) => { originalHandleCodexConfigChange(value); - const nextFormat = codexApiFormatFromWireApi(extractCodexWireApi(value)); - if (nextFormat) { - setLocalCodexApiFormat(nextFormat); - } debouncedValidate(value); }, [originalHandleCodexConfigChange, debouncedValidate], @@ -488,6 +516,7 @@ function ProviderFormFull({ const handleCodexApiFormatChange = useCallback( (format: CodexApiFormat) => { setLocalCodexApiFormat(format); + // wire_api is always "responses" for Codex; format controls proxy-layer conversion setCodexConfig((prev) => { const updated = setCodexWireApi(prev, "responses"); debouncedValidate(updated); @@ -1109,14 +1138,32 @@ function ProviderFormFull({ if (appId === "codex") { try { const authJson = JSON.parse(codexAuth); - const normalizedCodexConfig = + let normalizedCodexConfig = category !== "official" && (codexConfig ?? "").trim() ? setCodexWireApi(codexConfig ?? "", "responses") : (codexConfig ?? ""); + const normalizedCatalogModels = + category !== "official" && localCodexApiFormat === "openai_chat" + ? normalizeCodexCatalogModelsForSave(codexCatalogModels) + : []; + // Sync first catalog row's model into config.toml so Codex uses it as default + if (normalizedCatalogModels.length > 0) { + normalizedCodexConfig = setCodexModelNameInConfig( + normalizedCodexConfig, + normalizedCatalogModels[0].model, + ); + } const configObj = { auth: authJson, config: normalizedCodexConfig, + } as { + auth: unknown; + config: string; + modelCatalog?: { models: CodexCatalogModel[] }; }; + if (normalizedCatalogModels.length > 0) { + configObj.modelCatalog = { models: normalizedCatalogModels }; + } settingsConfig = JSON.stringify(configObj); } catch (err) { settingsConfig = values.settingsConfig.trim(); @@ -1937,9 +1984,8 @@ function ProviderFormFull({ onAutoSelectChange={setEndpointAutoSelect} apiFormat={localCodexApiFormat} onApiFormatChange={handleCodexApiFormatChange} - shouldShowModelField={category !== "official"} - modelName={codexModelName} - onModelNameChange={handleCodexModelNameChange} + catalogModels={codexCatalogModels} + onCatalogModelsChange={setCodexCatalogModels} speedTestEndpoints={speedTestEndpoints} /> )} diff --git a/src/components/providers/forms/hooks/useCodexConfigState.ts b/src/components/providers/forms/hooks/useCodexConfigState.ts index 1747d6c12..f729b2841 100644 --- a/src/components/providers/forms/hooks/useCodexConfigState.ts +++ b/src/components/providers/forms/hooks/useCodexConfigState.ts @@ -2,10 +2,9 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { extractCodexBaseUrl, setCodexBaseUrl as setCodexBaseUrlInConfig, - extractCodexModelName, - setCodexModelName as setCodexModelNameInConfig, } from "@/utils/providerConfigUtils"; import { normalizeTomlText } from "@/utils/textNormalization"; +import type { CodexCatalogModel } from "@/types"; interface UseCodexConfigStateProps { initialData?: { @@ -22,11 +21,12 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { const [codexConfig, setCodexConfigState] = useState(""); const [codexApiKey, setCodexApiKey] = useState(""); const [codexBaseUrl, setCodexBaseUrl] = useState(""); - const [codexModelName, setCodexModelName] = useState(""); + const [codexCatalogModels, setCodexCatalogModels] = useState< + CodexCatalogModel[] + >([]); const [codexAuthError, setCodexAuthError] = useState(""); const isUpdatingCodexBaseUrlRef = useRef(false); - const isUpdatingCodexModelNameRef = useRef(false); // 初始化 Codex 配置(编辑模式) useEffect(() => { @@ -45,18 +45,38 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { : ""; setCodexConfigState(configStr); + const modelCatalog = (config as any).modelCatalog; + const rawCatalogModels = Array.isArray(modelCatalog?.models) + ? modelCatalog.models + : []; + setCodexCatalogModels( + rawCatalogModels + .map((item: any) => ({ + model: typeof item?.model === "string" ? item.model : "", + displayName: + typeof item?.displayName === "string" + ? item.displayName + : typeof item?.display_name === "string" + ? item.display_name + : "", + contextWindow: + typeof item?.contextWindow === "string" || + typeof item?.contextWindow === "number" + ? item.contextWindow + : typeof item?.context_window === "string" || + typeof item?.context_window === "number" + ? item.context_window + : "", + })) + .filter((item: CodexCatalogModel) => item.model.trim()), + ); + // 提取 Base URL const initialBaseUrl = extractCodexBaseUrl(configStr); if (initialBaseUrl) { setCodexBaseUrl(initialBaseUrl); } - // 提取 Model Name - const initialModelName = extractCodexModelName(configStr); - if (initialModelName) { - setCodexModelName(initialModelName); - } - // 提取 API Key try { if (auth && typeof auth.OPENAI_API_KEY === "string") { @@ -77,15 +97,6 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { setCodexBaseUrl((prev) => (prev === extracted ? prev : extracted)); }, [codexConfig]); - // 与 TOML 配置保持模型名称同步 - useEffect(() => { - if (isUpdatingCodexModelNameRef.current) { - return; - } - const extracted = extractCodexModelName(codexConfig) || ""; - setCodexModelName((prev) => (prev === extracted ? prev : extracted)); - }, [codexConfig]); - // 获取 API Key(从 auth JSON) const getCodexAuthApiKey = useCallback((authString: string): string => { try { @@ -170,22 +181,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { [setCodexConfig], ); - // 处理 Codex Model Name 变化 - const handleCodexModelNameChange = useCallback( - (modelName: string) => { - const trimmed = modelName.trim(); - setCodexModelName(trimmed); - - isUpdatingCodexModelNameRef.current = true; - setCodexConfig((prev) => setCodexModelNameInConfig(prev, trimmed)); - setTimeout(() => { - isUpdatingCodexModelNameRef.current = false; - }, 0); - }, - [setCodexConfig], - ); - - // 处理 config 变化(同步 Base URL 和 Model Name) + // 处理 config 变化(同步 Base URL) const handleCodexConfigChange = useCallback( (value: string) => { // 归一化中文/全角/弯引号,避免 TOML 解析报错 @@ -198,35 +194,24 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { setCodexBaseUrl(extracted); } } - - if (!isUpdatingCodexModelNameRef.current) { - const extractedModel = extractCodexModelName(normalized) || ""; - if (extractedModel !== codexModelName) { - setCodexModelName(extractedModel); - } - } }, - [setCodexConfig, codexBaseUrl, codexModelName], + [setCodexConfig, codexBaseUrl], ); // 重置配置(用于预设切换) const resetCodexConfig = useCallback( - (auth: Record, config: string) => { + ( + auth: Record, + config: string, + modelCatalogModels: CodexCatalogModel[] = [], + ) => { const authString = JSON.stringify(auth, null, 2); setCodexAuth(authString); setCodexConfig(config); + setCodexCatalogModels(modelCatalogModels); const baseUrl = extractCodexBaseUrl(config); - if (baseUrl) { - setCodexBaseUrl(baseUrl); - } - - const modelName = extractCodexModelName(config); - if (modelName) { - setCodexModelName(modelName); - } else { - setCodexModelName(""); - } + setCodexBaseUrl(baseUrl || ""); // 提取 API Key try { @@ -247,13 +232,13 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { codexConfig, codexApiKey, codexBaseUrl, - codexModelName, + codexCatalogModels, codexAuthError, setCodexAuth, setCodexConfig, + setCodexCatalogModels, handleCodexApiKeyChange, handleCodexBaseUrlChange, - handleCodexModelNameChange, handleCodexConfigChange, resetCodexConfig, getCodexAuthApiKey, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 277e573d1..991f7283e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1080,12 +1080,30 @@ "saveFailed": "Save failed: {{error}}", "modelNameHint": "Specify the model to use, will be auto-updated in config.toml", "modelName": "Model Name", + "localRoutingToggle": "Needs Local Routing", + "localRoutingOffHint": "Use this when the provider natively supports OpenAI Responses and the model can be used directly by Codex; Codex can connect to the provider without local routing.", + "localRoutingOnHint": "Use this for custom models such as DeepSeek or Kimi, or providers that only support Chat Completions; keep local routing running while in use.", "upstreamModelName": "Upstream Model Name", - "upstreamModelNameHint": "For Chat format, enter the real upstream model here; takeover writes a Codex-compatible local model and routing maps it back to this model.", + "upstreamModelNameHint": "For Chat format, enter the real upstream model here; routing converts Codex Responses requests to Chat Completions while keeping this model.", "modelNamePlaceholder": "e.g., gpt-5-codex", "contextWindow1M": "1M Context Window", + "contextWindow": "Context Window", + "contextWindowPlaceholder": "e.g., 128000", "autoCompactLimit": "Auto Compact Limit", - "autoCompactLimitHint": "Auto-compacts history when context reaches this token limit" + "autoCompactLimitPlaceholder": "e.g., 90000", + "autoCompactLimitHint": "Auto-compacts history when context reaches this token limit", + "modelMetadataAdvanced": "Model Metadata Advanced Options", + "modelMetadataAdvancedHint": "Overrides Codex metadata for unknown models; clearing a field removes the matching config.toml entry.", + "modelMappingTitle": "Model Mapping", + "modelMappingHint": "Generates Codex model_catalog_json so /model can show these third-party model names; entries are saved exactly as listed. Codex must be restarted to refresh the model list after changes.", + "addCatalogModel": "Add Model", + "catalogDisplayName": "Display Name", + "catalogDisplayNamePlaceholder": "e.g., DeepSeek V4 Flash", + "catalogModelId": "Model ID", + "catalogModelPlaceholder": "e.g., deepseek-v4-flash", + "catalogColumnDisplay": "Menu Display Name", + "catalogColumnModel": "Actual Request Model", + "catalogColumnContext": "Context Window" }, "geminiConfig": { "envFile": "Environment Variables (.env)", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2573350ad..fd040a271 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1080,12 +1080,30 @@ "saveFailed": "保存に失敗しました: {{error}}", "modelNameHint": "使用するモデルを指定します。config.toml に自動更新されます", "modelName": "モデル名", + "localRoutingToggle": "ローカルルーティングが必要", + "localRoutingOffHint": "プロバイダーが OpenAI Responses API をネイティブにサポートし、Codex がモデルを直接使用できる場合に適しています。Codex はローカルルーティングなしで接続できます。", + "localRoutingOnHint": "DeepSeek、Kimi などのカスタムモデル、または Chat Completions のみをサポートするプロバイダー向けです。利用中はローカルルーティングを起動したままにしてください。", "upstreamModelName": "上流モデル名", - "upstreamModelNameHint": "Chat 形式ではここに実際の上流モデルを入力します。接管時は Codex 互換のローカルモデルを書き込み、ルーティングでこのモデルに戻します。", + "upstreamModelNameHint": "Chat 形式ではここに実際の上流モデルを入力します。ルーティングで Codex の Responses リクエストを Chat Completions に変換し、このモデルを維持します。", "modelNamePlaceholder": "例: gpt-5-codex", "contextWindow1M": "1M コンテキストウィンドウ", + "contextWindow": "コンテキストウィンドウ", + "contextWindowPlaceholder": "例: 128000", "autoCompactLimit": "自動圧縮しきい値", - "autoCompactLimitHint": "コンテキストトークン数がこのしきい値に達すると履歴を自動圧縮" + "autoCompactLimitPlaceholder": "例: 90000", + "autoCompactLimitHint": "コンテキストトークン数がこのしきい値に達すると履歴を自動圧縮", + "modelMetadataAdvanced": "モデルメタデータ詳細オプション", + "modelMetadataAdvancedHint": "未知のモデル向けに Codex メタデータを上書きします。空にすると対応する config.toml 設定を削除します。", + "modelMappingTitle": "モデルマッピング", + "modelMappingHint": "Codex model_catalog_json を生成し、/model コマンドでこれらの第三者モデル名を表示できるようにします。リストの内容はそのまま保存されます。変更後、モデルリストを更新するには Codex の再起動が必要です。", + "addCatalogModel": "モデルを追加", + "catalogDisplayName": "表示名", + "catalogDisplayNamePlaceholder": "例: DeepSeek V4 Flash", + "catalogModelId": "モデル ID", + "catalogModelPlaceholder": "例: deepseek-v4-flash", + "catalogColumnDisplay": "メニュー表示名", + "catalogColumnModel": "実際のリクエストモデル", + "catalogColumnContext": "コンテキストウィンドウ" }, "geminiConfig": { "envFile": "環境変数 (.env)", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 93dff6e1a..65b3fb5a2 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1080,12 +1080,30 @@ "saveFailed": "保存失败: {{error}}", "modelNameHint": "指定使用的模型,将自动更新到 config.toml 中", "modelName": "模型名称", + "localRoutingToggle": "需要本地路由映射", + "localRoutingOffHint": "适合供应商原生支持 OpenAI Responses API 且模型在 Codex 中可直接使用的场景,请求可由 Codex 直连供应商。", + "localRoutingOnHint": "适合 DeepSeek、Kimi 等自定义模型或仅支持 Chat Completions 的供应商;使用时请保持本地路由开启。", "upstreamModelName": "上游模型名称", - "upstreamModelNameHint": "Chat 格式下这里填写真实上游模型;接管时 Codex 本地会使用兼容模型,并由路由映射回该模型。", + "upstreamModelNameHint": "Chat 格式下这里填写真实上游模型;路由会把 Codex 的 Responses 请求转换为 Chat Completions 并保持该模型。", "modelNamePlaceholder": "例如: gpt-5-codex", "contextWindow1M": "1M 上下文窗口", + "contextWindow": "上下文窗口", + "contextWindowPlaceholder": "例如: 128000", "autoCompactLimit": "压缩阈值", - "autoCompactLimitHint": "上下文 token 数达到此阈值时自动压缩历史" + "autoCompactLimitPlaceholder": "例如: 90000", + "autoCompactLimitHint": "上下文 token 数达到此阈值时自动压缩历史", + "modelMetadataAdvanced": "模型元数据高级选项", + "modelMetadataAdvancedHint": "用于未知模型的 Codex 元数据覆盖;清空字段会从 config.toml 删除对应配置。", + "modelMappingTitle": "模型映射", + "modelMappingHint": "生成 Codex model_catalog_json,让 /model 命令显示这些第三方模型名;表中条目按填写内容原样保存。修改后需要重启 Codex 才能刷新模型列表。", + "addCatalogModel": "添加模型", + "catalogDisplayName": "显示名称", + "catalogDisplayNamePlaceholder": "例如: DeepSeek V4 Flash", + "catalogModelId": "模型 ID", + "catalogModelPlaceholder": "例如: deepseek-v4-flash", + "catalogColumnDisplay": "菜单显示名", + "catalogColumnModel": "实际请求模型", + "catalogColumnContext": "上下文窗口" }, "geminiConfig": { "envFile": "环境变量 (.env)", diff --git a/src/types.ts b/src/types.ts index 92958463d..48ac9b973 100644 --- a/src/types.ts +++ b/src/types.ts @@ -209,6 +209,12 @@ export type ClaudeApiFormat = // - "openai_chat": OpenAI Chat Completions 格式,需要本地路由转换 export type CodexApiFormat = "openai_responses" | "openai_chat"; +export interface CodexCatalogModel { + model: string; + displayName?: string; + contextWindow?: string | number; +} + // Claude 认证字段类型 export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY"; diff --git a/tests/components/ProviderForm.codexCatalog.test.ts b/tests/components/ProviderForm.codexCatalog.test.ts new file mode 100644 index 000000000..9154a6686 --- /dev/null +++ b/tests/components/ProviderForm.codexCatalog.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { normalizeCodexCatalogModelsForSave } from "@/components/providers/forms/ProviderForm"; + +describe("ProviderForm Codex catalog helpers", () => { + it("normalizes catalog rows and removes empty or duplicate models", () => { + expect( + normalizeCodexCatalogModelsForSave([ + { model: " deepseek-v4-flash ", displayName: " DeepSeek " }, + { model: "deepseek-v4-flash", displayName: "Duplicate" }, + { model: "", displayName: "Empty" }, + { model: "kimi-k2", contextWindow: "128000 tokens" }, + ]), + ).toEqual([ + { model: "deepseek-v4-flash", displayName: "DeepSeek" }, + { model: "kimi-k2", contextWindow: 128000 }, + ]); + }); +}); diff --git a/tests/utils/providerConfigUtils.codex.test.ts b/tests/utils/providerConfigUtils.codex.test.ts index 28e741abc..372bb3360 100644 --- a/tests/utils/providerConfigUtils.codex.test.ts +++ b/tests/utils/providerConfigUtils.codex.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest"; import { extractCodexBaseUrl, extractCodexModelName, + extractCodexTopLevelInt, + removeCodexTopLevelField, setCodexBaseUrl, setCodexModelName, + setCodexTopLevelInt, } from "@/utils/providerConfigUtils"; describe("Codex TOML utils", () => { @@ -148,4 +151,50 @@ describe("Codex TOML utils", () => { expect(extractCodexBaseUrl(input)).toBe("https://api.example.com/v1"); expect(extractCodexModelName(input)).toBe("gpt-5"); }); + + it("reads, writes, and removes top-level integer metadata fields", () => { + const input = [ + 'model_provider = "custom"', + 'model = "deepseek-v4-flash"', + "", + "[model_providers.custom]", + 'name = "DeepSeek"', + "", + ].join("\n"); + + const withContext = setCodexTopLevelInt( + input, + "model_context_window", + 128000, + ); + const withCompact = setCodexTopLevelInt( + withContext, + "model_auto_compact_token_limit", + 90000, + ); + + expect(extractCodexTopLevelInt(withCompact, "model_context_window")).toBe( + 128000, + ); + expect( + extractCodexTopLevelInt( + withCompact, + "model_auto_compact_token_limit", + ), + ).toBe(90000); + expect(withCompact).toMatch(/^model_context_window = 128000$/m); + expect(withCompact).toMatch( + /^model_auto_compact_token_limit = 90000$/m, + ); + + const removed = removeCodexTopLevelField( + withCompact, + "model_context_window", + ); + + expect( + extractCodexTopLevelInt(removed, "model_context_window"), + ).toBeUndefined(); + expect(removed).toContain("[model_providers.custom]"); + }); });