From d5c2e7f1d8fc6ed477d88bcc44a9bfdda1e5e854 Mon Sep 17 00:00:00 2001 From: lennney <94768569+lennney@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:08:52 +0800 Subject: [PATCH 1/2] feat: pre-compute model catalog for all configured models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following the cc-switch pattern, generate a model_catalog.json for ALL models in profile.model_list during config application, not just those with context window suffixes. Previously, apply_model_catalog_to_config only generated a catalog when at least one model had a suffix like [1M]. Most users don't use suffixes, so Codex fell back to the slow app-server RPC on every startup. Now ensure_full_model_catalog runs after the suffix catalog and generates a complete catalog when none exists yet. Changes: - Add ensure_full_model_catalog() — parses all models from model_list, builds entries via collect_catalog_entries, writes catalog JSON, and sets model_catalog_json in config.toml - No-ops when catalog already set or model_list is empty - Called from all three apply paths after apply_model_catalog_to_config - Update two tests to expect catalog generation --- crates/codex-plus-core/src/relay_config.rs | 48 ++++++++++++++++++++ crates/codex-plus-core/tests/relay_config.rs | 17 ++++--- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 21076b5a9..ed8662768 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 = ensure_full_model_catalog(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 = ensure_full_model_catalog(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 = ensure_full_model_catalog(home, &config_with_catalog, profile)?; apply_relay_config_file_to_home(home, &config_with_catalog) } @@ -1489,6 +1492,51 @@ fn sanitize_catalog_filename(id: &str) -> String { .collect() } +/// Generate a full model catalog from profile.model_list for ALL models, +/// regardless of whether they have context window suffixes. +/// This follows the cc-switch pattern: pre-compute model_catalog_json so +/// Codex can read models without waiting for an app-server RPC round-trip. +/// +/// No-ops when: +/// - model_catalog_json is already set by apply_model_catalog_to_config +/// - model_list is empty +fn ensure_full_model_catalog( + home: &Path, + config_text: &str, + profile: &RelayProfile, +) -> anyhow::Result { + let mut doc = parse_toml_document(config_text)?; + if doc.contains_key("model_catalog_json") { + return Ok(config_text.to_string()); + } + + let model_windows: std::collections::HashMap = + serde_json::from_str(&profile.model_windows).unwrap_or_default(); + let entries = crate::model_suffix::collect_catalog_entries( + &profile.model_list, + &model_windows, + &profile.model, + ); + + if entries.is_empty() { + return Ok(config_text.to_string()); + } + + let fallback = parse_optional_positive_u64(&profile.context_window, "上下文大小")?; + let catalog_relative = format!( + "model-catalogs/{}.json", + sanitize_catalog_filename(&profile.id) + ); + let catalog_path = home.join(&catalog_relative); + if let Some(parent) = catalog_path.parent() { + std::fs::create_dir_all(parent)?; + } + let catalog_json = crate::model_suffix::build_model_catalog_json(&entries, fallback); + std::fs::write(&catalog_path, catalog_json)?; + doc["model_catalog_json"] = toml_edit::value(catalog_relative); + Ok(normalize_optional_toml(doc)) +} + fn sync_context_limits_from_config(profile: &mut RelayProfile, config_text: &str) { if let Some(value) = root_positive_int_string(config_text, "model_context_window") { profile.context_window = value; diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index 045e990af..23f4e9170 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -1015,7 +1015,7 @@ 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_selected_models() { let temp = tempfile::tempdir().unwrap(); let profile = RelayProfile { id: "relay-a".to_string(), @@ -1044,10 +1044,10 @@ 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")); + 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-catalogs").exists()); } #[test] @@ -2956,7 +2956,7 @@ experimental_bearer_token = "sk-new" } #[test] -fn apply_relay_profile_no_catalog_when_model_list_has_no_suffix() { +fn apply_relay_profile_generates_catalog_for_models_without_suffix() { let temp = tempfile::tempdir().unwrap(); let profile = RelayProfile { id: "relay-a".to_string(), @@ -2985,9 +2985,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")); + assert!(config.contains("model_catalog_json")); assert!(config.contains("model_context_window = 200000")); - assert!(!temp.path().join("model-catalogs").exists()); + assert!(temp.path().join("model-catalogs").exists()); + let catalog_path = temp.path().join("model-catalogs/relay-a.json"); + assert!(catalog_path.exists()); + let catalog = std::fs::read_to_string(&catalog_path).unwrap(); + assert!(catalog.contains("deepseek-coder")); + assert!(catalog.contains("qwen3-coder")); } #[test] From b1aa69b1d729220ed4f425aa046662d02fcc2dc1 Mon Sep 17 00:00:00 2001 From: lennney <94768569+lennney@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:12:03 +0800 Subject: [PATCH 2/2] test: add adversarial tests for full model catalog generation Coverage: - skip when both model_list and model are empty - strip suffixes from catalog entries - preserve user-defined model_catalog_json path - skip when suffix catalog already set - handle whitespace-only model_list (current model still included) - deduplicate repeated model names - respect model_windows for per-model context_window - handle comma-separated model_list - keep current model first in catalog order --- crates/codex-plus-core/tests/relay_config.rs | 316 +++++++++++++++++++ 1 file changed, 316 insertions(+) diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index 23f4e9170..1b5fbafd3 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -3319,3 +3319,319 @@ experimental_bearer_token = "sk-new" ); assert!(!windows.contains_key("deepseek-v4-pro")); } + +// === Adversarial tests for ensure_full_model_catalog === + +#[test] +fn full_catalog_skips_when_model_list_and_model_are_empty() { + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "relay-empty".to_string(), + name: "Empty".to_string(), + relay_mode: RelayMode::PureApi, + config_contents: r#"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(), + ..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("model_catalog_json")); + assert!(!temp.path().join("model-catalogs").exists()); +} + +#[test] +fn full_catalog_strips_model_list_suffixes_in_catalog() { + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "relay-suffix".to_string(), + name: "Suffix".to_string(), + model: "deepseek-v4-pro".to_string(), + relay_mode: RelayMode::PureApi, + config_contents: r#"model = "deepseek-v4-pro" +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_list: "deepseek-v4-pro[1M]\nclaude-sonnet-4[200K]\nqwen3-coder".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(); + assert!(config.contains("model_catalog_json")); + let catalog = std::fs::read_to_string( + temp.path().join("model-catalogs/relay-suffix.json"), + ) + .unwrap(); + // Suffixes stripped from catalog output + assert!(!catalog.contains("[1M]")); + assert!(!catalog.contains("[200K]")); + // All models present in catalog + assert!(catalog.contains("deepseek-v4-pro")); + assert!(catalog.contains("claude-sonnet-4")); + assert!(catalog.contains("qwen3-coder")); + // Suffix-derived context windows respected + assert!(catalog.contains("1000000")); + assert!(catalog.contains("200000")); +} + +#[test] +fn full_catalog_preserves_user_defined_model_catalog_json() { + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "relay-user-catalog".to_string(), + name: "User Catalog".to_string(), + model: "deepseek-v4-pro".to_string(), + relay_mode: RelayMode::PureApi, + config_contents: r#"model = "deepseek-v4-pro" +model_catalog_json = "/custom/path/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_list: "deepseek-v4-pro\nqwen3-coder".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(); + // Preserves user's own catalog path + assert!(config.contains(r#"model_catalog_json = "/custom/path/catalog.json""#)); +} + +#[test] +fn full_catalog_skips_when_suffix_catalog_already_set() { + let temp = tempfile::tempdir().unwrap(); + // apply_model_catalog_to_config generates catalog for suffixed models + // ensure_full_model_catalog should skip because catalog is already set + let profile = RelayProfile { + id: "relay-hybrid".to_string(), + name: "Hybrid".to_string(), + model: "deepseek-v4-pro".to_string(), + relay_mode: RelayMode::PureApi, + config_contents: r#"model = "deepseek-v4-pro" +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(), + // Has suffix models → apply_model_catalog_to_config generates catalog + model_list: "deepseek-v4-pro[1M]\nqwen3-coder".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(); + assert!(config.contains("model_catalog_json")); + // Only one catalog file written (not duplicated by ensure_full_model_catalog) + let catalog_entries: Vec<_> = std::fs::read_dir(temp.path().join("model-catalogs")) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(catalog_entries.len(), 1); +} + +#[test] +fn full_catalog_handles_model_list_with_only_whitespace() { + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "relay-space".to_string(), + name: "Space".to_string(), + model: "qwen3-coder".to_string(), + relay_mode: RelayMode::PureApi, + config_contents: r#"model = "qwen3-coder" +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_list: " \n \n ".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(); + // Whitespace-only model_list → only current model "qwen3-coder" gets catalog entry + assert!(config.contains("model_catalog_json")); + let catalog = std::fs::read_to_string(temp.path().join("model-catalogs/relay-space.json")).unwrap(); + assert!(catalog.contains("qwen3-coder")); +} + +#[test] +fn full_catalog_deduplicates_model_names() { + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "relay-dup".to_string(), + name: "Dup".to_string(), + model: "qwen3-coder".to_string(), // also in model_list + relay_mode: RelayMode::PureApi, + config_contents: r#"model = "qwen3-coder" +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_list: "qwen3-coder\ndeepseek-v4-pro\nqwen3-coder".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(); + assert!(config.contains("model_catalog_json")); + let catalog = std::fs::read_to_string( + temp.path().join("model-catalogs/relay-dup.json"), + ) + .unwrap(); + // qwen3-coder slug appears exactly once (deduplicated by collect_catalog_entries) + let slug_count = catalog.matches("\"slug\": \"qwen3-coder\"").count(); + assert_eq!(slug_count, 1, "qwen3-coder slug appears {} times, expected 1", slug_count); +} + +#[test] +fn full_catalog_respects_model_windows_for_context_window() { + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "relay-windows".to_string(), + name: "Windows".to_string(), + model: "deepseek-v4-pro".to_string(), + relay_mode: RelayMode::PureApi, + config_contents: r#"model = "deepseek-v4-pro" +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_list: "deepseek-v4-pro\nkimi-k2.6".to_string(), + model_windows: r#"{"kimi-k2.6":"262000"}"#.to_string(), + context_window: "1000000".to_string(), + ..RelayProfile::default() + }; + + apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap(); + let catalog = std::fs::read_to_string( + temp.path().join("model-catalogs/relay-windows.json"), + ) + .unwrap(); + // kimi-k2.6 gets its explicit window from model_windows + assert!(catalog.contains("262000")); + // deepseek-v4-pro gets the fallback context_window + assert!(catalog.contains("1000000")); +} + +#[test] +fn full_catalog_handles_model_list_with_commas() { + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "relay-comma".to_string(), + name: "Comma".to_string(), + model: "gpt-5".to_string(), + relay_mode: RelayMode::PureApi, + config_contents: r#"model = "gpt-5" +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_list: "gpt-5,gpt-5-mini,claude-sonnet-4".to_string(), + ..RelayProfile::default() + }; + + apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap(); + let catalog = std::fs::read_to_string( + temp.path().join("model-catalogs/relay-comma.json"), + ) + .unwrap(); + assert!(catalog.contains("gpt-5")); + assert!(catalog.contains("gpt-5-mini")); + assert!(catalog.contains("claude-sonnet-4")); +} + +#[test] +fn full_catalog_keeps_current_model_first_in_catalog() { + // collect_catalog_entries puts current model first, ensure_full_model_catalog uses it + let temp = tempfile::tempdir().unwrap(); + let profile = RelayProfile { + id: "relay-order".to_string(), + name: "Order".to_string(), + model: "zulu-model".to_string(), + relay_mode: RelayMode::PureApi, + config_contents: r#"model = "zulu-model" +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_list: "alpha-model\nzulu-model\nbeta-model".to_string(), + ..RelayProfile::default() + }; + + apply_relay_profile_files_to_home_with_context(temp.path(), &profile, "").unwrap(); + let catalog = std::fs::read_to_string( + temp.path().join("model-catalogs/relay-order.json"), + ) + .unwrap(); + // zulu-model (current model) should come before alpha-model in the JSON array + let zulu_pos = catalog.find("\"zulu-model\"").unwrap(); + let alpha_pos = catalog.find("\"alpha-model\"").unwrap(); + assert!(zulu_pos < alpha_pos, "current model should appear first in catalog"); +}