diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 87ca9e7a19..9d8e4f1d2b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -793,6 +793,7 @@ dependencies = [ "once_cell", "regex", "reqwest 0.12.28", + "rmcp", "rquickjs", "rusqlite", "rust_decimal", @@ -1174,8 +1175,18 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1192,13 +1203,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -3635,6 +3670,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathdiff" version = "0.2.3" @@ -4598,6 +4639,41 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rmcp" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00a32c3b81b7b254076a65abd5ab2551209146713ba38f73818657e865e9433" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee70afb7956da9f30d5348a2539b5eb90d9038f834463657ab717076ac3b1ad" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + [[package]] name = "rquickjs" version = "0.8.1" @@ -4716,7 +4792,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4856,7 +4932,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "url", @@ -4881,8 +4957,10 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive 1.2.1", "serde", "serde_json", ] @@ -4899,6 +4977,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -5131,7 +5221,7 @@ version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.117", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 72884885a9..5b7e74f724 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,4 +1,5 @@ [package] +default-run = "cc-switch" name = "cc-switch" version = "3.16.4-16" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" @@ -25,6 +26,10 @@ name = "codex-history-repairer" path = "src/bin/codex-history-repairer.rs" required-features = ["history-repairer"] +[[bin]] +name = "ccswitch-readonly-mcp" +path = "src/bin/ccswitch_readonly_mcp.rs" + [features] default = [] history-repairer = [] @@ -94,6 +99,7 @@ sha2 = "0.10" hmac = "0.12" json5 = "0.4" json-five = "0.3.1" +rmcp = { version = "2.1.0", features = ["server", "transport-io"] } [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/bin/ccswitch_readonly_mcp.rs b/src-tauri/src/bin/ccswitch_readonly_mcp.rs new file mode 100644 index 0000000000..acb0a3e219 --- /dev/null +++ b/src-tauri/src/bin/ccswitch_readonly_mcp.rs @@ -0,0 +1,3 @@ +fn main() -> anyhow::Result<()> { + cc_switch_lib::run_readonly_mcp() +} diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 9de8485c18..b89c60b5b3 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -12,6 +12,7 @@ use std::process::Command; use toml_edit::{Array, DocumentMut, InlineTable, Item, TableLike}; pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom"; +const CC_SWITCH_READONLY_MCP_SERVER_ID: &str = "ccswitch_readonly"; /// Codex MultiRouter 专用的本地 provider id。 /// /// 普通第三方 Codex provider 继续使用 `custom` 桶;MultiRouter 使用稳定的 @@ -222,6 +223,34 @@ pub fn write_codex_live_config_atomic(config_text_opt: Option<&str>) -> Result<( write_text_file(&config_path, &cfg_text) } +pub fn ensure_ccswitch_readonly_mcp_config(config_text: &str) -> Result { + let command = std::env::current_exe() + .map_err(|e| AppError::Message(format!("Failed to resolve cc-switch executable: {e}")))?; + let command = command.to_string_lossy().to_string(); + + let mut doc = if config_text.trim().is_empty() { + DocumentMut::new() + } else { + config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))? + }; + + if doc.get("mcp_servers").is_none() { + doc["mcp_servers"] = toml_edit::table(); + } + + let mut server = toml_edit::Table::new(); + server["type"] = toml_edit::value("stdio"); + server["command"] = toml_edit::value(command); + let mut args = Array::new(); + args.push("--readonly-mcp"); + server["args"] = toml_edit::value(args); + + doc["mcp_servers"][CC_SWITCH_READONLY_MCP_SERVER_ID] = Item::Table(server); + Ok(doc.to_string()) +} + pub fn extract_codex_auth_api_key(auth: &Value) -> Option { auth.get("OPENAI_API_KEY") .and_then(|value| value.as_str()) @@ -769,6 +798,11 @@ fn project_codex_desktop_model_fields( entry_obj.insert("supported_in_api".to_string(), json!(true)); entry_obj.insert("hidden".to_string(), json!(false)); entry_obj.insert("isDefault".to_string(), json!(spec.is_default)); + // `tool_mode = null` keeps routed models visible in Chat. + entry_obj.insert("tool_mode".to_string(), Value::Null); + entry_obj.insert("toolMode".to_string(), Value::Null); + entry_obj.insert("output_modalities".to_string(), json!(["text"])); + entry_obj.insert("outputModalities".to_string(), json!(["text"])); if let Some(value) = entry_obj.get("additional_speed_tiers").cloned() { entry_obj.insert("additionalSpeedTiers".to_string(), value); @@ -922,6 +956,96 @@ fn sort_codex_catalog_specs_for_picker( indexed_specs.into_iter().map(|(_, spec)| spec).collect() } +fn codex_catalog_model_id(entry: &Value) -> Option<&str> { + entry + .get("slug") + .or_else(|| entry.get("model")) + .or_else(|| entry.get("id")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|model| !model.is_empty()) +} + +fn codex_catalog_model_display_name<'a>(entry: &'a Value, model: &'a str) -> &'a str { + entry + .get("displayName") + .or_else(|| entry.get("display_name")) + .or_else(|| entry.get("description")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(model) +} + +fn codex_official_gpt_specs_from_cache( + cache: &Value, + seen_models: &mut HashSet, +) -> Vec { + let Some(models) = cache.get("models").and_then(|models| models.as_array()) else { + return Vec::new(); + }; + + models + .iter() + .filter_map(|entry| { + let model = codex_catalog_model_id(entry)?; + if !model.to_ascii_lowercase().starts_with("gpt-") { + return None; + } + if !seen_models.insert(model.to_ascii_lowercase()) { + return None; + } + + let display_name = codex_catalog_model_display_name(entry, model); + let context_window = parse_codex_positive_u64( + entry + .get("contextWindow") + .or_else(|| entry.get("context_window")) + .or_else(|| entry.get("maxContextWindow")) + .or_else(|| entry.get("max_context_window")), + ) + .unwrap_or(128_000); + + Some(CodexCatalogModelSpec { + model: model.to_string(), + upstream_model: None, + display_name: display_name.to_string(), + context_window, + text_only: codex_catalog_model_name_is_text_only(model), + is_default: false, + }) + }) + .collect() +} + +fn codex_official_gpt_specs_to_preserve( + existing_specs: &[CodexCatalogModelSpec], +) -> Result, AppError> { + let mut seen_models = existing_specs + .iter() + .map(|spec| spec.model.to_ascii_lowercase()) + .collect::>(); + + for path in [ + get_codex_models_cache_backup_path(), + get_codex_models_cache_path(), + ] { + let Some(cache) = read_json_file_if_exists(&path)? else { + continue; + }; + if codex_models_cache_is_cc_switch_owned(&cache) { + continue; + } + + let specs = codex_official_gpt_specs_from_cache(&cache, &mut seen_models); + if !specs.is_empty() { + return Ok(specs); + } + } + + Ok(Vec::new()) +} + fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec { let Some(models) = settings .get("modelCatalog") @@ -1281,6 +1405,12 @@ fn codex_provider_reasoning_efforts_toml_array(key: &str) -> toml_edit::Value { toml_edit::Value::Array(array) } +fn codex_provider_text_modalities_toml_array() -> toml_edit::Value { + let mut array = Array::default(); + array.push("text"); + toml_edit::Value::Array(array) +} + /// 为当前活动 custom provider 生成 Codex Desktop 可枚举的内联模型数组。 fn codex_provider_models_toml_array(specs: &[CodexCatalogModelSpec]) -> Item { let mut array = Array::default(); @@ -1337,6 +1467,14 @@ fn codex_provider_models_toml_array(specs: &[CodexCatalogModelSpec]) -> Item { model.insert("supported_in_api", true.into()); model.insert("hidden", false.into()); model.insert("isDefault", spec.is_default.into()); + model.insert( + "output_modalities", + codex_provider_text_modalities_toml_array(), + ); + model.insert( + "outputModalities", + codex_provider_text_modalities_toml_array(), + ); array.push(toml_edit::Value::InlineTable(model)); } Item::Value(toml_edit::Value::Array(array)) @@ -1344,8 +1482,7 @@ fn codex_provider_models_toml_array(specs: &[CodexCatalogModelSpec]) -> Item { /// 将模型目录同步到活动 provider 的 `models` 字段。 /// -/// Codex Desktop 的 app-server 会把 custom provider 标为“自定义”,但候选菜单仍需要 -/// provider 内部能枚举模型;只写顶层 `model_catalog_json` 对部分 Desktop 版本不够。 +/// `modelCatalog` 中的模型始终投影到活动 provider,包括 Codex 保留的 provider id。 fn set_active_codex_provider_models(doc: &mut DocumentMut, specs: &[CodexCatalogModelSpec]) { if specs.is_empty() { return; @@ -1353,9 +1490,6 @@ fn set_active_codex_provider_models(doc: &mut DocumentMut, specs: &[CodexCatalog let Some(provider_id) = active_codex_model_provider_id(doc) else { return; }; - if !is_custom_codex_model_provider_id(&provider_id) { - return; - } if doc.get("model_providers").is_none() { doc["model_providers"] = toml_edit::table(); @@ -1382,9 +1516,6 @@ fn remove_active_codex_provider_models(doc: &mut DocumentMut) { let Some(provider_id) = active_codex_model_provider_id(doc) else { return; }; - if !is_custom_codex_model_provider_id(&provider_id) { - return; - } if let Some(provider_table) = doc .get_mut("model_providers") .and_then(|item| item.as_table_mut()) @@ -1831,9 +1962,10 @@ pub fn prepare_codex_config_text_with_model_catalog( config_text: &str, ) -> Result { let catalog_path = get_codex_model_catalog_path(); - let specs = codex_catalog_model_specs(settings, config_text); + let mut specs = codex_catalog_model_specs(settings, config_text); if !specs.is_empty() { + specs.extend(codex_official_gpt_specs_to_preserve(&specs)?); let template = load_codex_model_catalog_template()?; let catalog = codex_model_catalog_from_specs(&specs, &template); let config_text = set_codex_model_catalog_projection_fields( @@ -2959,6 +3091,56 @@ pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) -> doc.to_string() } +/// Remove CC Switch local takeover fields from a Codex config while preserving +/// user-owned tables such as desktop, projects, MCP servers, and feature flags. +/// +/// This is intentionally narrower than provider restore: it is used as the +/// last-resort cleanup path when proxy takeover is stopped or recovered without +/// a trustworthy backup. If the active provider is one of CC Switch's local +/// proxy buckets, Codex must fall back to its built-in OpenAI provider instead +/// of continuing to target 127.0.0.1 after CC Switch exits. +pub fn cleanup_codex_local_proxy_takeover_fields(config_text: &str) -> Result { + if config_text.trim().is_empty() { + return Ok(config_text.to_string()); + } + + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + + let active_provider = active_codex_model_provider_id(&doc); + let active_is_cc_switch_provider = active_provider.as_deref().is_some_and(|provider_id| { + provider_id.eq_ignore_ascii_case(CC_SWITCH_CODEX_MODEL_PROVIDER_ID) + || provider_id.eq_ignore_ascii_case(CC_SWITCH_CODEX_ROUTER_MODEL_PROVIDER_ID) + }); + + if active_is_cc_switch_provider { + remove_active_custom_codex_model_provider_section(&mut doc); + for key in [ + "model", + "model_provider", + "base_url", + "openai_base_url", + "wire_api", + "experimental_bearer_token", + ] { + doc.as_table_mut().remove(key); + } + remove_cc_switch_model_catalog_json_if_stale(&mut doc); + } else { + doc = remove_codex_toml_base_url_if(&doc.to_string(), codex_base_url_is_local_proxy) + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + doc = remove_codex_experimental_bearer_token_if(&doc.to_string(), |token| { + token == CODEX_PROXY_AUTH_PLACEHOLDER + })? + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + } + + Ok(doc.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -3746,6 +3928,119 @@ wire_api = "responses" ); } + #[test] + fn cleanup_local_proxy_takeover_fields_restores_builtin_codex_defaults() { + let live_config = r#"model = "claude-sonnet-5" +model_provider = "codex_model_router_v2" +model_context_window = 262144 +model_catalog_json = "cc-switch-model-catalog.json" +experimental_bearer_token = "PROXY_MANAGED" +approval_policy = "on-request" + +[desktop] +notifications-turn-mode = "always" + +[projects."C:\\work"] +trust_level = "trusted" + +[mcp_servers.readonly] +command = "readonly-mcp" + +[model_providers.codex_model_router_v2] +name = "CCSwitch MultiRouter" +base_url = "http://127.0.0.1:15721/v1" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +experimental_bearer_token = "PROXY_MANAGED" +"#; + + let cleaned = cleanup_codex_local_proxy_takeover_fields(live_config) + .expect("cleanup local takeover fields"); + let parsed: toml::Value = toml::from_str(&cleaned).expect("parse cleaned config"); + + assert!(parsed.get("model").is_none()); + assert!(parsed.get("model_provider").is_none()); + assert!(parsed.get("model_catalog_json").is_none()); + assert!(parsed.get("experimental_bearer_token").is_none()); + assert!( + parsed + .get("model_providers") + .and_then(|providers| providers.get("codex_model_router_v2")) + .is_none(), + "cleanup must remove the local router provider table" + ); + assert_eq!( + parsed + .get("model_context_window") + .and_then(|value| value.as_integer()), + Some(262_144), + "cleanup should keep user-owned context display settings" + ); + assert_eq!( + parsed + .get("desktop") + .and_then(|value| value.get("notifications-turn-mode")) + .and_then(|value| value.as_str()), + Some("always") + ); + assert!(parsed + .get("projects") + .and_then(|value| value.get(r"C:\work")) + .is_some()); + assert!(parsed + .get("mcp_servers") + .and_then(|value| value.get("readonly")) + .is_some()); + assert!(!cleaned.contains("127.0.0.1:15721")); + assert!(!cleaned.contains("PROXY_MANAGED")); + } + + #[test] + fn ensure_ccswitch_readonly_mcp_config_uses_current_exe_without_project_root() { + let config = r#"model = "claude-sonnet-5" +model_provider = "codex_model_router_v2" +"#; + + let updated = + ensure_ccswitch_readonly_mcp_config(config).expect("inject readonly MCP config"); + let parsed = updated + .parse::() + .expect("generated config should parse"); + let server = parsed + .get("mcp_servers") + .and_then(|item| item.as_table()) + .and_then(|table| table.get("ccswitch_readonly")) + .and_then(|item| item.as_table()) + .expect("readonly server should exist"); + + assert_eq!( + server.get("type").and_then(|item| item.as_str()), + Some("stdio") + ); + assert!( + server + .get("command") + .and_then(|item| item.as_str()) + .is_some_and(|command| !command.trim().is_empty()), + "command should be resolved from current executable" + ); + let args = server + .get("args") + .and_then(|item| item.as_array()) + .expect("args should exist"); + assert_eq!( + args.iter() + .filter_map(|item| item.as_str()) + .collect::>(), + vec!["--readonly-mcp"] + ); + assert!( + !updated.contains("CCSWITCH_READONLY_ROOT"), + "project root must not be hardcoded into shared config" + ); + } + #[test] fn merge_openai_router_config_uses_builtin_openai_history_bucket() { let live_config = r#"model = "gpt-5.5" @@ -4314,6 +4609,17 @@ openai_base_url = "http://127.0.0.1:15721/v1" Some(&json!(["text"])), "DeepSeek V4 must stay text-only so Codex does not inject image_generation" ); + assert!( + models[0] + .get("tool_mode") + .is_some_and(|value| value.is_null()), + "routed custom models should be visible to the new Chat picker, not code-mode-only" + ); + assert_eq!( + models[0].get("output_modalities"), + Some(&json!(["text"])), + "routed custom models should advertise text output for Chat mode" + ); assert_eq!( models[0] .get("supports_image_detail_original") @@ -5599,6 +5905,20 @@ base_url = "http://127.0.0.1:15721/v1" provider_model_ids.contains(&"qwen3.6"), "inline provider models must include Qwen so the Desktop menu is not just 自定义" ); + assert!( + provider_models + .iter() + .filter( + |model| model.get("model").and_then(|value| value.as_str()) == Some("qwen3.6") + ) + .all(|model| model + .get("output_modalities") + .and_then(|value| value.as_array()) + .is_some_and(|modalities| modalities + .iter() + .any(|value| value.as_str() == Some("text")))), + "inline provider models should advertise text output for Chat-capable pickers" + ); assert!( provider_model_ids.contains(&"deepseek-v4-flash"), "inline provider models must include DeepSeek so the Desktop menu can enumerate it" @@ -5635,6 +5955,147 @@ base_url = "http://127.0.0.1:15721/v1" assert!(model_fields.contains(&"deepseek-v4-flash")); } + #[test] + #[serial] + fn model_catalog_preserves_new_official_gpt_models() { + let _home = TestHomeGuard::new(); + seed_codex_models_cache(json!([ + { + "slug": "gpt-5.5", + "display_name": "GPT-5.5", + "model_messages": { "instructions_template": "template" }, + "context_window": 128000 + }, + { + "slug": "gpt-5.6-sol", + "display_name": "GPT-5.6 Sol", + "model_messages": { "instructions_template": "template" }, + "context_window": 256000 + }, + { + "slug": "gpt-5.6-terra", + "display_name": "GPT-5.6 Terra", + "model_messages": { "instructions_template": "template" }, + "context_window": 256000 + }, + { + "slug": "gpt-5.6-luna", + "display_name": "GPT-5.6 Luna", + "model_messages": { "instructions_template": "template" }, + "context_window": 256000 + } + ])); + let settings = json!({ + "modelCatalog": { + "models": [ + { "model": "qwen3.6", "displayName": "Qwen 3.6" } + ] + } + }); + let config = r#"model_provider = "custom" + +[model_providers.custom] +base_url = "http://127.0.0.1:15721/v1" +"#; + + let prepared = prepare_codex_config_text_with_model_catalog(&settings, config) + .expect("prepare config"); + let prepared_toml: toml::Value = toml::from_str(&prepared).expect("parse prepared config"); + let provider_models = prepared_toml + .get("model_providers") + .and_then(|providers| providers.get("custom")) + .and_then(|provider| provider.get("models")) + .and_then(|models| models.as_array()) + .expect("custom provider should expose inline models"); + let provider_model_ids = provider_models + .iter() + .filter_map(|model| model.get("model").and_then(|value| value.as_str())) + .collect::>(); + assert!(provider_model_ids.contains(&"qwen3.6")); + assert!(provider_model_ids.contains(&"gpt-5.6-sol")); + assert!(provider_model_ids.contains(&"gpt-5.6-terra")); + assert!(provider_model_ids.contains(&"gpt-5.6-luna")); + + let catalog: Value = + read_json_file(&get_codex_model_catalog_path()).expect("read generated catalog"); + let catalog_slugs = catalog + .get("models") + .and_then(|models| models.as_array()) + .expect("models array") + .iter() + .filter_map(|model| model.get("slug").and_then(|slug| slug.as_str())) + .collect::>(); + assert!(catalog_slugs.contains(&"qwen3.6")); + assert!(catalog_slugs.contains(&"gpt-5.6-sol")); + assert!(catalog_slugs.contains(&"gpt-5.6-terra")); + assert!(catalog_slugs.contains(&"gpt-5.6-luna")); + + let cache: Value = read_json_file(&get_codex_models_cache_path()).expect("read cache"); + let cache_slugs = cache + .get("models") + .and_then(|models| models.as_array()) + .expect("models array") + .iter() + .filter_map(|model| model.get("slug").and_then(|slug| slug.as_str())) + .collect::>(); + assert!(cache_slugs.contains(&"qwen3.6")); + assert!(cache_slugs.contains(&"gpt-5.6-sol")); + assert!(cache_slugs.contains(&"gpt-5.6-terra")); + assert!(cache_slugs.contains(&"gpt-5.6-luna")); + } + + #[test] + #[serial] + /// LM Studio 是 Codex 保留 provider id,但用户在 CCSwitchMulti 配置的 + /// modelCatalog 仍必须投影到 picker 可见模型,否则源码版同步后菜单为空。 + fn model_catalog_projects_models_for_lmstudio_provider_picker() { + let _home = TestHomeGuard::new(); + seed_codex_models_cache(json!([{ + "slug": "gpt-5.5", + "display_name": "GPT-5.5", + "model_messages": { "instructions_template": "template" }, + "context_window": 128000 + }])); + let settings = json!({ + "modelCatalog": { + "models": [ + { "model": "qwen2.5-coder-14b-instruct", "displayName": "Qwen Coder 14B" } + ] + } + }); + let config = r#"model_provider = "lmstudio" + +[model_providers.lmstudio] +name = "LM Studio" +"#; + + let prepared = prepare_codex_config_text_with_model_catalog(&settings, config) + .expect("prepare config"); + assert!(prepared.contains("model_catalog_json")); + let prepared_toml: toml::Value = toml::from_str(&prepared).expect("parse prepared config"); + let provider_models = prepared_toml + .get("model_providers") + .and_then(|providers| providers.get("lmstudio")) + .and_then(|provider| provider.get("models")) + .and_then(|models| models.as_array()) + .expect("lmstudio provider should expose inline models for Codex Desktop"); + let provider_model_ids: Vec<_> = provider_models + .iter() + .filter_map(|model| model.get("model").and_then(|value| value.as_str())) + .collect(); + assert!(provider_model_ids.contains(&"qwen2.5-coder-14b-instruct")); + + let cache: Value = read_json_file(&get_codex_models_cache_path()).expect("read cache"); + let slugs = cache + .get("models") + .and_then(|models| models.as_array()) + .expect("models array") + .iter() + .filter_map(|model| model.get("slug").and_then(|slug| slug.as_str())) + .collect::>(); + assert!(slugs.contains(&"qwen2.5-coder-14b-instruct")); + } + #[test] #[serial] /// 退出 MultiRouter 后只恢复 CC Switch 接管过的缓存,避免污染 official backup。 diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index 7e5ea9c625..0a6bb68f45 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -250,6 +250,22 @@ pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result, +) -> Result { + Ok(state.proxy_service.get_interaction_mode().await) +} + +#[tauri::command] +pub async fn set_interaction_mode( + state: tauri::State<'_, AppState>, + mode: InteractionMode, +) -> Result<(), String> { + state.proxy_service.set_interaction_mode(mode).await; + Ok(()) +} + /// 运行 Codex MultiRouter 一键诊断。 /// /// 该命令只读取本机配置、探测本地代理端口和读取本地 router 日志;不会向真实上游发送 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d13b12af16..3cc294d524 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -29,6 +29,7 @@ mod prompt_files; mod provider; mod provider_defaults; mod proxy; +mod readonly_mcp; mod services; mod session_manager; mod settings; @@ -53,6 +54,7 @@ pub use mcp::{ sync_single_server_to_codex, sync_single_server_to_gemini, }; pub use provider::{Provider, ProviderMeta}; +pub use readonly_mcp::run_readonly_mcp; pub use services::{ skill::{migrate_skills_to_ssot, ImportSkillSelection}, ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService, @@ -1395,6 +1397,8 @@ pub fn run() { commands::get_proxy_takeover_status, commands::set_proxy_takeover_for_app, commands::get_proxy_status, + commands::get_interaction_mode, + commands::set_interaction_mode, commands::diagnose_codex_multirouter, commands::unlock_codex_model_picker, commands::sync_codex_history_to_multirouter, @@ -1738,12 +1742,11 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) { let needs_restore = has_backups || live_taken_over; if needs_restore { - log::info!("检测到接管残留,开始恢复 Live 配置(保留代理状态)..."); - // 使用 keep_state 版本,保留 settings 表中的代理状态 - if let Err(e) = proxy_service.stop_with_restore_keep_state().await { + log::info!("检测到接管残留,开始恢复 Live 配置并关闭接管状态..."); + if let Err(e) = proxy_service.stop_with_restore().await { log::error!("退出时恢复 Live 配置失败: {e}"); } else { - log::info!("已恢复 Live 配置(代理状态已保留,下次启动将自动恢复)"); + log::info!("已恢复 Live 配置(接管状态已关闭,Codex 不再依赖本地代理)"); } return; } @@ -1783,10 +1786,7 @@ pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) { // 启动时恢复代理状态 // ============================================================ -/// 启动时根据 proxy_config 表中的代理状态自动恢复代理服务 -/// -/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`, -/// 则自动启动代理服务并接管对应应用的 Live 配置。 +/// 启动时恢复独立代理服务,并清理上次异常退出残留的 Live 接管状态。 async fn restore_proxy_state_on_startup(state: &store::AppState) { match crate::proxy::external_openai_api::load_profile(&state.db) { Ok(profile) if profile.enabled => { @@ -1803,45 +1803,26 @@ async fn restore_proxy_state_on_startup(state: &store::AppState) { Err(e) => log::warn!("读取第三方 Agent API profile 失败,跳过独立监听恢复: {e}"), } - // 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取) - let mut apps_to_restore = Vec::new(); + // App takeover is an explicit user action. Startup must not re-take over + // Claude/Codex/Gemini from stale DB flags, otherwise Codex can remain + // pinned to 127.0.0.1 after CC Switch is closed. + let mut stale_takeovers = Vec::new(); for app_type in ["claude", "codex", "gemini"] { if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await { if config.enabled { - apps_to_restore.push(app_type); + stale_takeovers.push(app_type); } } } - if apps_to_restore.is_empty() { - log::debug!("启动时没有需要恢复的 app takeover"); + if stale_takeovers.is_empty() { + log::debug!("启动时没有需要清理的 app takeover 残留"); return; } - log::info!("检测到上次代理状态需要恢复,应用列表: {apps_to_restore:?}"); - - // 逐个恢复接管状态 - for app_type in apps_to_restore { - match state - .proxy_service - .set_takeover_for_app(app_type, true) - .await - { - Ok(()) => { - log::info!("✓ 已恢复 {app_type} 的代理接管状态"); - } - Err(e) => { - log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}"); - // 失败时清除该应用的状态,避免下次启动再次尝试 - if let Err(clear_err) = state - .proxy_service - .set_takeover_for_app(app_type, false) - .await - { - log::error!("清除 {app_type} 代理状态失败: {clear_err}"); - } - } - } + log::warn!("检测到上次 app takeover 残留,启动时仅恢复清理,不自动接管: {stale_takeovers:?}"); + if let Err(e) = state.proxy_service.stop_with_restore().await { + log::error!("启动时清理 app takeover 残留失败: {e}"); } } @@ -2130,7 +2111,9 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! { #[cfg(test)] mod tests { - use super::{classify_exit_request, ExitRequestAction}; + use super::{classify_exit_request, restore_proxy_state_on_startup, store, ExitRequestAction}; + use crate::database::Database; + use std::sync::Arc; #[test] fn no_code_keeps_app_alive_in_tray() { @@ -2156,4 +2139,28 @@ mod tests { ExitRequestAction::CleanupAndExit ); } + + #[tokio::test] + async fn startup_clears_stale_codex_takeover_without_reenabling_it() { + let db = Arc::new(Database::memory().expect("init db")); + let mut config = db + .get_proxy_config_for_app("codex") + .await + .expect("read codex proxy config"); + config.enabled = true; + db.update_proxy_config_for_app(config) + .await + .expect("mark codex takeover stale"); + + let state = store::AppState::new(db.clone()); + restore_proxy_state_on_startup(&state).await; + + assert!( + !db.get_proxy_config_for_app("codex") + .await + .expect("read codex proxy config") + .enabled, + "startup must not re-enable Codex takeover from a stale enabled flag" + ); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9d81b848f3..6fd9417ee2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,6 +2,14 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + if std::env::args().any(|arg| arg == "--readonly-mcp") { + if let Err(err) = cc_switch_lib::run_readonly_mcp() { + eprintln!("cc-switch readonly MCP failed: {err}"); + std::process::exit(1); + } + return; + } + // 在 Linux 上设置 WebKit 环境变量以解决 DMA-BUF 渲染问题 // 某些 Linux 系统(如 Debian 13.2、Nvidia GPU)上 WebKitGTK 的 DMA-BUF 渲染器可能导致白屏/黑屏 // 参考: https://github.com/tauri-apps/tauri/issues/9394 diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 56cdcd6b41..030ac0239a 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -8,7 +8,7 @@ use super::{ content_encoding::{decompress_body, get_content_encoding}, error::*, failover_switch::FailoverSwitchManager, - json_canonical::{canonicalize_value, short_value_hash}, + json_canonical::{canonical_json_string, canonicalize_value, short_value_hash}, log_codes::fwd as log_fwd, provider_router::ProviderRouter, providers::{ @@ -19,7 +19,9 @@ use super::{ thinking_rectifier::{ normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature, }, - types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig}, + types::{ + CopilotOptimizerConfig, InteractionMode, OptimizerConfig, ProxyStatus, RectifierConfig, + }, ProxyError, }; use crate::commands::{CodexOAuthState, CopilotAuthState}; @@ -41,6 +43,545 @@ use tokio::sync::RwLock; const PROXY_AUTH_PLACEHOLDER: &str = "PROXY_MANAGED"; const CODEX_RESPONSES_LITE_FALLBACK_TTL: Duration = Duration::from_secs(24 * 60 * 60); +const CLAUDE_CHAT_MODEL: &str = "claude-sonnet-5"; + +const CLAUDE_ASK_ALLOWED_TOOLS: &[&str] = &[ + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", +]; +const CLAUDE_ASK_PROJECT_MCP_SERVER: &str = "ccswitch_readonly"; + +#[derive(Debug, Default)] +struct AskInputFilterStats { + original_items: usize, + kept_items: usize, + removed_assistant_messages: usize, + removed_contextual_fragments: usize, +} + +fn provider_supports_chat_ask_profiles(provider: &Provider) -> bool { + if provider.uses_managed_account_auth() { + return false; + } + + provider + .settings_config + .get("codexResolvedRouteMatched") + .and_then(Value::as_bool) + .unwrap_or(false) + || provider + .settings_config + .get("codexResolvedRouteId") + .and_then(Value::as_str) + .is_some_and(|route_id| !route_id.trim().is_empty()) +} + +fn apply_claude_chat_profile_for_provider( + body: &mut Value, + provider_supports_profiles: bool, +) -> bool { + if !provider_supports_profiles { + return false; + } + + let Some(obj) = body.as_object_mut() else { + return false; + }; + + obj.remove("instructions"); + + obj.remove("tools"); + obj.remove("tool_choice"); + obj.remove("parallel_tool_calls"); + + if let Some(input) = obj.get_mut("input").and_then(Value::as_array_mut) { + input.retain_mut(should_keep_claude_chat_item); + } + + true +} + +fn should_keep_claude_chat_item(item: &mut Value) -> bool { + let item_type = item.get("type").and_then(Value::as_str).unwrap_or(""); + + if item_type == "reasoning" + || item_type.ends_with("_call") + || item_type.ends_with("_call_output") + || item_type.contains("tool") + { + return false; + } + + if matches!( + item.get("role").and_then(Value::as_str), + Some("system") | Some("developer") | Some("tool") + ) { + return false; + } + + remove_chat_contextual_content_items(item) +} + +fn remove_chat_contextual_content_items(item: &mut Value) -> bool { + if item + .get("text") + .and_then(Value::as_str) + .is_some_and(is_chat_contextual_fragment) + { + return false; + } + + if let Some(content) = item.get_mut("content").and_then(Value::as_array_mut) { + content.retain(|content_item| { + !content_item + .get("text") + .and_then(Value::as_str) + .is_some_and(is_chat_contextual_fragment) + }); + return !content.is_empty(); + } + + true +} + +fn is_chat_contextual_fragment(text: &str) -> bool { + let trimmed = text.trim(); + is_wrapped_fragment(trimmed, "", "") + || is_agents_instructions_fragment(trimmed) + || is_wrapped_fragment( + trimmed, + "", + ) + || is_wrapped_fragment(trimmed, "") + || is_wrapped_fragment(trimmed, "") +} + +fn is_wrapped_fragment(trimmed: &str, open_prefix: &str, close: &str) -> bool { + trimmed.starts_with(open_prefix) && trimmed.ends_with(close) +} + +fn is_agents_instructions_fragment(trimmed: &str) -> bool { + trimmed.starts_with("# AGENTS.md instructions") + && trimmed.contains("") + && trimmed.ends_with("") +} + +fn apply_claude_ask_profile_for_provider( + body: &mut Value, + provider_supports_profiles: bool, +) -> bool { + if !provider_supports_profiles { + return false; + } + + let Some(obj) = body.as_object_mut() else { + return false; + }; + + obj.remove("instructions"); + let filter_result = filter_claude_ask_tools(obj); + log::info!( + "[CodexAsk] filtered_tools kept={} original={}", + filter_result.kept_count, + filter_result.original_count + ); + + if let Some(input) = obj.get_mut("input") { + let original_input = std::mem::take(input); + let (filtered_input, input_stats) = filter_responses_input_for_claude_ask(original_input); + log::info!( + "[CodexAsk] filtered_input kept={} original={} removed_assistant={} removed_contextual_fragments={}", + input_stats.kept_items, + input_stats.original_items, + input_stats.removed_assistant_messages, + input_stats.removed_contextual_fragments + ); + *input = filtered_input; + } + + true +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AskToolFilterResult { + original_count: usize, + kept_count: usize, +} + +fn filter_claude_ask_tools(obj: &mut serde_json::Map) -> AskToolFilterResult { + let Some(tools_value) = obj.get("tools") else { + return AskToolFilterResult { + original_count: 0, + kept_count: 0, + }; + }; + + let Some(tools) = tools_value.as_array() else { + obj.remove("tools"); + return AskToolFilterResult { + original_count: 0, + kept_count: 0, + }; + }; + + let available_tools = tools + .iter() + .filter_map(response_tool_name_for_ask_log) + .collect::>(); + log::info!("[CodexAsk] available_tools=[{}]", available_tools.join(",")); + + let allowed_tools = tools + .iter() + .filter(|tool| response_tool_name(tool).is_some_and(is_claude_ask_readonly_tool)) + .cloned() + .collect::>(); + let original_count = tools.len(); + let kept_count = allowed_tools.len(); + + let kept_tool_names = allowed_tools + .iter() + .filter_map(response_tool_name_for_ask_log) + .collect::>(); + + if kept_count == 0 { + obj.remove("tools"); + } else { + obj.insert("tools".to_string(), Value::Array(allowed_tools)); + } + + if tool_choice_points_to_removed_tool(obj.get("tool_choice"), &kept_tool_names) { + obj.remove("tool_choice"); + } + + AskToolFilterResult { + original_count, + kept_count, + } +} + +fn response_tool_name_for_ask_log(tool: &Value) -> Option { + response_tool_name(tool).map(ToString::to_string) +} + +fn response_tool_name(tool: &Value) -> Option<&str> { + tool.get("name") + .and_then(Value::as_str) + .or_else(|| tool.pointer("/function/name").and_then(Value::as_str)) +} + +fn is_claude_ask_readonly_tool(name: &str) -> bool { + CLAUDE_ASK_ALLOWED_TOOLS.contains(&name) +} + +fn tool_choice_points_to_removed_tool( + tool_choice: Option<&Value>, + kept_tool_names: &[String], +) -> bool { + let Some(name) = tool_choice.and_then(tool_choice_tool_name) else { + return false; + }; + !kept_tool_names.iter().any(|kept| kept == name) +} + +fn tool_choice_tool_name(tool_choice: &Value) -> Option<&str> { + match tool_choice { + Value::String(value) => match value.as_str() { + "auto" | "none" | "required" => None, + other => Some(other), + }, + Value::Object(object) => object.get("name").and_then(Value::as_str).or_else(|| { + tool_choice + .pointer("/function/name") + .and_then(Value::as_str) + }), + _ => None, + } +} + +fn filter_responses_input_for_claude_ask(input: Value) -> (Value, AskInputFilterStats) { + match input { + Value::Array(items) => { + let (items, stats) = filter_response_items_for_claude_ask(items); + (Value::Array(items), stats) + } + Value::Object(object) => { + let (items, stats) = filter_response_items_for_claude_ask(vec![Value::Object(object)]); + (Value::Array(items), stats) + } + other => (other, AskInputFilterStats::default()), + } +} + +fn filter_response_items_for_claude_ask(items: Vec) -> (Vec, AskInputFilterStats) { + let mut stats = AskInputFilterStats { + original_items: items.len(), + ..Default::default() + }; + let mut allowed_calls = HashMap::new(); + + for item in &items { + if is_ask_allowed_tool_call_item(item) { + if let Some(call_id) = item.get("call_id").and_then(Value::as_str) { + let name = ask_tool_name(item); + allowed_calls.insert(call_id.to_string(), name); + } + } + } + + let mut filtered = Vec::with_capacity(items.len()); + for mut item in items { + if item.get("type").and_then(Value::as_str) == Some("reasoning") { + continue; + } + + if is_ask_tool_output_item(&item) { + if let Some(tool_name) = item + .get("call_id") + .and_then(Value::as_str) + .and_then(|call_id| allowed_calls.get(call_id)) + { + sanitize_ask_mcp_tool_output(tool_name, &mut item); + filtered.push(item); + } + continue; + } + + if is_ask_tool_call_item(&item) { + if item + .get("call_id") + .and_then(Value::as_str) + .is_some_and(|call_id| allowed_calls.contains_key(call_id)) + { + filtered.push(item); + } + continue; + } + + if item.get("role").and_then(Value::as_str) == Some("assistant") { + stats.removed_assistant_messages += 1; + continue; + } + + if should_keep_ask_conversation_item(&item) { + if clean_ask_contextual_content_items(&mut item, &mut stats) { + filtered.push(item); + } + } + } + + stats.kept_items = filtered.len(); + (filtered, stats) +} + +fn sanitize_ask_mcp_tool_output(tool_name: &str, item: &mut Value) { + if !matches!( + tool_name, + "list_mcp_resources" | "list_mcp_resource_templates" + ) { + return; + } + + let output = item.get_mut("output"); + let Some(output) = output else { + return; + }; + + if let Value::String(text) = output { + let fallback_key = if tool_name == "list_mcp_resources" { + "resources" + } else { + "resource_templates" + }; + let mut parsed = serde_json::from_str::(text) + .unwrap_or_else(|_| serde_json::json!({ fallback_key: [] })); + filter_mcp_server_results(&mut parsed); + *text = canonical_json_string(&parsed); + return; + } + + filter_mcp_server_results(output); +} + +fn filter_mcp_server_results(value: &mut Value) { + match value { + Value::Array(items) => { + if items.iter().any(|item| item.get("server").is_some()) { + items.retain(|item| { + item.get("server").and_then(Value::as_str) + == Some(CLAUDE_ASK_PROJECT_MCP_SERVER) + }); + } + for item in items { + filter_mcp_server_results(item); + } + } + Value::Object(object) => { + for value in object.values_mut() { + filter_mcp_server_results(value); + } + } + _ => {} + } +} + +fn is_ask_allowed_tool_call_item(item: &Value) -> bool { + is_ask_tool_call_item(item) && is_claude_ask_readonly_tool(&ask_tool_name(item)) +} + +fn is_ask_tool_call_item(item: &Value) -> bool { + matches!( + item.get("type").and_then(Value::as_str), + Some("function_call" | "custom_tool_call" | "tool_search_call") + ) +} + +fn is_ask_tool_output_item(item: &Value) -> bool { + matches!( + item.get("type").and_then(Value::as_str), + Some("function_call_output" | "custom_tool_call_output" | "tool_search_output") + ) +} + +fn should_keep_ask_conversation_item(item: &Value) -> bool { + let item_type = item.get("type").and_then(Value::as_str); + if item_type.is_some_and(|value| { + value.ends_with("_call") || value.ends_with("_call_output") || value.contains("tool") + }) { + return false; + } + + match item.get("role").and_then(Value::as_str) { + Some("user") | Some("latest_reminder") => true, + Some("assistant") => false, + Some("system") | Some("developer") | Some("tool") => false, + Some(_) => false, + None => matches!( + item_type, + Some("input_text" | "input_image" | "input_file" | "input_audio" | "message") + ), + } +} + +fn clean_ask_contextual_content_items(item: &mut Value, stats: &mut AskInputFilterStats) -> bool { + if let Some(text) = item.get("text").and_then(Value::as_str) { + if let Some(sanitized) = sanitize_environment_context_fragment(text) { + *item.get_mut("text").expect("text exists") = Value::String(sanitized); + } else if is_ask_removed_contextual_fragment(text) { + stats.removed_contextual_fragments += 1; + return false; + } + } + + if let Some(content) = item.get_mut("content") { + match content { + Value::String(text) => { + if let Some(sanitized) = sanitize_environment_context_fragment(text) { + *content = Value::String(sanitized); + } else if is_ask_removed_contextual_fragment(text) { + stats.removed_contextual_fragments += 1; + return false; + } + } + Value::Array(content_items) => { + content_items.retain_mut(|content_item| { + let Some(text) = content_item.get("text").and_then(Value::as_str) else { + return true; + }; + if let Some(sanitized) = sanitize_environment_context_fragment(text) { + *content_item.get_mut("text").expect("text exists") = + Value::String(sanitized); + return true; + } + if is_ask_removed_contextual_fragment(text) { + stats.removed_contextual_fragments += 1; + return false; + } + true + }); + if content_items.is_empty() { + return false; + } + } + _ => {} + } + } + + true +} + +fn is_ask_removed_contextual_fragment(text: &str) -> bool { + let trimmed = text.trim(); + is_agents_instructions_fragment(trimmed) + || is_wrapped_fragment( + trimmed, + "", + ) + || is_wrapped_fragment(trimmed, "") + || is_wrapped_fragment(trimmed, "") +} + +fn sanitize_environment_context_fragment(text: &str) -> Option { + let trimmed = text.trim(); + if !trimmed.starts_with("") || !trimmed.ends_with("") + { + return None; + } + + let mut entries = Vec::new(); + let mut current_environment: Option = None; + + for raw_line in trimmed.lines() { + let line = raw_line.trim(); + if line.starts_with("") && line.ends_with("") { + entries.push((current_environment.clone(), line.to_string())); + } + } + + if entries.is_empty() { + return Some("\n".to_string()); + } + + let has_environment_ids = entries.iter().any(|(environment, _)| environment.is_some()); + let mut out = String::from("\n"); + for (environment, cwd) in entries { + if has_environment_ids { + if let Some(environment) = environment { + out.push_str(&environment); + out.push('\n'); + out.push_str(&cwd); + out.push('\n'); + out.push_str("\n"); + } else { + out.push_str(&cwd); + out.push('\n'); + } + } else { + out.push_str(&cwd); + out.push('\n'); + } + } + out.push_str(""); + Some(out) +} + +fn ask_tool_name(item: &Value) -> String { + item.get("name") + .and_then(Value::as_str) + .or_else(|| item.get("tool_name").and_then(Value::as_str)) + .unwrap_or("unknown") + .to_string() +} pub struct ForwardResult { pub response: ProxyResponse, @@ -106,6 +647,7 @@ pub struct RequestForwarder { current_providers: Arc>>, gemini_shadow: Arc, codex_chat_history: Arc, + interaction_mode: Arc>, /// 故障转移切换管理器 failover_manager: Arc, /// AppHandle,用于发射事件和更新托盘 @@ -194,6 +736,7 @@ impl RequestForwarder { current_providers: Arc>>, gemini_shadow: Arc, codex_chat_history: Arc, + interaction_mode: Arc>, failover_manager: Arc, app_handle: Option, current_provider_id_at_start: String, @@ -215,6 +758,7 @@ impl RequestForwarder { current_providers, gemini_shadow, codex_chat_history, + interaction_mode, failover_manager, app_handle, current_provider_id_at_start, @@ -1267,6 +1811,27 @@ impl RequestForwarder { }; let codex_route_missed = codex_router_configured && routed_provider.is_none(); let provider = routed_provider.as_ref().unwrap_or(provider); + let mut effective_body = body.clone(); + let claude_chat_profile_applied = + if matches!(*self.interaction_mode.read().await, InteractionMode::Chat) { + apply_claude_chat_profile_for_provider( + &mut effective_body, + provider_supports_chat_ask_profiles(provider), + ) + } else { + false + }; + if claude_chat_profile_applied { + let model = effective_body + .get("model") + .and_then(Value::as_str) + .unwrap_or(CLAUDE_CHAT_MODEL); + log::info!( + "[CodexChat] applied chat profile to model={} effective_provider={}", + model, + provider.id + ); + } if let Some(trace_id) = codex_trace_id.as_deref() { let route_id = provider @@ -1352,11 +1917,11 @@ impl RequestForwarder { // Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route // 映射成真实上游模型名,并且未知 route 要直接报错,不能使用默认模型兜底。 let mapped_body = if matches!(app_type, AppType::ClaudeDesktop) { - crate::claude_desktop_config::map_proxy_request_model(body.clone(), provider) + crate::claude_desktop_config::map_proxy_request_model(effective_body.clone(), provider) .map_err(|e| ProxyError::InvalidRequest(e.to_string()))? } else { let (mapped_body, _original_model, _mapped_model) = - super::model_mapper::apply_model_mapping(body.clone(), provider); + super::model_mapper::apply_model_mapping(effective_body.clone(), provider); mapped_body }; @@ -1588,6 +2153,46 @@ impl RequestForwarder { "[Codex] Restored or enriched {restored} cached function call item(s) for Chat upstream" ); } + let claude_chat_profile_reapplied = + if matches!(*self.interaction_mode.read().await, InteractionMode::Chat) { + apply_claude_chat_profile_for_provider( + &mut mapped_body, + provider_supports_chat_ask_profiles(provider), + ) + } else { + false + }; + if claude_chat_profile_reapplied { + let model = mapped_body + .get("model") + .and_then(Value::as_str) + .unwrap_or(CLAUDE_CHAT_MODEL); + log::info!( + "[CodexChat] reapplied chat profile after history enrich to model={} effective_provider={}", + model, + provider.id + ); + } + let claude_ask_profile_applied = + if matches!(*self.interaction_mode.read().await, InteractionMode::Ask) { + apply_claude_ask_profile_for_provider( + &mut mapped_body, + provider_supports_chat_ask_profiles(provider), + ) + } else { + false + }; + if claude_ask_profile_applied { + let model = mapped_body + .get("model") + .and_then(Value::as_str) + .unwrap_or(CLAUDE_CHAT_MODEL); + log::info!( + "[CodexAsk] applied ask profile to model={} effective_provider={}", + model, + provider.id + ); + } super::providers::apply_codex_chat_upstream_model(provider, &mut mapped_body); let reasoning_config = super::providers::resolve_codex_chat_reasoning_config(provider, &mapped_body); @@ -2185,6 +2790,13 @@ impl RequestForwarder { .get("model") .and_then(|v| v.as_str()) .unwrap_or(""); + log_gpt_5_4_mini_request_classification( + request_model, + provider, + &effective_endpoint, + request_bytes_len, + &filtered_body, + ); let responses_lite_fallback_key = codex_responses_lite_fallback_key(&provider.id, &url, request_model); if matches!(app_type, AppType::Codex) @@ -3616,17 +4228,135 @@ fn prepare_upstream_request_body(request_body: Value) -> Value { canonicalize_value(filter_private_params_with_whitelist(request_body, &[])) } -/// 生成 Codex Responses->Chat 出站请求的脱敏形态摘要。 -/// -/// 该摘要只记录顶层字段名、对象/数组形态和工具计数,不记录消息正文、工具参数、 -/// API Key 或任意用户 prompt。上游返回空 400 时可用它定位严格 Chat 接口拒绝的字段组合。 -fn summarize_codex_chat_request_shape(body: &Value) -> String { - let mut parts = Vec::new(); - if let Some(object) = body.as_object() { - let mut keys = object.keys().map(String::as_str).collect::>(); - keys.sort_unstable(); - parts.push(format!("top_keys=[{}]", keys.join(","))); - } else { +fn log_gpt_5_4_mini_request_classification( + request_model: &str, + provider: &Provider, + endpoint: &str, + request_bytes: usize, + body: &Value, +) { + if request_model != "gpt-5.4-mini" { + return; + } + + log::debug!( + "[Gpt54MiniClassify] timestamp={} provider={} endpoint={} request_bytes={} input_count={} messages_count={} tools_count={} tool_names=[{}] output_schema_keys=[{}] response_format_keys=[{}] preview={}", + chrono::Local::now().to_rfc3339(), + provider.id, + endpoint, + request_bytes, + json_array_count(body.get("input")), + json_array_count(body.get("messages")), + json_array_count(body.get("tools")), + gpt_5_4_mini_tool_names(body).join(","), + json_object_keys(body.get("output_schema")).join(","), + json_object_keys(body.get("response_format")).join(","), + gpt_5_4_mini_prompt_preview(body, 160), + ); +} + +fn json_array_count(value: Option<&Value>) -> String { + value + .and_then(Value::as_array) + .map(|values| values.len().to_string()) + .unwrap_or_else(|| "absent".to_string()) +} + +fn json_object_keys(value: Option<&Value>) -> Vec { + let Some(object) = value.and_then(Value::as_object) else { + return Vec::new(); + }; + let mut keys = object.keys().cloned().collect::>(); + keys.sort_unstable(); + keys +} + +fn gpt_5_4_mini_tool_names(body: &Value) -> Vec { + let Some(tools) = body.get("tools").and_then(Value::as_array) else { + return Vec::new(); + }; + + let mut names = tools + .iter() + .filter_map(response_tool_name) + .map(ToString::to_string) + .collect::>(); + names.sort_unstable(); + names.dedup(); + names +} + +fn gpt_5_4_mini_prompt_preview(body: &Value, max_chars: usize) -> String { + let text = body + .get("instructions") + .and_then(Value::as_str) + .or_else(|| first_input_text(body)) + .unwrap_or(""); + compact_log_preview(text, max_chars) +} + +fn first_input_text(body: &Value) -> Option<&str> { + if let Some(value) = body.get("input") { + if let Some(text) = value.as_str() { + return Some(text); + } + if let Some(items) = value.as_array() { + for item in items { + if let Some(text) = item.as_str() { + return Some(text); + } + if let Some(text) = item.get("text").and_then(Value::as_str) { + return Some(text); + } + if let Some(content) = item.get("content").and_then(Value::as_array) { + for content_item in content { + if let Some(text) = content_item.get("text").and_then(Value::as_str) { + return Some(text); + } + } + } + } + } + } + + if let Some(messages) = body.get("messages").and_then(Value::as_array) { + for message in messages { + if let Some(text) = message.get("content").and_then(Value::as_str) { + return Some(text); + } + if let Some(content) = message.get("content").and_then(Value::as_array) { + for content_item in content { + if let Some(text) = content_item.get("text").and_then(Value::as_str) { + return Some(text); + } + } + } + } + } + + None +} + +fn compact_log_preview(text: &str, max_chars: usize) -> String { + let compact = text.split_whitespace().collect::>().join(" "); + let mut preview = compact.chars().take(max_chars).collect::(); + if compact.chars().count() > max_chars { + preview.push_str("..."); + } + preview +} + +/// 生成 Codex Responses->Chat 出站请求的脱敏形态摘要。 +/// +/// 该摘要只记录顶层字段名、对象/数组形态和工具计数,不记录消息正文、工具参数、 +/// API Key 或任意用户 prompt。上游返回空 400 时可用它定位严格 Chat 接口拒绝的字段组合。 +fn summarize_codex_chat_request_shape(body: &Value) -> String { + let mut parts = Vec::new(); + if let Some(object) = body.as_object() { + let mut keys = object.keys().map(String::as_str).collect::>(); + keys.sort_unstable(); + parts.push(format!("top_keys=[{}]", keys.join(","))); + } else { parts.push(format!("body={}", value_for_log(body))); } @@ -3650,6 +4380,15 @@ fn summarize_codex_chat_request_shape(body: &Value) -> String { } else { parts.push("tools=absent".to_string()); } + parts.push(format!( + "assistant_tool_calls={}", + if chat_messages_have_assistant_tool_calls(body) { + "present" + } else { + "absent" + } + )); + parts.push(format!("tool_messages={}", chat_tool_message_count(body))); for key in [ "tool_choice", @@ -3683,6 +4422,32 @@ fn summarize_codex_chat_request_shape(body: &Value) -> String { parts.join(";") } +fn chat_messages_have_assistant_tool_calls(body: &Value) -> bool { + body.get("messages") + .and_then(Value::as_array) + .is_some_and(|messages| { + messages.iter().any(|message| { + message.get("role").and_then(Value::as_str) == Some("assistant") + && message + .get("tool_calls") + .and_then(Value::as_array) + .is_some_and(|tool_calls| !tool_calls.is_empty()) + }) + }) +} + +fn chat_tool_message_count(body: &Value) -> usize { + body.get("messages") + .and_then(Value::as_array) + .map(|messages| { + messages + .iter() + .filter(|message| message.get("role").and_then(Value::as_str) == Some("tool")) + .count() + }) + .unwrap_or(0) +} + /// 把 JSON 值压缩成不含正文内容的形态描述。 fn value_for_shape_log(value: &Value) -> String { match value { @@ -3833,6 +4598,7 @@ mod tests { current_providers: Arc::new(RwLock::new(HashMap::new())), gemini_shadow: Arc::new(GeminiShadowStore::new()), codex_chat_history: Arc::new(CodexChatHistoryStore::default()), + interaction_mode: Arc::new(RwLock::new(InteractionMode::Code)), failover_manager: Arc::new(FailoverSwitchManager::new(db)), app_handle: None, current_provider_id_at_start: String::new(), @@ -3848,6 +4614,1019 @@ mod tests { } } + #[test] + fn claude_chat_profile_removes_agent_instructions_and_tools_without_prompt() { + let mut body = json!({ + "model": "claude-sonnet-5", + "instructions": "You are Codex, an autonomous coding agent.", + "tools": [{"type": "function", "name": "shell_command"}], + "tool_choice": "auto", + "parallel_tool_calls": true, + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "随便聊聊"}] + }, + { + "type": "function_call", + "call_id": "call_1", + "name": "shell_command", + "arguments": "Get-ChildItem" + }, + { + "type": "reasoning", + "summary": [{"text": "Need shell"}] + } + ] + }); + + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); + assert!(body.get("instructions").is_none()); + assert!(body.get("tools").is_none()); + assert!(body.get("tool_choice").is_none()); + assert!(body.get("parallel_tool_calls").is_none()); + + let serialized = body["input"].to_string(); + assert!(serialized.contains("随便聊聊")); + assert!(!serialized.contains("shell_command")); + assert!(!serialized.contains("Get-ChildItem")); + assert!(!serialized.contains("reasoning")); + assert!(!serialized.contains("autonomous coding agent")); + } + + #[test] + fn claude_chat_profile_removes_environment_context_fragment() { + let mut body = json!({ + "model": "claude-sonnet-5", + "input": [{ + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "\nE:\\repo\npowershell\n" + }, + { + "type": "input_text", + "text": "真实用户问题" + } + ] + }] + }); + + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); + let serialized = body["input"].to_string(); + assert!(serialized.contains("真实用户问题")); + assert!(!serialized.contains("environment_context")); + assert!(!serialized.contains("powershell")); + } + + #[test] + fn claude_chat_profile_removes_agents_fragment() { + let mut body = json!({ + "model": "claude-sonnet-5", + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "# AGENTS.md instructions\n\n\nRead AGENTS.md before editing.\n" + }] + }] + }); + + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); + assert_eq!(body["input"].as_array().unwrap().len(), 0); + } + + #[test] + fn claude_chat_profile_removes_codex_internal_context_fragment() { + let mut body = json!({ + "model": "claude-sonnet-5", + "input": [{ + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "\nworkspace metadata\n" + }, + { + "type": "input_text", + "text": "聊聊设计" + } + ] + }] + }); + + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); + let serialized = body["input"].to_string(); + assert!(serialized.contains("聊聊设计")); + assert!(!serialized.contains("codex_internal_context")); + assert!(!serialized.contains("workspace metadata")); + } + + #[test] + fn claude_chat_profile_removes_goal_context_fragment() { + let mut body = json!({ + "model": "claude-sonnet-5", + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "\nactive objective\n" + }] + }] + }); + + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); + assert_eq!(body["input"].as_array().unwrap().len(), 0); + } + + #[test] + fn claude_chat_profile_removes_recommended_plugins_fragment() { + let mut body = json!({ + "model": "claude-sonnet-5", + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "\nplugin candidates\n" + }] + }] + }); + + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); + assert_eq!(body["input"].as_array().unwrap().len(), 0); + } + + #[test] + fn claude_chat_profile_preserves_normal_text_mentioning_agents() { + let text = "普通用户问题:AGENTS.md 是什么?里面的 标签怎么理解?"; + let mut body = json!({ + "model": "claude-sonnet-5", + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": text + }] + }] + }); + + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); + assert_eq!(body["input"][0]["content"][0]["text"], text); + } + + #[test] + fn claude_chat_profile_preserves_normal_text_mentioning_environment_context() { + let text = "普通用户问题:如果文档里出现 ,应该怎么解释?"; + let mut body = json!({ + "model": "claude-sonnet-5", + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": text + }] + }] + }); + + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); + assert_eq!(body["input"][0]["content"][0]["text"], text); + } + + #[test] + fn deepseek_chat_profile_removes_agent_context_and_tools() { + let mut body = json!({ + "model": "deepseek-v4-flash", + "instructions": "You are Codex, an autonomous coding agent.", + "tools": [{"type": "function", "name": "shell_command"}], + "tool_choice": "auto", + "parallel_tool_calls": true, + "input": [{ + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "\nE:\\repo\npowershell\n" + }, + { + "type": "input_text", + "text": "只聊天" + } + ] + }] + }); + + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); + assert!(body.get("instructions").is_none()); + assert!(body.get("tools").is_none()); + assert!(body.get("tool_choice").is_none()); + assert!(body.get("parallel_tool_calls").is_none()); + + let serialized = body["input"].to_string(); + assert!(serialized.contains("只聊天")); + assert!(!serialized.contains("environment_context")); + assert!(!serialized.contains("shell_command")); + assert!(!serialized.contains("autonomous coding agent")); + } + + #[test] + fn custom_routed_chat_profile_applies_to_unknown_model() { + let mut body = json!({ + "model": "kimi-k2-local", + "instructions": "You are Codex, an autonomous coding agent.", + "tools": [{"type": "function", "name": "shell_command"}], + "tool_choice": "auto", + "input": [{ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "纯聊天"}] + }] + }); + + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); + assert!(body.get("instructions").is_none()); + assert!(body.get("tools").is_none()); + assert!(body.get("tool_choice").is_none()); + assert!(body["input"].to_string().contains("纯聊天")); + } + + #[test] + fn chat_profile_removes_tool_history_restored_after_enrich() { + let mut body = json!({ + "model": "kimi-k2-local", + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "只聊天"}] + }, + { + "type": "function_call", + "call_id": "call_1", + "name": "read_mcp_resource", + "arguments": "{\"uri\":\"ccswitch://project/file/src-tauri/src/proxy/forwarder.rs\"}" + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "fn secret_project_code() {}" + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "之前看过代码"}] + } + ] + }); + + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); + let serialized = body["input"].to_string(); + assert!(serialized.contains("只聊天")); + assert!(serialized.contains("之前看过代码")); + assert!(!serialized.contains("read_mcp_resource")); + assert!(!serialized.contains("secret_project_code")); + assert!(!serialized.contains("function_call")); + assert!(!serialized.contains("function_call_output")); + } + + #[test] + fn chat_profile_does_not_apply_to_unknown_model_without_custom_route() { + let mut body = json!({ + "model": "unknown-built-in", + "instructions": "keep me", + "tools": [{"type": "function", "name": "shell_command"}], + "input": "hello" + }); + + assert!(!apply_claude_chat_profile_for_provider(&mut body, false)); + assert_eq!(body["instructions"], "keep me"); + assert!(body.get("tools").is_some()); + } + + #[test] + fn named_profile_model_does_not_apply_without_custom_route() { + let mut body = json!({ + "model": "deepseek-v4-flash", + "instructions": "keep me", + "tools": [{"type": "function", "name": "shell_command"}], + "input": "hello" + }); + + assert!(!apply_claude_chat_profile_for_provider(&mut body, false)); + assert_eq!(body["instructions"], "keep me"); + assert!(body.get("tools").is_some()); + } + + #[test] + fn claude_ask_profile_keeps_allowed_mcp_call_and_output_only() { + let mut body = json!({ + "model": "claude-sonnet-5", + "instructions": "agent instructions", + "tools": [ + {"type": "function", "name": "read_mcp_resource"}, + {"type": "function", "name": "shell"}, + {"type": "function", "name": "apply_patch"} + ], + "tool_choice": "auto", + "parallel_tool_calls": true, + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Inspect structure"}] + }, + { + "type": "reasoning", + "summary": [{"text": "Need shell"}] + }, + { + "type": "function_call", + "call_id": "shell_1", + "name": "shell", + "arguments": "Get-ChildItem" + }, + { + "type": "function_call_output", + "call_id": "shell_1", + "output": "tools/\ndownloads/" + }, + { + "type": "function_call", + "call_id": "mcp_1", + "name": "read_mcp_resource", + "arguments": "{\"server\":\"ccswitch_readonly\",\"uri\":\"ccswitch://project/tree\"}" + }, + { + "type": "function_call_output", + "call_id": "mcp_1", + "output": "src/\nsrc-tauri/" + }, + { + "type": "function_call", + "call_id": "patch_1", + "name": "apply_patch", + "arguments": "*** Begin Patch" + }, + { + "type": "function_call_output", + "call_id": "patch_1", + "output": "patched" + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "I saw tools and downloads."}] + } + ] + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + assert!(body.get("instructions").is_none()); + let tools = body["tools"].as_array().expect("allowed tools"); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["name"], "read_mcp_resource"); + assert_eq!(body["tool_choice"], "auto"); + assert_eq!(body["parallel_tool_calls"], true); + + let serialized = body["input"].to_string(); + assert!(serialized.contains("read_mcp_resource")); + assert!(serialized.contains("ccswitch://project/tree")); + assert!(serialized.contains("src-tauri/")); + assert!(!serialized.contains("shell")); + assert!(!serialized.contains("Get-ChildItem")); + assert!(!serialized.contains("tools/")); + assert!(!serialized.contains("apply_patch")); + assert!(!serialized.contains("patched")); + assert!(!serialized.contains("agent instructions")); + assert!(!serialized.contains(r#""type":"reasoning""#)); + } + + #[test] + fn claude_ask_profile_sanitizes_single_environment_context_to_cwd_only() { + let mut body = json!({ + "model": "claude-sonnet-5", + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "\n C:\\project\n powershell\n 2026-07-07\n Asia/Shanghai\n enabled\n \n enabled\n" + }] + }] + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + let text = body["input"][0]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains(r#"C:\project"#)); + assert!(!text.contains("powershell")); + assert!(!text.contains("current_date")); + assert!(!text.contains("timezone")); + assert!(!text.contains("network")); + assert!(!text.contains("filesystem")); + assert!(!text.contains("permission_profile")); + assert!(!text.contains("unrestricted")); + assert!(!text.contains("restricted")); + assert!(!text.contains("subagents")); + } + + #[test] + fn claude_ask_profile_sanitizes_multi_environment_context_to_ids_and_cwd() { + let mut body = json!({ + "model": "claude-sonnet-5", + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "\n \n E:\\repo\n powershell\n unrestricted\n \n \n F:\\other\n enabled\n \n" + }] + }] + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + let text = body["input"][0]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains(r#""#)); + assert!(text.contains(r#"E:\repo"#)); + assert!(text.contains(r#""#)); + assert!(text.contains(r#"F:\other"#)); + assert!(!text.contains("powershell")); + assert!(!text.contains("filesystem")); + assert!(!text.contains("unrestricted")); + assert!(!text.contains("network")); + } + + #[test] + fn claude_ask_profile_sanitizes_top_level_environment_text() { + let mut body = json!({ + "model": "claude-sonnet-5", + "input": [{ + "type": "message", + "role": "user", + "text": "\nE:\\repo\npowershell\nunrestricted\n" + }] + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + let text = body["input"][0]["text"].as_str().unwrap(); + assert!(text.contains(r#"E:\repo"#)); + assert!(!text.contains("powershell")); + assert!(!text.contains("filesystem")); + assert!(!text.contains("unrestricted")); + } + + #[test] + fn claude_ask_profile_sanitizes_string_content_environment_context() { + let mut body = json!({ + "model": "claude-sonnet-5", + "input": [{ + "type": "message", + "role": "user", + "content": "\nE:\\repo\npowershell\nenabled\n" + }] + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + let text = body["input"][0]["content"].as_str().unwrap(); + assert!(text.contains(r#"E:\repo"#)); + assert!(!text.contains("powershell")); + assert!(!text.contains("network")); + } + + #[test] + fn claude_ask_profile_removes_agents_fragment() { + let agents = "# AGENTS.md instructions\n\n\n- Read files first.\n- Do not weaken safety gates.\n"; + let mut body = json!({ + "model": "claude-sonnet-5", + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": agents + }] + }] + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + let serialized = body["input"].to_string(); + assert!(!serialized.contains("AGENTS.md instructions")); + assert!(!serialized.contains("Read files first")); + assert!(!serialized.contains("")); + } + + #[test] + fn claude_ask_profile_removes_codex_context_fragments_and_old_assistant_text() { + let mut body = json!({ + "model": "claude-sonnet-5", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "\nCanva\nFigma\nGmail\n" + }, + { + "type": "input_text", + "text": "\nold objective\n" + }, + { + "type": "input_text", + "text": "\ninternal routing notes\n" + }, + { + "type": "input_text", + "text": "检查当前项目结构" + } + ] + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "我运行在 macOS 环境中,无法直接访问 Windows 路径。"}] + } + ] + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + let serialized = body["input"].to_string(); + assert!(serialized.contains("检查当前项目结构")); + assert!(!serialized.contains("recommended_plugins")); + assert!(!serialized.contains("Canva")); + assert!(!serialized.contains("goal_context")); + assert!(!serialized.contains("old objective")); + assert!(!serialized.contains("codex_internal_context")); + assert!(!serialized.contains("internal routing notes")); + assert!(!serialized.contains("macOS")); + assert!(!serialized.contains("无法直接访问")); + } + + #[test] + fn claude_ask_profile_leaves_normal_user_environment_text_unchanged() { + let text = "普通说明:这里提到 但不是机器生成的完整环境片段。"; + let mut body = json!({ + "model": "claude-sonnet-5", + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": text + }] + }] + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + assert_eq!(body["input"][0]["content"][0]["text"], text); + } + + #[test] + fn claude_ask_profile_keeps_allowed_mcp_call_output_while_sanitizing_environment() { + let mut body = json!({ + "model": "claude-sonnet-5", + "tools": [ + {"type": "function", "name": "read_mcp_resource"}, + {"type": "function", "name": "shell_command"} + ], + "input": [ + { + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "\nE:\\repo\npowershell\nunrestricted\n" + }] + }, + { + "type": "function_call", + "call_id": "mcp_1", + "name": "read_mcp_resource", + "arguments": "{\"server\":\"ccswitch_readonly\",\"uri\":\"ccswitch://project/tree\"}" + }, + { + "type": "function_call_output", + "call_id": "mcp_1", + "output": "src/\nsrc-tauri/" + } + ] + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + let serialized = body["input"].to_string(); + let env_text = body["input"][0]["content"][0]["text"].as_str().unwrap(); + assert!(serialized.contains("read_mcp_resource")); + assert!(serialized.contains("ccswitch://project/tree")); + assert!(serialized.contains("src-tauri/")); + assert!(env_text.contains(r#"E:\repo"#)); + assert!(!env_text.contains("powershell")); + assert!(!env_text.contains("unrestricted")); + } + + #[test] + fn claude_ask_profile_preserves_allowed_readonly_tool() { + let mut body = json!({ + "model": "claude-sonnet-5", + "tools": [ + {"type": "function", "name": "list_mcp_resources"}, + {"type": "function", "name": "list_mcp_resource_templates"}, + { + "type": "function", + "name": "read_mcp_resource", + "parameters": { + "type": "object", + "properties": { + "server": {"type": "string"}, + "uri": {"type": "string"} + }, + "required": ["uri"] + } + }, + {"type": "function", "name": "shell"}, + {"type": "function", "name": "apply_patch"} + ], + "tool_choice": "required", + "parallel_tool_calls": true, + "input": "Inspect only." + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + let tools = body["tools"].as_array().expect("allowed tools"); + assert_eq!(tools.len(), 3); + assert_eq!(tools[0]["name"], "list_mcp_resources"); + assert_eq!(tools[1]["name"], "list_mcp_resource_templates"); + assert_eq!(tools[2]["name"], "read_mcp_resource"); + assert!(tools[2]["parameters"]["properties"]["server"] + .get("enum") + .is_none()); + assert!(!tools[2]["parameters"]["required"] + .as_array() + .unwrap() + .iter() + .any(|value| value.as_str() == Some("server"))); + assert_eq!(body["tool_choice"], "required"); + assert_eq!(body["parallel_tool_calls"], true); + } + + #[test] + fn claude_ask_profile_filters_mcp_list_outputs_to_readonly_server() { + let mut body = json!({ + "model": "claude-sonnet-5", + "tools": [ + {"type": "function", "name": "list_mcp_resources"}, + {"type": "function", "name": "list_mcp_resource_templates"}, + {"type": "function", "name": "read_mcp_resource"} + ], + "input": [ + { + "type": "function_call", + "call_id": "resources_1", + "name": "list_mcp_resources", + "arguments": "{}" + }, + { + "type": "function_call_output", + "call_id": "resources_1", + "output": { + "resources": [ + {"server": "ccswitch_readonly", "uri": "ccswitch://project/tree"}, + {"server": "codex_apps", "uri": "gmail://labels"}, + {"server": "Canva", "uri": "canva://designs"} + ] + } + }, + { + "type": "function_call", + "call_id": "templates_1", + "name": "list_mcp_resource_templates", + "arguments": "{}" + }, + { + "type": "function_call_output", + "call_id": "templates_1", + "output": "{\"resource_templates\":[{\"server\":\"ccswitch_readonly\",\"uriTemplate\":\"ccswitch://project/file/{path}\"},{\"server\":\"Figma\",\"uriTemplate\":\"figma://file/{id}\"}]}" + } + ] + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + let serialized = body["input"].to_string(); + assert!(serialized.contains("ccswitch_readonly")); + assert!(serialized.contains("ccswitch://project/tree")); + assert!(serialized.contains("ccswitch://project/file/{path}")); + assert!(!serialized.contains("codex_apps")); + assert!(!serialized.contains("gmail://labels")); + assert!(!serialized.contains("Canva")); + assert!(!serialized.contains("Figma")); + } + + #[test] + fn claude_ask_profile_preserves_read_resource_call_for_other_server() { + let mut body = json!({ + "model": "claude-sonnet-5", + "tools": [ + {"type": "function", "name": "read_mcp_resource"} + ], + "input": [ + { + "type": "function_call", + "call_id": "read_1", + "name": "read_mcp_resource", + "arguments": "{\"server\":\"codex_apps\",\"uri\":\"gmail://labels\"}" + }, + { + "type": "function_call_output", + "call_id": "read_1", + "output": "global mail data" + }, + { + "type": "function_call", + "call_id": "read_2", + "name": "read_mcp_resource", + "arguments": "{\"server\":\"ccswitch_readonly\",\"uri\":\"ccswitch://project/tree\"}" + }, + { + "type": "function_call_output", + "call_id": "read_2", + "output": "src-tauri/" + } + ] + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + let serialized = body["input"].to_string(); + assert!(serialized.contains("codex_apps")); + assert!(serialized.contains("global mail data")); + assert!(serialized.contains("ccswitch_readonly")); + assert!(serialized.contains("src-tauri/")); + } + + #[test] + fn claude_ask_profile_removes_shell_write_and_patch_tools() { + let mut body = json!({ + "model": "claude-sonnet-5", + "tools": [ + {"type": "function", "name": "shell"}, + {"type": "function", "name": "powershell"}, + {"type": "function", "name": "write_file"}, + {"type": "function", "name": "edit_file"}, + {"type": "function", "name": "apply_patch"} + ], + "tool_choice": "auto", + "parallel_tool_calls": true, + "input": "Inspect only." + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + assert!(body.get("tools").is_none()); + assert_eq!(body["tool_choice"], "auto"); + assert_eq!(body["parallel_tool_calls"], true); + } + + #[test] + fn claude_ask_profile_removes_tool_choice_for_filtered_tool() { + let mut body = json!({ + "model": "claude-sonnet-5", + "tools": [ + {"type": "function", "name": "read_mcp_resource"}, + {"type": "function", "name": "shell_command"} + ], + "tool_choice": { + "type": "function", + "function": {"name": "shell_command"} + }, + "parallel_tool_calls": true, + "input": "Inspect only." + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + let tools = body["tools"].as_array().expect("allowed tools"); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["name"], "read_mcp_resource"); + assert!(body.get("tool_choice").is_none()); + assert_eq!(body["parallel_tool_calls"], true); + } + + #[test] + fn claude_ask_profile_removes_unpaired_tool_output() { + let mut body = json!({ + "model": "claude-sonnet-5", + "input": [{ + "type": "function_call_output", + "call_id": "missing_call", + "output": "orphan result" + }] + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + let serialized = body["input"].to_string(); + assert!(!serialized.contains("orphan result")); + } + + #[test] + fn deepseek_ask_profile_keeps_readonly_mcp_and_removes_shell_protocol() { + let mut body = json!({ + "model": "deepseek-v4-pro", + "instructions": "agent instructions", + "tools": [ + {"type": "function", "name": "list_mcp_resources"}, + {"type": "function", "name": "read_mcp_resource"}, + {"type": "function", "name": "shell_command"} + ], + "tool_choice": { + "type": "function", + "function": {"name": "shell_command"} + }, + "parallel_tool_calls": true, + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Inspect only"}] + }, + { + "type": "function_call", + "call_id": "shell_1", + "name": "shell_command", + "arguments": "Get-ChildItem" + }, + { + "type": "function_call_output", + "call_id": "shell_1", + "output": "private shell result" + }, + { + "type": "function_call", + "call_id": "mcp_1", + "name": "read_mcp_resource", + "arguments": "{\"server\":\"ccswitch_readonly\",\"uri\":\"ccswitch://project/tree\"}" + }, + { + "type": "function_call_output", + "call_id": "mcp_1", + "output": "src-tauri/" + } + ] + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + assert!(body.get("instructions").is_none()); + assert!(body.get("tool_choice").is_none()); + assert_eq!(body["parallel_tool_calls"], true); + + let tools = body["tools"].as_array().expect("allowed tools"); + assert_eq!(tools.len(), 2); + assert_eq!(tools[0]["name"], "list_mcp_resources"); + assert_eq!(tools[1]["name"], "read_mcp_resource"); + + let serialized = body["input"].to_string(); + assert!(serialized.contains("read_mcp_resource")); + assert!(serialized.contains("ccswitch://project/tree")); + assert!(serialized.contains("src-tauri/")); + assert!(!serialized.contains("shell_command")); + assert!(!serialized.contains("Get-ChildItem")); + assert!(!serialized.contains("private shell result")); + assert!(!serialized.contains("agent instructions")); + } + + #[test] + fn custom_routed_ask_profile_applies_to_unknown_model() { + let mut body = json!({ + "model": "qwen-local-custom", + "instructions": "agent instructions", + "tools": [ + {"type": "function", "name": "read_mcp_resource"}, + {"type": "function", "name": "shell_command"} + ], + "input": [ + { + "type": "function_call", + "call_id": "mcp_1", + "name": "read_mcp_resource", + "arguments": "{\"server\":\"ccswitch_readonly\",\"uri\":\"ccswitch://project/tree\"}" + }, + { + "type": "function_call_output", + "call_id": "mcp_1", + "output": "src-tauri/" + }, + { + "type": "function_call", + "call_id": "shell_1", + "name": "shell_command", + "arguments": "Get-ChildItem" + } + ] + }); + + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); + assert!(body.get("instructions").is_none()); + let tools = body["tools"].as_array().expect("allowed tools"); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["name"], "read_mcp_resource"); + + let serialized = body["input"].to_string(); + assert!(serialized.contains("read_mcp_resource")); + assert!(serialized.contains("src-tauri/")); + assert!(!serialized.contains("shell_command")); + assert!(!serialized.contains("Get-ChildItem")); + } + + #[test] + fn provider_profile_detection_uses_routed_non_managed_providers_only() { + let mut routed = test_provider_with_type(None); + routed.settings_config = json!({ + "codexResolvedRouteId": "qwen-local", + "codexResolvedRouteMatched": true + }); + assert!(provider_supports_chat_ask_profiles(&routed)); + + let mut codex_oauth = test_provider_with_type(Some("codex_oauth")); + codex_oauth.settings_config = json!({ + "codexResolvedRouteId": "official", + "codexResolvedRouteMatched": true + }); + assert!(!provider_supports_chat_ask_profiles(&codex_oauth)); + } + + #[test] + fn gpt_5_4_mini_classification_helpers_are_shape_only() { + let body = json!({ + "model": "gpt-5.4-mini", + "instructions": " classify this request without leaking the full body ", + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "secret user text"}] + } + ], + "messages": [ + {"role": "user", "content": "message text"} + ], + "tools": [ + {"type": "function", "name": "beta_tool"}, + {"type": "function", "function": {"name": "alpha_tool"}}, + {"type": "function", "name": "beta_tool"} + ], + "output_schema": { + "schema": {}, + "name": "result" + }, + "response_format": { + "type": "json_schema", + "json_schema": {} + } + }); + + assert_eq!(json_array_count(body.get("input")), "1"); + assert_eq!(json_array_count(body.get("messages")), "1"); + assert_eq!(json_array_count(body.get("tools")), "3"); + assert_eq!( + gpt_5_4_mini_tool_names(&body), + vec!["alpha_tool".to_string(), "beta_tool".to_string()] + ); + assert_eq!( + json_object_keys(body.get("output_schema")), + vec!["name".to_string(), "schema".to_string()] + ); + assert_eq!( + json_object_keys(body.get("response_format")), + vec!["json_schema".to_string(), "type".to_string()] + ); + assert_eq!( + gpt_5_4_mini_prompt_preview(&body, 160), + "classify this request without leaking the full body" + ); + } + + #[test] + fn gpt_5_4_mini_prompt_preview_falls_back_to_first_input_and_truncates() { + let long_text = format!("{}{}", "a".repeat(170), " tail should not appear"); + let body = json!({ + "model": "gpt-5.4-mini", + "input": [{ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": long_text}] + }] + }); + + let preview = gpt_5_4_mini_prompt_preview(&body, 160); + assert_eq!(preview.chars().count(), 163); + assert!(preview.ends_with("...")); + assert!(!preview.contains("tail should not appear")); + } + // 验证只有上游明确返回 Responses-Lite 不支持时,才触发剥头重试。 #[test] fn codex_responses_lite_error_triggers_retry_without_header() { diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index 67571b7a4e..318e144ed7 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -290,6 +290,7 @@ impl RequestContext { state.current_providers.clone(), state.gemini_shadow.clone(), state.codex_chat_history.clone(), + state.interaction_mode.clone(), state.failover_manager.clone(), state.app_handle.clone(), self.current_provider_id.clone(), diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index a88a76e7bf..ea682844de 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -3294,7 +3294,7 @@ mod tests { should_convert_codex_responses_to_chat, }, server::ProxyState, - types::{ProxyConfig, ProxyStatus}, + types::{InteractionMode, ProxyConfig, ProxyStatus}, ProxyError, }, }; @@ -4572,6 +4572,7 @@ data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\"}}\n provider_router: Arc::new(ProviderRouter::new(db.clone())), gemini_shadow: Arc::new(GeminiShadowStore::default()), codex_chat_history: Arc::new(CodexChatHistoryStore::default()), + interaction_mode: Arc::new(RwLock::new(InteractionMode::Code)), app_handle: None, failover_manager: Arc::new(FailoverSwitchManager::new(db)), } diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index 123380d8fe..16b9e1af04 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -289,6 +289,7 @@ pub fn responses_to_chat_completions_with_reasoning_text_only_and_cache( ) -> Result { let mut result = json!({}); let tool_context = build_codex_tool_context_from_request(&body); + let preserve_reasoning_content = codex_chat_preserve_reasoning_content(reasoning_config); let text_only_model = text_only_override.unwrap_or(false) || body .get("model") @@ -315,6 +316,7 @@ pub fn responses_to_chat_completions_with_reasoning_text_only_and_cache( input, &mut messages, &tool_context, + preserve_reasoning_content, text_only_model, )?; } @@ -385,6 +387,12 @@ pub fn responses_to_chat_completions_with_reasoning_text_only_and_cache( Ok(result) } +fn codex_chat_preserve_reasoning_content(config: Option<&CodexChatReasoningConfig>) -> bool { + config + .and_then(|config| config.output_format.as_deref()) + .is_some_and(|format| format.eq_ignore_ascii_case("reasoning_content")) +} + /// 按 provider/route 明确声明的能力透传 OpenAI prompt cache 参数。 /// /// DeepSeek、GLM/Z.AI、Qwen/DashScope 的自动前缀缓存不需要这些 OpenAI 私有字段; @@ -725,6 +733,7 @@ fn append_responses_input_as_chat_messages( input: &Value, messages: &mut Vec, tool_context: &CodexToolContext, + preserve_reasoning_content: bool, text_only_model: bool, ) -> Result<(), ProxyError> { let mut pending_tool_calls = Vec::new(); @@ -747,6 +756,7 @@ fn append_responses_input_as_chat_messages( &mut pending_reasoning, &mut last_assistant_index, tool_context, + preserve_reasoning_content, text_only_model, )?; } @@ -759,6 +769,7 @@ fn append_responses_input_as_chat_messages( &mut pending_reasoning, &mut last_assistant_index, tool_context, + preserve_reasoning_content, text_only_model, )?; } @@ -770,8 +781,11 @@ fn append_responses_input_as_chat_messages( &mut pending_tool_calls, &mut pending_reasoning, &mut last_assistant_index, + preserve_reasoning_content, ); - backfill_tool_call_reasoning_placeholders(messages); + if preserve_reasoning_content { + backfill_tool_call_reasoning_placeholders(messages); + } Ok(()) } @@ -782,6 +796,7 @@ fn append_responses_item_as_chat_message( pending_reasoning: &mut Option, last_assistant_index: &mut Option, tool_context: &CodexToolContext, + preserve_reasoning_content: bool, text_only_model: bool, ) -> Result<(), ProxyError> { let item_type = item.get("type").and_then(|v| v.as_str()); @@ -807,6 +822,7 @@ fn append_responses_item_as_chat_message( pending_tool_calls, pending_reasoning, last_assistant_index, + preserve_reasoning_content, ); let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or(""); let output = match item.get("output") { @@ -826,6 +842,7 @@ fn append_responses_item_as_chat_message( pending_tool_calls, pending_reasoning, last_assistant_index, + preserve_reasoning_content, ); let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or(""); let output = canonical_json_string(item); @@ -837,7 +854,8 @@ fn append_responses_item_as_chat_message( } Some("reasoning") => { let reasoning = responses_reasoning_item_text(item); - let attached_to_previous = pending_tool_calls.is_empty() + let attached_to_previous = preserve_reasoning_content + && pending_tool_calls.is_empty() && attach_reasoning_to_last_assistant(messages, *last_assistant_index, &reasoning); if !attached_to_previous { append_pending_reasoning(pending_reasoning, reasoning); @@ -849,6 +867,7 @@ fn append_responses_item_as_chat_message( pending_tool_calls, pending_reasoning, last_assistant_index, + preserve_reasoning_content, ); let role = item .get("role") @@ -865,7 +884,11 @@ fn append_responses_item_as_chat_message( }); if role == "assistant" { let mut message = message; - attach_pending_reasoning_to_assistant(&mut message, pending_reasoning); + attach_pending_reasoning_to_assistant( + &mut message, + pending_reasoning, + preserve_reasoning_content, + ); update_last_assistant_index(messages, &message, last_assistant_index); messages.push(message); return Ok(()); @@ -881,11 +904,13 @@ fn append_responses_item_as_chat_message( pending_tool_calls, pending_reasoning, last_assistant_index, + preserve_reasoning_content, ); if item.get("role").is_some() || item.get("content").is_some() { let message = responses_message_item_to_chat_message( item, pending_reasoning, + preserve_reasoning_content, text_only_model, ); update_last_assistant_index(messages, &message, last_assistant_index); @@ -898,11 +923,13 @@ fn append_responses_item_as_chat_message( pending_tool_calls, pending_reasoning, last_assistant_index, + preserve_reasoning_content, ); if item.get("role").is_some() || item.get("content").is_some() { let message = responses_message_item_to_chat_message( item, pending_reasoning, + preserve_reasoning_content, text_only_model, ); update_last_assistant_index(messages, &message, last_assistant_index); @@ -919,6 +946,7 @@ fn flush_pending_tool_calls( pending_tool_calls: &mut Vec, pending_reasoning: &mut Option, last_assistant_index: &mut Option, + preserve_reasoning_content: bool, ) { if pending_tool_calls.is_empty() { return; @@ -929,7 +957,11 @@ fn flush_pending_tool_calls( "content": null, "tool_calls": std::mem::take(pending_tool_calls) }); - attach_pending_reasoning_to_assistant(&mut message, pending_reasoning); + attach_pending_reasoning_to_assistant( + &mut message, + pending_reasoning, + preserve_reasoning_content, + ); *last_assistant_index = Some(messages.len()); messages.push(message); } @@ -937,6 +969,7 @@ fn flush_pending_tool_calls( fn responses_message_item_to_chat_message( item: &Value, pending_reasoning: &mut Option, + preserve_reasoning_content: bool, text_only_model: bool, ) -> Value { let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("user"); @@ -953,7 +986,11 @@ fn responses_message_item_to_chat_message( if chat_role == "assistant" { append_pending_reasoning(pending_reasoning, responses_message_reasoning_text(item)); - attach_pending_reasoning_to_assistant(&mut message, pending_reasoning); + attach_pending_reasoning_to_assistant( + &mut message, + pending_reasoning, + preserve_reasoning_content, + ); } else if pending_reasoning.is_some() { pending_reasoning.take(); } @@ -1034,7 +1071,13 @@ fn append_unique_pending_reasoning( fn attach_pending_reasoning_to_assistant( message: &mut Value, pending_reasoning: &mut Option, + preserve_reasoning_content: bool, ) { + if !preserve_reasoning_content { + pending_reasoning.take(); + return; + } + let Some(reasoning) = pending_reasoning.take() else { return; }; @@ -3105,6 +3148,42 @@ mod tests { assert_eq!(out[3]["content"], "U2"); } + fn reasoning_content_config() -> CodexChatReasoningConfig { + CodexChatReasoningConfig { + output_format: Some("reasoning_content".to_string()), + ..CodexChatReasoningConfig::default() + } + } + + #[test] + fn responses_request_to_chat_drops_reasoning_content_by_default() { + let input = json!({ + "model": "claude-sonnet-5", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "read_file", + "arguments": "{\"path\":\"README.md\"}", + "reasoning_content": "Do not send this to generic chat upstreams." + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "Readme content" + } + ] + }); + + let result = responses_to_chat_completions(input).unwrap(); + let messages = result["messages"].as_array().unwrap(); + + assert_eq!(messages[0]["role"], "assistant"); + assert_eq!(messages[0]["tool_calls"][0]["id"], "call_1"); + assert!(messages[0].get("reasoning_content").is_none()); + assert_eq!(messages[1]["role"], "tool"); + } + #[test] fn responses_request_to_chat_passes_reasoning_content_back_to_assistant_message() { let input = json!({ @@ -3131,7 +3210,8 @@ mod tests { ] }); - let result = responses_to_chat_completions(input).unwrap(); + let config = reasoning_content_config(); + let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); let messages = result["messages"].as_array().unwrap(); assert_eq!(messages[0]["role"], "assistant"); @@ -3168,7 +3248,8 @@ mod tests { ] }); - let result = responses_to_chat_completions(input).unwrap(); + let config = reasoning_content_config(); + let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); let messages = result["messages"].as_array().unwrap(); assert_eq!(messages[0]["role"], "assistant"); @@ -3195,7 +3276,8 @@ mod tests { ] }); - let result = responses_to_chat_completions(input).unwrap(); + let config = reasoning_content_config(); + let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); let messages = result["messages"].as_array().unwrap(); assert_eq!(messages[0]["role"], "assistant"); @@ -3229,7 +3311,8 @@ mod tests { ] }); - let result = responses_to_chat_completions(input).unwrap(); + let config = reasoning_content_config(); + let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); let messages = result["messages"].as_array().unwrap(); assert_eq!(messages[0]["role"], "assistant"); @@ -3258,7 +3341,8 @@ mod tests { ] }); - let result = responses_to_chat_completions(input).unwrap(); + let config = reasoning_content_config(); + let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); let messages = result["messages"].as_array().unwrap(); assert_eq!(messages[0]["role"], "assistant"); @@ -3288,7 +3372,8 @@ mod tests { ] }); - let result = responses_to_chat_completions(input).unwrap(); + let config = reasoning_content_config(); + let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); let messages = result["messages"].as_array().unwrap(); assert_eq!(messages[0]["role"], "assistant"); @@ -3320,7 +3405,8 @@ mod tests { ] }); - let result = responses_to_chat_completions(input).unwrap(); + let config = reasoning_content_config(); + let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); let messages = result["messages"].as_array().unwrap(); assert_eq!(messages[0]["role"], "assistant"); diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 96ab49ebf8..d6ed2e1b08 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -820,7 +820,7 @@ mod tests { use crate::proxy::providers::{ codex_chat_history::CodexChatHistoryStore, gemini_shadow::GeminiShadowStore, }; - use crate::proxy::types::{ProxyConfig, ProxyStatus}; + use crate::proxy::types::{InteractionMode, ProxyConfig, ProxyStatus}; use rust_decimal::Decimal; use std::collections::HashMap; use std::str::FromStr; @@ -942,6 +942,7 @@ mod tests { provider_router: Arc::new(ProviderRouter::new(db.clone())), gemini_shadow: Arc::new(GeminiShadowStore::default()), codex_chat_history: Arc::new(CodexChatHistoryStore::default()), + interaction_mode: Arc::new(RwLock::new(InteractionMode::Code)), app_handle: None, failover_manager: Arc::new(FailoverSwitchManager::new(db)), } diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index d219a2c335..f67fe1c720 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -44,6 +44,8 @@ pub struct ProxyState { pub gemini_shadow: Arc, /// Codex Chat bridge history,用于恢复 previous_response_id 指向的 tool call pub codex_chat_history: Arc, + /// Codex 交互模式:运行时内存态,不写入 provider 配置 + pub interaction_mode: Arc>, /// AppHandle,用于发射事件和更新托盘菜单 pub app_handle: Option, /// 故障转移切换管理器 @@ -72,7 +74,23 @@ impl ProxyServer { db: Arc, app_handle: Option, ) -> Self { - Self::new_with_mode(config, db, app_handle, ProxyServerMode::FullProxy) + Self::new_with_interaction_mode( + config, + db, + app_handle, + ProxyServerMode::FullProxy, + Arc::new(RwLock::new(InteractionMode::Code)), + ) + } + + pub fn new_with_interaction_mode( + config: ProxyConfig, + db: Arc, + app_handle: Option, + mode: ProxyServerMode, + interaction_mode: Arc>, + ) -> Self { + Self::new_with_mode(config, db, app_handle, mode, interaction_mode) } pub fn new_external_openai_api( @@ -85,6 +103,7 @@ impl ProxyServer { db, app_handle, ProxyServerMode::ExternalOpenAiApiOnly, + Arc::new(RwLock::new(InteractionMode::Code)), ) } @@ -93,6 +112,7 @@ impl ProxyServer { db: Arc, app_handle: Option, mode: ProxyServerMode, + interaction_mode: Arc>, ) -> Self { // 创建共享的 ProviderRouter(熔断器状态将跨所有请求保持) let provider_router = Arc::new(ProviderRouter::new(db.clone())); @@ -108,6 +128,7 @@ impl ProxyServer { provider_router, gemini_shadow: Arc::new(GeminiShadowStore::default()), codex_chat_history: Arc::new(CodexChatHistoryStore::default()), + interaction_mode, app_handle, failover_manager, }; diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index f2dfc5800d..392bc8c461 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -1,5 +1,15 @@ use serde::{Deserialize, Serialize}; +/// Runtime interaction mode for Codex MultiRouter requests. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "PascalCase")] +pub enum InteractionMode { + #[default] + Code, + Ask, + Chat, +} + /// 代理服务器配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProxyConfig { diff --git a/src-tauri/src/readonly_mcp.rs b/src-tauri/src/readonly_mcp.rs new file mode 100644 index 0000000000..6657d8913f --- /dev/null +++ b/src-tauri/src/readonly_mcp.rs @@ -0,0 +1,405 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use rmcp::{ + model::{ + ErrorData, ListResourceTemplatesResult, ListResourcesResult, PaginatedRequestParams, + ReadResourceRequestParams, ReadResourceResult, Resource, ResourceContents, + ResourceTemplate, ServerCapabilities, ServerInfo, + }, + service::RequestContext, + transport::stdio, + RoleServer, ServerHandler, ServiceExt, +}; +use std::{ + collections::VecDeque, + env, fs, + path::{Component, Path, PathBuf}, +}; + +const TREE_URI: &str = "ccswitch://project/tree"; +const TREE_URI_PREFIX: &str = "ccswitch://project/tree/"; +const FILE_URI_PREFIX: &str = "ccswitch://project/file/"; +const ROOT_TREE_DEPTH: usize = 2; +const SUBTREE_DEPTH: usize = 4; +const MAX_TREE_ENTRIES: usize = 2000; +const MAX_FILE_BYTES: u64 = 512 * 1024; +const IGNORED_DIRS: &[&str] = &[".git", "node_modules", "target", "dist", "build"]; + +#[derive(Debug, Clone)] +struct ReadonlyProjectServer { + root: PathBuf, +} + +impl ReadonlyProjectServer { + fn from_env() -> anyhow::Result { + let root = match env::var("CCSWITCH_READONLY_ROOT") { + Ok(root) if !root.trim().is_empty() => PathBuf::from(root), + _ => env::current_dir()?, + }; + let root = fs::canonicalize(root)?; + if !root.is_dir() { + anyhow::bail!("readonly MCP root is not a directory"); + } + Ok(Self { root }) + } + + fn tree_text(&self, uri: &str) -> Result { + let (base, max_depth) = if uri == TREE_URI { + (self.root.clone(), ROOT_TREE_DEPTH) + } else if let Some(rel) = uri.strip_prefix(TREE_URI_PREFIX) { + (resolve_inside_root(&self.root, rel)?, SUBTREE_DEPTH) + } else { + return Err(ErrorData::resource_not_found("resource not found", None)); + }; + if !base.is_dir() { + return Err(invalid_params("resource is not a directory")); + } + + let mut out = String::new(); + out.push_str(&format!("root: {}\n", self.root.display())); + let rel_base = base.strip_prefix(&self.root).unwrap_or(&base); + let scope = if rel_base.as_os_str().is_empty() { + ".".to_string() + } else { + rel_base.to_string_lossy().to_string() + }; + out.push_str(&format!("scope: {scope}\n")); + + let mut queue = VecDeque::from([(base, 0usize)]); + let mut entries_seen = 0usize; + let mut truncated = false; + + while let Some((dir, depth)) = queue.pop_front() { + if depth >= max_depth { + continue; + } + + let mut entries = fs::read_dir(&dir) + .map_err(|err| invalid_params(format!("cannot read directory: {err}")))? + .filter_map(Result::ok) + .collect::>(); + entries.sort_by_key(|entry| entry.file_name()); + + for entry in entries { + if entries_seen >= MAX_TREE_ENTRIES { + truncated = true; + break; + } + + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + let is_dir = path.is_dir(); + if is_dir && IGNORED_DIRS.contains(&name.as_str()) { + continue; + } + + let rel = path.strip_prefix(&self.root).unwrap_or(&path); + out.push_str(&" ".repeat(depth)); + out.push_str(&rel.to_string_lossy()); + if is_dir { + out.push('/'); + } + out.push('\n'); + entries_seen += 1; + + if is_dir { + queue.push_back((path, depth + 1)); + } + } + + if truncated { + break; + } + } + + if truncated { + out.push_str("[truncated: max entries reached]\n"); + } + Ok(out) + } + + fn read_file_contents(&self, uri: &str) -> Result { + let rel = uri + .strip_prefix(FILE_URI_PREFIX) + .ok_or_else(|| invalid_params("unsupported resource uri"))?; + let path = resolve_inside_root(&self.root, rel)?; + if !path.is_file() { + return Err(invalid_params("resource is not a file")); + } + + let meta = fs::metadata(&path).map_err(|err| invalid_params(format!("metadata: {err}")))?; + if meta.len() > MAX_FILE_BYTES { + return Err(invalid_params("file exceeds 512 KiB limit")); + } + + let bytes = fs::read(&path).map_err(|err| invalid_params(format!("read file: {err}")))?; + let mime_type = mime_type_for_path(&path); + if bytes.contains(&0) { + return Ok(ResourceContents::BlobResourceContents { + uri: uri.to_string(), + mime_type: Some(mime_type.to_string()), + blob: STANDARD.encode(bytes), + meta: None, + }); + } + match String::from_utf8(bytes) { + Ok(text) => Ok(ResourceContents::TextResourceContents { + uri: uri.to_string(), + mime_type: Some(mime_type.to_string()), + text, + meta: None, + }), + Err(error) => Ok(ResourceContents::BlobResourceContents { + uri: uri.to_string(), + mime_type: Some(mime_type.to_string()), + blob: STANDARD.encode(error.into_bytes()), + meta: None, + }), + } + } +} + +impl ServerHandler for ReadonlyProjectServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_resources().build()) + } + + async fn list_resources( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListResourcesResult { + resources: vec![Resource::new(TREE_URI, "project-tree") + .with_description("Read-only project directory tree") + .with_mime_type("text/plain")], + next_cursor: None, + meta: None, + }) + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult { + resource_templates: vec![ + ResourceTemplate::new("ccswitch://project/tree/{path}", "project-subtree") + .with_description("Read a shallow project subtree") + .with_mime_type("text/plain"), + ResourceTemplate::new("ccswitch://project/file/{path}", "project-file") + .with_description("Read any project file") + .with_mime_type("application/octet-stream"), + ], + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + let contents = if request.uri == TREE_URI || request.uri.starts_with(TREE_URI_PREFIX) { + let uri = request.uri; + let text = self.tree_text(&uri)?; + ResourceContents::TextResourceContents { + uri, + mime_type: Some("text/plain".to_string()), + text, + meta: None, + } + } else if request.uri.starts_with(FILE_URI_PREFIX) { + self.read_file_contents(&request.uri)? + } else { + return Err(ErrorData::resource_not_found("resource not found", None)); + }; + + Ok(ReadResourceResult::new(vec![contents])) + } +} + +fn mime_type_for_path(path: &Path) -> &'static str { + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref() + { + Some( + "txt" | "md" | "rs" | "ts" | "tsx" | "js" | "jsx" | "json" | "toml" | "yaml" | "yml" + | "xml" | "html" | "css" | "csv" | "py" | "ps1" | "sh" | "bat" | "cmd", + ) => "text/plain", + Some("jpg" | "jpeg") => "image/jpeg", + Some("png") => "image/png", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("bmp") => "image/bmp", + Some("svg") => "image/svg+xml", + Some("pdf") => "application/pdf", + Some("zip") => "application/zip", + _ => "application/octet-stream", + } +} + +fn resolve_inside_root(root: &Path, relative: &str) -> Result { + let rel = percent_decode(relative)?; + let path = Path::new(&rel); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_))) + { + return Err(invalid_params("path must be relative and stay under root")); + } + + let joined = root.join(path); + let canonical = + fs::canonicalize(&joined).map_err(|err| invalid_params(format!("canonicalize: {err}")))?; + if !canonical.starts_with(root) { + return Err(invalid_params("path escapes root")); + } + Ok(canonical) +} + +fn percent_decode(input: &str) -> Result { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] == b'%' { + if i + 2 >= bytes.len() { + return Err(invalid_params("invalid percent encoding")); + } + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]) + .map_err(|_| invalid_params("invalid percent encoding"))?; + let value = u8::from_str_radix(hex, 16) + .map_err(|_| invalid_params("invalid percent encoding"))?; + out.push(value); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).map_err(|_| invalid_params("path is not valid UTF-8")) +} + +fn invalid_params(message: impl Into) -> ErrorData { + ErrorData::invalid_params(message.into(), None) +} + +pub async fn run_readonly_mcp_async() -> anyhow::Result<()> { + ReadonlyProjectServer::from_env()? + .serve(stdio()) + .await? + .waiting() + .await?; + Ok(()) +} + +pub fn run_readonly_mcp() -> anyhow::Result<()> { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(run_readonly_mcp_async()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn readonly_server_uses_current_dir_when_root_env_missing() { + let temp = TempDir::new().expect("tempdir"); + let old_cwd = env::current_dir().expect("cwd"); + let old_root = env::var("CCSWITCH_READONLY_ROOT").ok(); + env::remove_var("CCSWITCH_READONLY_ROOT"); + env::set_current_dir(temp.path()).expect("set cwd"); + + let server = ReadonlyProjectServer::from_env().expect("server"); + + env::set_current_dir(old_cwd).expect("restore cwd"); + if let Some(root) = old_root { + env::set_var("CCSWITCH_READONLY_ROOT", root); + } + assert_eq!( + server.root, + fs::canonicalize(temp.path()).expect("canonical") + ); + } + + #[test] + fn readonly_server_returns_text_for_utf8_files() { + let temp = TempDir::new().expect("tempdir"); + fs::write(temp.path().join("notes.md"), "# Notes\n").expect("write text"); + let server = ReadonlyProjectServer { + root: fs::canonicalize(temp.path()).expect("canonical"), + }; + + let contents = server + .read_file_contents("ccswitch://project/file/notes.md") + .expect("read text"); + + assert_eq!( + contents, + ResourceContents::TextResourceContents { + uri: "ccswitch://project/file/notes.md".to_string(), + mime_type: Some("text/plain".to_string()), + text: "# Notes\n".to_string(), + meta: None, + } + ); + } + + #[test] + fn readonly_server_returns_jpeg_as_blob() { + let temp = TempDir::new().expect("tempdir"); + let bytes = [0xff, 0xd8, 0xff, 0x00, 0x10]; + fs::write(temp.path().join("front.jpg"), bytes).expect("write jpeg"); + let server = ReadonlyProjectServer { + root: fs::canonicalize(temp.path()).expect("canonical"), + }; + + let contents = server + .read_file_contents("ccswitch://project/file/front.jpg") + .expect("read jpeg"); + + assert_eq!( + contents, + ResourceContents::BlobResourceContents { + uri: "ccswitch://project/file/front.jpg".to_string(), + mime_type: Some("image/jpeg".to_string()), + blob: STANDARD.encode(bytes), + meta: None, + } + ); + } + + #[test] + fn readonly_server_returns_unknown_binary_as_blob() { + let temp = TempDir::new().expect("tempdir"); + let bytes = [0, 1, 2, 3]; + fs::write(temp.path().join("payload.bin"), bytes).expect("write binary"); + let server = ReadonlyProjectServer { + root: fs::canonicalize(temp.path()).expect("canonical"), + }; + + let contents = server + .read_file_contents("ccswitch://project/file/payload.bin") + .expect("read binary"); + + assert_eq!( + contents, + ResourceContents::BlobResourceContents { + uri: "ccswitch://project/file/payload.bin".to_string(), + mime_type: Some("application/octet-stream".to_string()), + blob: STANDARD.encode(bytes), + meta: None, + } + ); + } +} diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 72127b0e53..f64f056f3d 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -59,6 +59,7 @@ pub struct ProxyService { db: Arc, server: Arc>>, external_openai_api_server: Arc>>, + interaction_mode: Arc>, /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新 app_handle: Arc>>, switch_locks: SwitchLockManager, @@ -75,6 +76,7 @@ impl ProxyService { db, server: Arc::new(RwLock::new(None)), external_openai_api_server: Arc::new(RwLock::new(None)), + interaction_mode: Arc::new(RwLock::new(InteractionMode::Code)), app_handle: Arc::new(RwLock::new(None)), switch_locks: SwitchLockManager::new(), } @@ -340,11 +342,17 @@ impl ProxyService { &self, provider: &Provider, ) -> Result<(), String> { + let projected_provider = self + .db + .get_provider_by_id(&provider.id, AppType::Codex.as_str()) + .map_err(|e| format!("读取 Codex provider '{}' 失败: {e}", provider.id))? + .filter(|db_provider| db_provider.settings_config.get("codexRouting").is_some()); + let provider_for_projection = projected_provider.as_ref().unwrap_or(provider); let existing_live = self.read_codex_live().ok(); let mut effective_settings = build_effective_settings_with_common_config( self.db.as_ref(), &AppType::Codex, - provider, + provider_for_projection, ) .map_err(|e| format!("构建 codex 有效配置失败: {e}"))?; if let Some(existing_live) = existing_live.as_ref() { @@ -374,12 +382,18 @@ impl ProxyService { let updated_config = Self::apply_codex_proxy_toml_config_for_provider( config_str, &proxy_codex_base_url, - Some(provider), + Some(provider_for_projection), ); effective_settings["config"] = json!(updated_config); - Self::sync_codex_model_catalog_projection_flag(&mut effective_settings, Some(provider)); + Self::sync_codex_model_catalog_projection_flag( + &mut effective_settings, + Some(provider_for_projection), + ); - self.write_codex_takeover_live_for_provider(&effective_settings, Some(provider))?; + self.write_codex_takeover_live_for_provider( + &effective_settings, + Some(provider_for_projection), + )?; Ok(()) } @@ -414,6 +428,15 @@ impl ProxyService { self.switch_locks.lock_for_app(app_type).await } + pub async fn get_interaction_mode(&self) -> InteractionMode { + *self.interaction_mode.read().await + } + + pub async fn set_interaction_mode(&self, mode: InteractionMode) { + *self.interaction_mode.write().await = mode; + log::info!("[InteractionMode] switched to {mode:?}"); + } + /// 启动代理服务器 pub async fn start(&self) -> Result { // 1. 启动时自动设置 proxy_enabled = true @@ -451,7 +474,13 @@ impl ProxyService { // 4. 创建并启动服务器 let app_handle = self.app_handle.read().await.clone(); - let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle); + let server = ProxyServer::new_with_interaction_mode( + config.clone(), + self.db.clone(), + app_handle, + crate::proxy::server::ProxyServerMode::FullProxy, + self.interaction_mode.clone(), + ); let info = server .start() .await @@ -2120,11 +2149,7 @@ impl ProxyService { } if let Some(cfg_str) = config.get("config").and_then(|v| v.as_str()) { - let updated = Self::remove_local_toml_base_url(cfg_str); - let updated = - crate::codex_config::remove_codex_experimental_bearer_token_if(&updated, |token| { - token == PROXY_TOKEN_PLACEHOLDER - }) + let updated = crate::codex_config::cleanup_codex_local_proxy_takeover_fields(cfg_str) .map_err(|e| format!("清理 Codex 接管占位符失败: {e}"))?; config["config"] = json!(updated); } @@ -2134,6 +2159,7 @@ impl ProxyService { } /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现) + #[cfg(test)] fn remove_local_toml_base_url(toml_str: &str) -> String { crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url) } @@ -2722,6 +2748,9 @@ impl ProxyService { if let Some(root) = live_config.as_object_mut() { root.insert("modelCatalog".to_string(), model_catalog); + if let Some(codex_routing) = provider.settings_config.get("codexRouting").cloned() { + root.insert("codexRouting".to_string(), codex_routing); + } } } @@ -2856,6 +2885,9 @@ impl ProxyService { let merged_config = crate::codex_config::merge_codex_provider_config_with_live(&live_config) .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + let merged_config = + crate::codex_config::ensure_ccswitch_readonly_mcp_config(&merged_config) + .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; crate::codex_config::write_codex_live_config_atomic(Some(&merged_config)) .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; return Ok(()); @@ -3072,7 +3104,13 @@ impl ProxyService { } let app_handle = self.app_handle.read().await.clone(); - let new_server = ProxyServer::new(new_config.clone(), self.db.clone(), app_handle); + let new_server = ProxyServer::new_with_interaction_mode( + new_config.clone(), + self.db.clone(), + app_handle, + crate::proxy::server::ProxyServerMode::FullProxy, + self.interaction_mode.clone(), + ); let info = new_server .start() .await @@ -3985,6 +4023,98 @@ wire_api = "responses" .expect("reset settings"); } + #[tokio::test] + #[serial] + async fn user_exit_stop_with_restore_disables_codex_takeover() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + use_ephemeral_proxy_port(&db).await; + let service = ProxyService::new(db.clone()); + + let official_config = r#"model_provider = "openai" +model = "gpt-5.5" +"#; + crate::codex_config::write_codex_live_atomic( + &json!({ "OPENAI_API_KEY": "old-openai-key" }), + Some(official_config), + ) + .expect("seed Codex live config"); + + let mut router = Provider::with_id( + "codex-router".to_string(), + "Codex Router".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "PROXY_MANAGED" }, + "config": r#"model = "gpt-5.5" +model_provider = "openai" +openai_base_url = "http://127.0.0.1:15721/v1" +"#, + "modelCatalog": { + "models": [ + { "model": "gpt-5.5", "displayName": "GPT-5.5", "contextWindow": 272000 } + ] + }, + "codexRouting": { + "enabled": true, + "routes": [ + { + "id": "openai-official", + "enabled": true, + "match": { "models": ["gpt-5.5"] }, + "upstream": { + "baseUrl": "https://chatgpt.com/backend-api/codex", + "apiFormat": "openai_responses", + "auth": { "source": "managed_codex_oauth" }, + "modelMap": { "gpt-5.5": "gpt-5.5" } + } + } + ] + } + }), + None, + ); + router.category = Some("custom".to_string()); + + db.save_provider("codex", &router) + .expect("save router provider"); + db.set_current_provider("codex", "codex-router") + .expect("set router provider"); + crate::settings::set_current_provider(&AppType::Codex, Some("codex-router")) + .expect("set local current provider"); + + service + .set_takeover_for_app("codex", true) + .await + .expect("enable Codex takeover"); + assert!( + db.get_proxy_config_for_app("codex") + .await + .expect("read codex config") + .enabled + ); + + service + .stop_with_restore() + .await + .expect("user exit cleanup should restore and disable takeover"); + + assert!( + !db.get_proxy_config_for_app("codex") + .await + .expect("read codex config") + .enabled, + "user exit cleanup must disable Codex takeover so Codex does not depend on a stopped local proxy" + ); + let restored_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read restored config"); + assert!( + !restored_config.contains("127.0.0.1"), + "restored Codex config must not point at the local proxy after user exit" + ); + } + #[tokio::test] #[serial] async fn codex_sync_current_to_live_during_takeover_preserves_oauth_auth_json() { diff --git a/src/components/codex/CodexRouterWorkspacePage.test.ts b/src/components/codex/CodexRouterWorkspacePage.test.ts index 12caf78e76..9d6ac46694 100644 --- a/src/components/codex/CodexRouterWorkspacePage.test.ts +++ b/src/components/codex/CodexRouterWorkspacePage.test.ts @@ -1131,6 +1131,21 @@ describe("Codex MultiRouter workspace route persistence helpers", () => { displayName: "GPT 5.4 Mini", contextWindow: 128000, }, + { + model: "gpt-5.6-sol", + displayName: "GPT-5.6 Sol", + contextWindow: 372000, + }, + { + model: "gpt-5.6-terra", + displayName: "GPT-5.6 Terra", + contextWindow: 372000, + }, + { + model: "gpt-5.6-luna", + displayName: "GPT-5.6 Luna", + contextWindow: 372000, + }, { model: "qwen3.6", displayName: "Qwen 3.6", contextWindow: 262144 }, ]); }); @@ -1270,6 +1285,21 @@ describe("Codex MultiRouter workspace route persistence helpers", () => { expect(savedPlan.settingsConfig.modelCatalog.models).toEqual([ { model: "gpt-5.5" }, + { + model: "gpt-5.6-sol", + displayName: "GPT-5.6 Sol", + contextWindow: 372000, + }, + { + model: "gpt-5.6-terra", + displayName: "GPT-5.6 Terra", + contextWindow: 372000, + }, + { + model: "gpt-5.6-luna", + displayName: "GPT-5.6 Luna", + contextWindow: 372000, + }, { model: "gpt-5.5-relay-gpt", displayName: "Relay GPT 5.5", @@ -1360,6 +1390,9 @@ describe("Codex MultiRouter workspace route persistence helpers", () => { }); expect(catalog.models.map((model) => model.model)).toEqual([ "gpt-5.5", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", "gpt-5.5-relay-gpt", ]); }); @@ -1433,6 +1466,9 @@ describe("Codex MultiRouter workspace route persistence helpers", () => { expect(catalog.models.map((model) => model.model)).toEqual([ "gpt-5.5", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", "claude-opus-4-8", ]); expect( @@ -1764,12 +1800,73 @@ describe("Codex MultiRouter workspace route persistence helpers", () => { { model: "gpt-5.4", contextWindow: 272000 }, { model: "gpt-5.4-mini", contextWindow: 128000 }, { model: "gpt-5.3-codex-spark", contextWindow: 128000 }, + { + model: "gpt-5.6-sol", + displayName: "GPT-5.6 Sol", + contextWindow: 372000, + }, + { + model: "gpt-5.6-terra", + displayName: "GPT-5.6 Terra", + contextWindow: 372000, + }, + { + model: "gpt-5.6-luna", + displayName: "GPT-5.6 Luna", + contextWindow: 372000, + }, ]); expect(plan.settingsConfig.modelCatalog.spawnAgentModels).toEqual([ "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", + "gpt-5.6-sol", + ]); + }); + + it("merges new official GPT models into an existing older provider catalog", () => { + const officialBackup: Provider = { + id: "codex-official-backup", + name: "OpenAI Official Backup", + category: "official", + settingsConfig: { + modelCatalog: { + models: [ + { model: "gpt-5.5", contextWindow: 272000 }, + { model: "gpt-5.4", contextWindow: 272000 }, + ], + }, + }, + }; + const plan = createDraftRoutingPlan([officialBackup], [officialBackup]); + const routes = [ + normalizeCodexRouteForSave( + { + label: officialBackup.name, + targetProviderId: officialBackup.id, + match: { + models: ["gpt-5.5", "gpt-5.4"], + prefixes: ["gpt-"], + }, + }, + 0, + new Set(), + ), + ]; + + const rebuilt = buildModelCatalogForRoutes( + plan, + routes, + new Map([[officialBackup.id, officialBackup]]), + ); + + expect(rebuilt.models.map((model) => model.model)).toEqual([ + "gpt-5.5", + "gpt-5.4", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", ]); }); diff --git a/src/components/codex/CodexRouterWorkspacePage.tsx b/src/components/codex/CodexRouterWorkspacePage.tsx index 3fdf85bd8e..c9e0ae7f06 100644 --- a/src/components/codex/CodexRouterWorkspacePage.tsx +++ b/src/components/codex/CodexRouterWorkspacePage.tsx @@ -228,11 +228,15 @@ function normalizedRouteIdentityText(value?: string | null): string { /// 收集 provider 可暴露的模型集合;旧 inline route 没有 targetProviderId 时用它判断是否与新增 provider 等价。 function providerRouteModelIdentitySet(provider: Provider): Set { + const providerCatalog = readCodexModelCatalog(provider).models; return new Set( [ - ...readCodexModelCatalog(provider).models.map((model) => model.model), + ...providerCatalog.map((model) => model.model), ...collectProviderModelIds(provider), - ...fallbackCatalogDraftForProvider(provider).map((model) => model.model), + ...supplementalCatalogDraftForProvider( + provider, + providerCatalog.length > 0, + ).map((model) => model.model), ...inferProviderPrefixes(provider, collectProviderModelIds(provider)), ] .map(normalizedRouteIdentityText) @@ -397,7 +401,25 @@ type CodexModelCatalogDraft = { spawnAgentModels?: string[]; }; +const OPENAI_CODEX_NEW_OFFICIAL_MODELS: CodexCatalogModelDraft[] = [ + { + model: "gpt-5.6-sol", + displayName: "GPT-5.6 Sol", + contextWindow: 372000, + }, + { + model: "gpt-5.6-terra", + displayName: "GPT-5.6 Terra", + contextWindow: 372000, + }, + { + model: "gpt-5.6-luna", + displayName: "GPT-5.6 Luna", + contextWindow: 372000, + }, +]; const OPENAI_CODEX_FALLBACK_CATALOG_MODELS: CodexCatalogModelDraft[] = [ + ...OPENAI_CODEX_NEW_OFFICIAL_MODELS, { model: "gpt-5.5", contextWindow: 272000 }, { model: "gpt-5.4", contextWindow: 272000 }, { model: "gpt-5.4-mini", contextWindow: 128000 }, @@ -932,10 +954,6 @@ function collectProviderModelIds(provider: Provider): string[] { ].filter( (model): model is string => typeof model === "string" && !!model.trim(), ); - const providerText = - `${provider.id} ${provider.name} ${provider.category ?? ""}` - .toLowerCase() - .trim(); const providerType = String( provider.meta?.providerType ?? provider.settingsConfig?.providerType ?? "", ).toLowerCase(); @@ -946,15 +964,14 @@ function collectProviderModelIds(provider: Provider): string[] { "", ).toLowerCase(); const looksLikeOpenAiSource = - providerText.includes("openai") || + provider.category === "official" || providerType.includes("codex_oauth") || baseUrlText.includes("chatgpt.com/backend-api/codex"); - const fallbackModels = - looksLikeOpenAiSource && - catalogModels.length === 0 && - singleModelFields.length === 0 + const fallbackModels = looksLikeOpenAiSource + ? catalogModels.length === 0 && singleModelFields.length === 0 ? OPENAI_CODEX_FALLBACK_MODELS - : []; + : OPENAI_CODEX_NEW_OFFICIAL_MODELS.map((model) => model.model) + : []; return Array.from( new Set([...catalogModels, ...singleModelFields, ...fallbackModels]), ); @@ -984,8 +1001,9 @@ function buildRouteModelMapFromProvider( /// 官方/OAuth provider 经常没有可读 /models catalog;这里提供带上下文窗口的兜底目录, /// 避免 MultiRouter 只保存裸模型名后让 Codex Desktop 回退到 128k 级默认窗口。 -function fallbackCatalogDraftForProvider( +function supplementalCatalogDraftForProvider( provider: Provider, + hasExistingCatalog: boolean, ): CodexCatalogModelDraft[] { const settings = provider.settingsConfig ?? {}; const providerType = String( @@ -995,7 +1013,10 @@ function fallbackCatalogDraftForProvider( provider.category === "official" || providerType.includes("codex_oauth") ) { - return OPENAI_CODEX_FALLBACK_CATALOG_MODELS.map((model) => ({ ...model })); + const models = hasExistingCatalog + ? OPENAI_CODEX_NEW_OFFICIAL_MODELS + : OPENAI_CODEX_FALLBACK_CATALOG_MODELS; + return models.map((model) => ({ ...model })); } return []; } @@ -1182,10 +1203,10 @@ function buildModelCatalogDraftFromSources( byModel.set(id, catalogDraftFromSourceModel(id, catalogModel)); } - const fallbackCatalogModels = - sourceCatalogModels.length === 0 - ? fallbackCatalogDraftForProvider(provider) - : []; + const fallbackCatalogModels = supplementalCatalogDraftForProvider( + provider, + sourceCatalogModels.length > 0, + ); for (const catalogModel of fallbackCatalogModels) { const id = catalogModel.model.trim(); if (!id || byModel.has(id)) continue; @@ -1603,10 +1624,12 @@ export function buildModelCatalogForRoutes( const targetCatalogModels = targetProvider ? readCodexModelCatalog(targetProvider).models : []; - const fallbackCatalogModels = - targetProvider && targetCatalogModels.length === 0 - ? fallbackCatalogDraftForProvider(targetProvider) - : []; + const fallbackCatalogModels = targetProvider + ? supplementalCatalogDraftForProvider( + targetProvider, + targetCatalogModels.length > 0, + ) + : []; const routableCatalogModels = [ ...targetCatalogModels, ...fallbackCatalogModels, diff --git a/src/components/proxy/ProxyToggle.tsx b/src/components/proxy/ProxyToggle.tsx index 61c831eddc..4ff87d343a 100644 --- a/src/components/proxy/ProxyToggle.tsx +++ b/src/components/proxy/ProxyToggle.tsx @@ -6,6 +6,7 @@ */ import { Radio, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { cn } from "@/lib/utils"; @@ -19,8 +20,15 @@ interface ProxyToggleProps { export function ProxyToggle({ className, activeApp }: ProxyToggleProps) { const { t } = useTranslation(); - const { isRunning, takeoverStatus, setTakeoverForApp, isPending, status } = - useProxyStatus(); + const { + isRunning, + takeoverStatus, + setTakeoverForApp, + interactionMode, + setInteractionMode, + isPending, + status, + } = useProxyStatus(); const handleToggle = async (checked: boolean) => { try { @@ -59,30 +67,78 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) { }); return ( -
+ {activeApp === "codex" && ( +
+ + + +
)} - title={tooltipText} - > - {isPending ? ( - - ) : ( - + {isPending ? ( + + ) : ( + + )} + - )} - +
); } diff --git a/src/hooks/useProxyStatus.ts b/src/hooks/useProxyStatus.ts index 072c9b884f..b054890b93 100644 --- a/src/hooks/useProxyStatus.ts +++ b/src/hooks/useProxyStatus.ts @@ -7,6 +7,7 @@ import { invoke } from "@tauri-apps/api/core"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import type { + InteractionMode, ProxyStatus, ProxyServerInfo, ProxyTakeoverStatus, @@ -37,6 +38,12 @@ export function useProxyStatus() { placeholderData: (previousData) => previousData, }); + const { data: interactionMode = "Code" } = useQuery({ + queryKey: ["interactionMode"], + queryFn: () => invoke("get_interaction_mode"), + placeholderData: (previousData) => previousData, + }); + // 启动服务器(总开关:仅启动服务,不接管) const startProxyServerMutation = useMutation({ mutationFn: () => invoke("start_proxy_server"), @@ -188,6 +195,25 @@ export function useProxyStatus() { }, }); + const setInteractionModeMutation = useMutation({ + mutationFn: (mode: InteractionMode) => + invoke("set_interaction_mode", { mode }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["interactionMode"] }); + }, + onError: (error: Error) => { + const detail = + extractErrorMessage(error) || + t("common.unknown", { defaultValue: "未知错误" }); + toast.error( + t("proxy.interactionMode.failed", { + detail, + defaultValue: `切换交互模式失败: ${detail}`, + }), + ); + }, + }); + // 检查是否运行中 const checkRunning = async () => { try { @@ -211,6 +237,7 @@ export function useProxyStatus() { isLoading, isRunning: status?.running || false, takeoverStatus, + interactionMode, isTakeoverActive: takeoverStatus?.claude || takeoverStatus?.codex || @@ -227,6 +254,7 @@ export function useProxyStatus() { // 代理模式下切换供应商 switchProxyProvider: switchProxyProviderMutation.mutateAsync, + setInteractionMode: setInteractionModeMutation.mutateAsync, // 状态检查 checkRunning, @@ -240,6 +268,7 @@ export function useProxyStatus() { startProxyServerMutation.isPending || stopProxyServerMutation.isPending || stopWithRestoreMutation.isPending || - setTakeoverForAppMutation.isPending, + setTakeoverForAppMutation.isPending || + setInteractionModeMutation.isPending, }; } diff --git a/src/lib/api/proxy.ts b/src/lib/api/proxy.ts index d61a483696..c8ef0d4196 100644 --- a/src/lib/api/proxy.ts +++ b/src/lib/api/proxy.ts @@ -4,6 +4,7 @@ import type { ProxyStatus, ProxyServerInfo, ProxyTakeoverStatus, + InteractionMode, GlobalProxyConfig, AppProxyConfig, ExternalOpenAIAPIProfile, @@ -39,6 +40,14 @@ export const proxyApi = { return invoke("get_proxy_status"); }, + async getInteractionMode(): Promise { + return invoke("get_interaction_mode"); + }, + + async setInteractionMode(mode: InteractionMode): Promise { + return invoke("set_interaction_mode", { mode }); + }, + async diagnoseCodexMultiRouter( providerId?: string | null, ): Promise { diff --git a/src/types/proxy.ts b/src/types/proxy.ts index 263a4fe254..104d16cc9f 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -53,6 +53,8 @@ export interface ProxyTakeoverStatus { hermes: boolean; } +export type InteractionMode = "Code" | "Ask" | "Chat"; + export type CodexDiagnosticStatus = "pass" | "warn" | "fail" | "info"; export interface CodexDiagnosticCheck {