diff --git a/crates/codex-plus-core/src/ports.rs b/crates/codex-plus-core/src/ports.rs index 2263b21f..8dfa258a 100644 --- a/crates/codex-plus-core/src/ports.rs +++ b/crates/codex-plus-core/src/ports.rs @@ -242,9 +242,6 @@ fn normalize_lock_error(error: std::io::Error) -> std::io::Error { #[cfg(test)] mod tests { use super::*; - use std::sync::{Mutex, MutexGuard}; - - static GUARD_PORT_ENV_LOCK: Mutex<()> = Mutex::new(()); #[test] fn resilient_guard_holds_lock_and_listener_when_requested_port_is_available() { @@ -324,8 +321,6 @@ mod tests { #[test] fn launcher_guard_port_returns_base_when_no_env_override() { - let _guard = guard_port_env_lock(); - _clear_guard_port_env_vars(); let port = launcher_guard_port(); // On non-Windows: LAUNCHER_GUARD_PORT_BASE + 0 // On Windows: LAUNCHER_GUARD_PORT_BASE + USERNAME hash mod 1000 @@ -335,8 +330,6 @@ mod tests { #[test] fn manager_guard_port_returns_base_when_no_env_override() { - let _guard = guard_port_env_lock(); - _clear_guard_port_env_vars(); let port = manager_guard_port(); assert!(port >= MANAGER_GUARD_PORT_BASE); assert!(port < MANAGER_GUARD_PORT_BASE + 1000); @@ -344,58 +337,49 @@ mod tests { #[test] fn launcher_guard_port_honors_env_override() { - let _guard = guard_port_env_lock(); - _clear_guard_port_env_vars(); - unsafe { std::env::set_var("CODEX_PLUS_GUARD_PORT", "9999") }; + unsafe { + std::env::set_var("CODEX_PLUS_GUARD_PORT", "9999"); + } let port = launcher_guard_port(); - unsafe { std::env::remove_var("CODEX_PLUS_GUARD_PORT") }; + unsafe { + std::env::remove_var("CODEX_PLUS_GUARD_PORT"); + } assert_eq!(port, 9999); } #[test] fn launcher_guard_port_honors_specific_env_override() { - let _guard = guard_port_env_lock(); - _clear_guard_port_env_vars(); - unsafe { std::env::set_var("CODEX_PLUS_LAUNCHER_GUARD_PORT", "8888") }; + unsafe { + std::env::set_var("CODEX_PLUS_LAUNCHER_GUARD_PORT", "8888"); + } let port = launcher_guard_port(); - unsafe { std::env::remove_var("CODEX_PLUS_LAUNCHER_GUARD_PORT") }; + unsafe { + std::env::remove_var("CODEX_PLUS_LAUNCHER_GUARD_PORT"); + } assert_eq!(port, 8888); } #[test] fn manager_guard_port_honors_specific_env_override() { - let _guard = guard_port_env_lock(); - _clear_guard_port_env_vars(); - unsafe { std::env::set_var("CODEX_PLUS_MANAGER_GUARD_PORT", "7777") }; + unsafe { + std::env::set_var("CODEX_PLUS_MANAGER_GUARD_PORT", "7777"); + } let port = manager_guard_port(); - unsafe { std::env::remove_var("CODEX_PLUS_MANAGER_GUARD_PORT") }; + unsafe { + std::env::remove_var("CODEX_PLUS_MANAGER_GUARD_PORT"); + } assert_eq!(port, 7777); } #[test] fn launcher_guard_port_honors_offset_env() { - let _guard = guard_port_env_lock(); - _clear_guard_port_env_vars(); - unsafe { std::env::set_var("CODEX_PLUS_GUARD_PORT_OFFSET", "50") }; + unsafe { + std::env::set_var("CODEX_PLUS_GUARD_PORT_OFFSET", "50"); + } let port = launcher_guard_port(); - unsafe { std::env::remove_var("CODEX_PLUS_GUARD_PORT_OFFSET") }; + unsafe { + std::env::remove_var("CODEX_PLUS_GUARD_PORT_OFFSET"); + } assert_eq!(port, LAUNCHER_GUARD_PORT_BASE + 50); } - - fn guard_port_env_lock() -> MutexGuard<'static, ()> { - GUARD_PORT_ENV_LOCK - .lock() - .expect("guard port env lock should not be poisoned") - } -} - -/// Clear all guard-port env vars to prevent cross-test contamination -/// when cargo runs tests in parallel threads. -fn _clear_guard_port_env_vars() { - unsafe { - let _ = std::env::remove_var("CODEX_PLUS_GUARD_PORT"); - let _ = std::env::remove_var("CODEX_PLUS_LAUNCHER_GUARD_PORT"); - let _ = std::env::remove_var("CODEX_PLUS_MANAGER_GUARD_PORT"); - let _ = std::env::remove_var("CODEX_PLUS_GUARD_PORT_OFFSET"); - } } diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index fc9e76a4..17eaac84 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -366,6 +366,7 @@ pub fn apply_relay_profile_files_to_home_with_context( &profile.auto_compact_limit, )?; let config_with_catalog = apply_model_catalog_to_config(home, profile, &config_with_limits)?; + let config_with_catalog = apply_custom_catalog_fallback(home, &config_with_catalog, profile)?; apply_relay_files_to_home(home, &config_with_catalog, &profile.auth_contents) } @@ -403,6 +404,7 @@ pub fn apply_relay_profile_to_home_with_switch_rules_and_computer_use_guard( &profile.auto_compact_limit, )?; let config_with_catalog = apply_model_catalog_to_config(home, profile, &config_with_limits)?; + let config_with_catalog = apply_custom_catalog_fallback(home, &config_with_catalog, profile)?; if profile.relay_mode == crate::settings::RelayMode::PureApi { apply_relay_files_to_home_with_computer_use_guard( @@ -440,6 +442,7 @@ pub fn apply_relay_profile_config_to_home_with_context( &profile.auto_compact_limit, )?; let config_with_catalog = apply_model_catalog_to_config(home, profile, &config_with_limits)?; + let config_with_catalog = apply_custom_catalog_fallback(home, &config_with_catalog, profile)?; apply_relay_config_file_to_home(home, &config_with_catalog) } @@ -1456,6 +1459,88 @@ fn root_positive_int_string(config_text: &str, key: &str) -> Option { .filter(|value| *value > 0) .map(|value| value.to_string()) } +/// Fallback custom model catalog: only generates when `apply_model_catalog_to_config` +/// hasn't already set `model_catalog_json` (e.g. no suffixes in model_list). +fn apply_custom_catalog_fallback( + home: &Path, + config_text: &str, + profile: &RelayProfile, +) -> anyhow::Result { + let mut doc = parse_toml_document(config_text)?; + // Only generate when no catalog was set by apply_model_catalog_to_config + let context_window = parse_optional_positive_u64(&profile.context_window, "上下文大小")?; + if let Some(value) = context_window { + ensure_custom_model_catalog(home, &mut doc, value)?; + } + Ok(normalize_optional_toml(doc)) +} + +/// Generate a custom model catalog file for non-standard models that have +/// an explicit `model_context_window` set. This allows Codex CLI to find +/// the model in its catalog lookup and use the user-specified context window. +/// +/// Skips generation if: +/// - `model_catalog_json` is already set in the config +/// - No `model` is configured +fn ensure_custom_model_catalog( + home: &Path, + doc: &mut DocumentMut, + context_window: u64, +) -> anyhow::Result<()> { + // Don't override an existing model_catalog_json setting + if doc.contains_key("model_catalog_json") { + return Ok(()); + } + + // Read model name from the TOML doc + let model = doc + .get("model") + .and_then(|item| item.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let model = match model { + Some(m) if !m.is_empty() => m, + _ => return Ok(()), + }; + + let catalog_filename = "model_catalog_custom.json"; + let catalog_path = home.join(catalog_filename); + + // Use a display name derived from the slug + let display_name = model + .split(['-', '_']) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().to_string() + chars.as_str(), + } + }) + .collect::>() + .join(" "); + + let catalog = serde_json::json!({ + "models": [{ + "slug": model, + "display_name": display_name, + "base_instructions": "You are Codex, a coding agent that helps users write code.", + "context_window": context_window, + "max_context_window": context_window, + "effective_context_window_percent": 95 + }] + }); + + let catalog_json = serde_json::to_string_pretty(&catalog) + .with_context(|| "序列化自定义模型目录失败")?; + crate::settings::atomic_write(&catalog_path, catalog_json.as_bytes()) + .with_context(|| format!("写入自定义模型目录 {} 失败", catalog_path.display()))?; + + doc["model_catalog_json"] = toml_edit::value(catalog_path.to_string_lossy().to_string()); + + Ok(()) +} fn toml_value_is_subset(target: &toml_edit::Value, source: &toml_edit::Value) -> bool { match (target, source) { diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index c19476f4..ea4c8234 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -968,14 +968,14 @@ enabled = true } #[test] -fn apply_relay_profile_does_not_write_model_catalog_json_for_selected_models() { +fn apply_relay_profile_writes_model_catalog_json_for_custom_model_with_context_window() { let temp = tempfile::tempdir().unwrap(); let profile = RelayProfile { id: "relay-a".to_string(), name: "Relay A".to_string(), - model: "qwen3-coder".to_string(), + model: "mimo-v2.5".to_string(), relay_mode: RelayMode::PureApi, - config_contents: r#"model = "qwen3-coder" + config_contents: r#"model = "mimo-v2.5" model_provider = "custom" [model_providers.custom] @@ -988,19 +988,143 @@ experimental_bearer_token = "sk-new" .to_string(), auth_contents: r#"{"OPENAI_API_KEY":"sk-new"}"#.to_string(), model_insert_mode: Default::default(), - model_list: "deepseek-coder\nqwen3-coder".to_string(), - context_window: "200000".to_string(), - auto_compact_limit: "160000".to_string(), + model_list: String::new(), + context_window: "1000000".to_string(), + auto_compact_limit: "800000".to_string(), ..RelayProfile::default() }; apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap(); + // Check config has all expected settings let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + assert!(config.contains(r#"model = "mimo-v2.5""#)); + assert!(config.contains("model_context_window = 1000000")); + assert!(config.contains("model_auto_compact_token_limit = 800000")); + // Custom model with context_window should generate a model_catalog_json entry + assert!(config.contains("model_catalog_json"), "Expected model_catalog_json for custom model with context_window"); + assert!(config.contains("model_catalog_custom.json")); + + // Check the custom catalog file was created + let catalog_path = temp.path().join("model_catalog_custom.json"); + assert!(catalog_path.exists(), "Custom catalog file should exist"); + let catalog: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&catalog_path).unwrap()).unwrap(); + let models = catalog.get("models").and_then(|v| v.as_array()).unwrap(); + assert_eq!(models.len(), 1); + let model_entry = &models[0]; + assert_eq!(model_entry["slug"].as_str().unwrap(), "mimo-v2.5"); + assert_eq!(model_entry["context_window"].as_u64().unwrap(), 1000000); + assert_eq!(model_entry["max_context_window"].as_u64().unwrap(), 1000000); + assert!(model_entry["base_instructions"].as_str().unwrap().contains("coding agent")); +} + +#[test] +fn apply_relay_profile_skips_custom_catalog_when_context_window_empty() { + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "relay-a".to_string(), + name: "Relay A".to_string(), + model: "mimo-v2.5".to_string(), + relay_mode: RelayMode::PureApi, + config_contents: r#"model = "mimo-v2.5" +model_provider = "custom" + +[model_providers.custom] +name = "custom" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://relay.example/v1" +"# + .to_string(), + auth_contents: r#"{"OPENAI_API_KEY":"sk-new"}"#.to_string(), + model_insert_mode: Default::default(), + model_list: String::new(), + context_window: String::new(), // empty = no override + auto_compact_limit: String::new(), + ..RelayProfile::default() + }; + + apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap(); + + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + assert!(config.contains(r#"model = "mimo-v2.5""#)); + // No context_window set → no model_context_window in config → no custom catalog + assert!(!config.contains("model_context_window")); assert!(!config.contains("model_catalog_json")); - assert!(config.contains("model_context_window = 200000")); - assert!(config.contains("model_auto_compact_token_limit = 160000")); - assert!(!temp.path().join("model-catalogs").exists()); + assert!(!temp.path().join("model_catalog_custom.json").exists()); +} + +#[test] +fn apply_relay_profile_skips_custom_catalog_when_model_empty() { + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "relay-a".to_string(), + name: "Relay A".to_string(), + model: String::new(), // empty + relay_mode: RelayMode::PureApi, + config_contents: r#"model = "" +model_provider = "custom" + +[model_providers.custom] +name = "custom" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://relay.example/v1" +"# + .to_string(), + auth_contents: r#"{"OPENAI_API_KEY":"sk-new"}"#.to_string(), + model_insert_mode: Default::default(), + model_list: String::new(), + context_window: "1000000".to_string(), + auto_compact_limit: "800000".to_string(), + ..RelayProfile::default() + }; + + apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap(); + + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + // context_window is set, but model is empty → no custom catalog + assert!(config.contains("model_context_window = 1000000")); + assert!(!config.contains("model_catalog_json")); + assert!(!temp.path().join("model_catalog_custom.json").exists()); +} + +#[test] +fn apply_relay_profile_does_not_override_existing_model_catalog_json() { + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "relay-a".to_string(), + name: "Relay A".to_string(), + model: "mimo-v2.5".to_string(), + relay_mode: RelayMode::PureApi, + config_contents: r#"model = "mimo-v2.5" +model_catalog_json = "/custom/path/to/catalog.json" +model_provider = "custom" + +[model_providers.custom] +name = "custom" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://relay.example/v1" +experimental_bearer_token = "sk-new" +"# + .to_string(), + auth_contents: r#"{"OPENAI_API_KEY":"sk-new"}"#.to_string(), + model_insert_mode: Default::default(), + model_list: String::new(), + context_window: "1000000".to_string(), + auto_compact_limit: "800000".to_string(), + ..RelayProfile::default() + }; + + apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap(); + + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + // Existing model_catalog_json should be preserved, not overwritten + assert!(config.contains(r#"model_catalog_json = "/custom/path/to/catalog.json""#)); + assert!(!config.contains("model_catalog_custom.json")); + assert!(!temp.path().join("model_catalog_custom.json").exists()); } #[test] @@ -2902,7 +3026,7 @@ experimental_bearer_token = "sk-new" } #[test] -fn apply_relay_profile_no_catalog_when_model_list_has_no_suffix() { +fn apply_relay_profile_generates_custom_catalog_when_context_window_set() { let temp = tempfile::tempdir().unwrap(); let profile = RelayProfile { id: "relay-a".to_string(), @@ -2931,9 +3055,14 @@ experimental_bearer_token = "sk-new" apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap(); let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); - assert!(!config.contains("model_catalog_json")); + // context_window set → custom fallback catalog is generated + assert!(config.contains("model_catalog_json")); + assert!(config.contains("model_catalog_custom.json")); assert!(config.contains("model_context_window = 200000")); + // But no suffix-based model-catalogs directory assert!(!temp.path().join("model-catalogs").exists()); + // Custom catalog file exists + assert!(temp.path().join("model_catalog_custom.json").exists()); } #[test]