From 368e72150d00f13f7424dae12850b370aac80c59 Mon Sep 17 00:00:00 2001 From: z13321812367-sys <1478191279@qq.com> Date: Wed, 8 Jul 2026 05:23:28 +0800 Subject: [PATCH 01/13] feat: add InteractionMode (Chat/Ask/Code) runtime switching for Claude Sonnet 5 - Add InteractionMode enum (Chat/Ask/Code) stored in ProxyService Arc> - Add Chat Profile: remove tools/instructions/reasoning/project context - Add Ask Profile: remove instructions, filter to 3 read-only MCP tools, sanitize environment_context to cwd-only, strip non-MCP tool protocol - Add ccswitch-readonly-mcp binary exposing project-tree/file MCP resources - Fix Code continuation for tool call sequences (transform_codex_chat.rs) - Add UI toggle [Chat] [Ask] [Code] in proxy status bar with aria-pressed - Register ccswitch_readonly MCP server in ~/.codex/config.toml - Add gpt-5.4-mini request shape logging - Preserve existing Chat and Code paths unchanged --- src-tauri/Cargo.lock | 102 +- src-tauri/Cargo.toml | 6 + src-tauri/Cargo.toml.before-default-run | 126 + src-tauri/src/bin/ccswitch_readonly_mcp.rs | 346 ++ src-tauri/src/codex_config.rs | 64 +- src-tauri/src/commands/proxy.rs | 16 + src-tauri/src/lib.rs | 2 + src-tauri/src/provider.rs | 3 + src-tauri/src/proxy/forwarder.rs | 1438 ++++- .../forwarder.rs.before-claude-chat-profile | 5248 +++++++++++++++++ src-tauri/src/proxy/handler_context.rs | 1 + src-tauri/src/proxy/handlers.rs | 3 +- .../proxy/providers/transform_codex_chat.rs | 110 +- src-tauri/src/proxy/response_processor.rs | 3 +- src-tauri/src/proxy/server.rs | 23 +- src-tauri/src/proxy/types.rs | 10 + src-tauri/src/services/proxy.rs | 50 +- src/components/proxy/ProxyToggle.tsx | 104 +- src/hooks/useProxyStatus.ts | 31 +- src/lib/api/proxy.ts | 9 + src/types.ts | 2 + src/types/proxy.ts | 2 + 22 files changed, 7613 insertions(+), 86 deletions(-) create mode 100644 src-tauri/Cargo.toml.before-default-run create mode 100644 src-tauri/src/bin/ccswitch_readonly_mcp.rs create mode 100644 src-tauri/src/proxy/forwarder.rs.before-claude-chat-profile diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6c27db00cc..ab24ddb991 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 1a300730bf..7651cafec2 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-13" 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/Cargo.toml.before-default-run b/src-tauri/Cargo.toml.before-default-run new file mode 100644 index 0000000000..1a300730bf --- /dev/null +++ b/src-tauri/Cargo.toml.before-default-run @@ -0,0 +1,126 @@ +[package] +name = "cc-switch" +version = "3.16.4-13" +description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" +authors = ["Jason Young"] +license = "MIT" +repository = "https://github.com/BigStrongSun/ccswitchmulti" +edition = "2021" +rust-version = "1.85.0" +autobins = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +name = "cc_switch_lib" +crate-type = ["staticlib", "cdylib", "rlib"] +doctest = false + +[[bin]] +name = "cc-switch" +path = "src/main.rs" + +[[bin]] +name = "codex-history-repairer" +path = "src/bin/codex-history-repairer.rs" +required-features = ["history-repairer"] + +[features] +default = [] +history-repairer = [] +test-hooks = [] + +[build-dependencies] +tauri-build = { version = "2.4.0", features = [] } + +[dependencies] +serde_json = { version = "1.0", features = ["preserve_order"] } +serde = { version = "1.0", features = ["derive"] } +log = "0.4" +chrono = { version = "0.4", features = ["serde"] } +tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset", "image-png"] } +tauri-plugin-log = "2" +tauri-plugin-opener = "2" +tauri-plugin-process = "2" +tauri-plugin-updater = "2" +tauri-plugin-dialog = "2" +tauri-plugin-store = "2" +tauri-plugin-deep-link = "2" +tauri-plugin-window-state = "2" +dirs = "5.0" +toml = "0.8" +toml_edit = "0.22" +reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] } +arboard = "3.6" +flate2 = "1" +filetime = "0.2" +brotli = "7" +zstd = "0.13" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] } +tokio-tungstenite = "0.24" +futures = "0.3" +async-stream = "0.3" +bytes = "1.5" +axum = { version = "0.7", features = ["ws"] } +tower = { version = "0.4", features = ["util"] } +tower-http = { version = "0.5", features = ["cors"] } +hyper = { version = "1.0", features = ["full"] } +hyper-util = { version = "0.1", features = ["tokio", "http1", "client-legacy"] } +hyper-rustls = { version = "0.27", features = ["http1", "tls12", "ring", "webpki-tokio"] } +http = "1" +http-body = "1" +http-body-util = "0.1" +httparse = "1" +tokio-rustls = "0.26" +rustls = "0.23" +webpki-roots = "0.26" +rustls-native-certs = "0.8" +regex = "1.10" +rquickjs = { version = "0.8", features = ["array-buffer", "classes"] } +thiserror = "2.0" +anyhow = "1.0" +zip = "2.2" +serde_yaml = "0.9" +tempfile = "3" +url = "2.5" +auto-launch = "0.5" +once_cell = "1.21.3" +base64 = "0.22" +rusqlite = { version = "0.31", features = ["bundled", "backup", "hooks"] } +indexmap = { version = "2", features = ["serde"] } +rust_decimal = "1.33" +uuid = { version = "1.11", features = ["v4"] } +sha2 = "0.10" +hmac = "0.12" +json5 = "0.4" +json-five = "0.3.1" + +[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] +tauri-plugin-single-instance = "2" + +[target.'cfg(target_os = "linux")'.dependencies] +webkit2gtk = { version = "2.0.1", features = ["v2_16"] } + +[target.'cfg(target_os = "windows")'.dependencies] +winreg = "0.52" +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_UI_Controls", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } + +[target.'cfg(all(target_os = "windows", target_arch = "aarch64"))'.dependencies] +rquickjs = { version = "0.8", features = ["bindgen"] } + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.5" +objc2-app-kit = { version = "0.2", features = ["NSColor"] } + +# Optimize release binary size to help reduce AppImage footprint +[profile.release] +codegen-units = 1 +lto = "thin" +opt-level = "s" +# 使用 unwind 以便 panic hook 能捕获 backtrace(abort 会直接终止无法捕获) +panic = "unwind" +strip = "symbols" + +[dev-dependencies] +serial_test = "3" +tempfile = "3" 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..06284e59cc --- /dev/null +++ b/src-tauri/src/bin/ccswitch_readonly_mcp.rs @@ -0,0 +1,346 @@ +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 = env::var("CCSWITCH_READONLY_ROOT") + .map_err(|_| anyhow::anyhow!("CCSWITCH_READONLY_ROOT is not set"))?; + let root = fs::canonicalize(root)?; + if !root.is_dir() { + anyhow::bail!("CCSWITCH_READONLY_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_text(&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}")))?; + if bytes.contains(&0) { + return Err(invalid_params("binary file rejected")); + } + + String::from_utf8(bytes).map_err(|_| invalid_params("file is not valid UTF-8 text")) + } +} + +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 read-only subtree under CCSWITCH_READONLY_ROOT", + ) + .with_mime_type("text/plain"), + ResourceTemplate::new("ccswitch://project/file/{path}", "project-file") + .with_description("Read a UTF-8 text file under CCSWITCH_READONLY_ROOT") + .with_mime_type("text/plain"), + ], + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + let (uri, text) = if request.uri == TREE_URI || request.uri.starts_with(TREE_URI_PREFIX) { + let uri = request.uri; + let text = self.tree_text(&uri)?; + (uri, text) + } else if request.uri.starts_with(FILE_URI_PREFIX) { + let text = self.read_file_text(&request.uri)?; + (request.uri, text) + } else { + return Err(ErrorData::resource_not_found("resource not found", None)); + }; + + Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { + uri, + mime_type: Some("text/plain".to_string()), + text, + meta: None, + }, + ])) + } +} + +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) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + ReadonlyProjectServer::from_env()? + .serve(stdio()) + .await? + .waiting() + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn resolves_path_inside_root() { + let dir = tempdir().unwrap(); + let file = dir.path().join("a.txt"); + fs::write(&file, "ok").unwrap(); + + let root = fs::canonicalize(dir.path()).unwrap(); + let resolved = resolve_inside_root(&root, "a.txt").unwrap(); + + assert_eq!(resolved, fs::canonicalize(file).unwrap()); + } + + #[test] + fn rejects_parent_escape() { + let dir = tempdir().unwrap(); + let root = fs::canonicalize(dir.path()).unwrap(); + + assert!(resolve_inside_root(&root, "../secret.txt").is_err()); + } + + #[test] + fn reads_tree_and_ignores_heavy_dirs() { + let dir = tempdir().unwrap(); + fs::create_dir(dir.path().join("src")).unwrap(); + fs::write(dir.path().join("src/lib.rs"), "pub fn x() {}").unwrap(); + fs::create_dir(dir.path().join("node_modules")).unwrap(); + fs::write(dir.path().join("node_modules/skip.js"), "skip").unwrap(); + + let server = ReadonlyProjectServer { + root: fs::canonicalize(dir.path()).unwrap(), + }; + let tree = server.tree_text(TREE_URI).unwrap(); + + assert!(tree.contains("src/")); + assert!(tree.contains("src\\lib.rs") || tree.contains("src/lib.rs")); + assert!(!tree.contains("node_modules")); + } + + #[test] + fn root_tree_is_shallow_and_subtree_can_drill_down() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src-tauri/src/proxy")).unwrap(); + fs::write( + dir.path().join("src-tauri/src/proxy/forwarder.rs"), + "fn main() {}", + ) + .unwrap(); + + let server = ReadonlyProjectServer { + root: fs::canonicalize(dir.path()).unwrap(), + }; + + let root_tree = server.tree_text(TREE_URI).unwrap(); + assert!(root_tree.contains("src-tauri/")); + assert!(!root_tree.contains("forwarder.rs")); + + let subtree = server + .tree_text("ccswitch://project/tree/src-tauri") + .unwrap(); + assert!(subtree.contains("src-tauri\\src/") || subtree.contains("src-tauri/src/")); + assert!( + subtree.contains("src-tauri\\src\\proxy/") || subtree.contains("src-tauri/src/proxy/") + ); + } + + #[test] + fn rejects_binary_file() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("bin.dat"), [0, 1, 2]).unwrap(); + let server = ReadonlyProjectServer { + root: fs::canonicalize(dir.path()).unwrap(), + }; + + assert!(server + .read_file_text("ccswitch://project/file/bin.dat") + .is_err()); + } +} diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 20d3a11ee1..b8ec7cad8e 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -1344,8 +1344,10 @@ fn codex_provider_models_toml_array(specs: &[CodexCatalogModelSpec]) -> Item { /// 将模型目录同步到活动 provider 的 `models` 字段。 /// -/// Codex Desktop 的 app-server 会把 custom provider 标为“自定义”,但候选菜单仍需要 -/// provider 内部能枚举模型;只写顶层 `model_catalog_json` 对部分 Desktop 版本不够。 +/// Codex Desktop 的 app-server 会把 custom/reserved local provider 标为“自定义”, +/// 但候选菜单仍需要 provider 内部能枚举模型;只写顶层 `model_catalog_json` +/// 对部分 Desktop 版本不够。这里以 CCSwitchMulti 的 `modelCatalog` 为显式信号, +/// 不再因为 `lmstudio` 这类 Codex 保留 provider id 跳过投影。 fn set_active_codex_provider_models(doc: &mut DocumentMut, specs: &[CodexCatalogModelSpec]) { if specs.is_empty() { return; @@ -1353,9 +1355,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 +1381,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()) @@ -5506,6 +5502,58 @@ base_url = "http://127.0.0.1:15721/v1" assert!(model_fields.contains(&"deepseek-v4-flash")); } + #[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 e2424c1773..e183b0b280 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -249,6 +249,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..645a4b4896 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1395,6 +1395,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, diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 28fb3a7675..7e963f4def 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -507,6 +507,9 @@ pub struct ProviderMeta { /// - "openai_responses": OpenAI Responses API 格式,需要转换 #[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")] pub api_format: Option, + /// Codex 上游为 Claude 时,将 claude-sonnet-5 清洗为普通聊天请求 + #[serde(rename = "claudeChatMode", skip_serializing_if = "Option::is_none")] + pub claude_chat_mode: Option, /// 通用认证绑定(provider_config / managed_account) /// /// 新代码应只写入该字段;githubAccountId 仅保留兼容读取。 diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 98886ea243..834cd420b8 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,515 @@ 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"; + +fn apply_claude_chat_profile(body: &mut Value) -> bool { + let is_chat_model = body + .get("model") + .and_then(Value::as_str) + .is_some_and(|model| model == CLAUDE_CHAT_MODEL); + + if !is_chat_model { + 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(body: &mut Value) -> bool { + let is_ask_model = body + .get("model") + .and_then(Value::as_str) + .is_some_and(|model| model == CLAUDE_CHAT_MODEL); + + if !is_ask_model { + 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!( + "[ClaudeAsk] 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); + *input = filter_responses_input_for_claude_ask(original_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!( + "[ClaudeAsk] 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 { + match input { + Value::Array(items) => Value::Array(filter_response_items_for_claude_ask(items)), + Value::Object(object) => { + Value::Array(filter_response_items_for_claude_ask(vec![Value::Object( + object, + )])) + } + other => other, + } +} + +fn filter_response_items_for_claude_ask(items: Vec) -> Vec { + 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 should_keep_ask_conversation_item(&item) { + sanitize_ask_environment_context_item(&mut item); + filtered.push(item); + } + } + + filtered +} + +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("assistant") | Some("latest_reminder") => true, + Some("system") | Some("developer") | Some("tool") => false, + Some(_) => false, + None => matches!( + item_type, + Some("input_text" | "input_image" | "input_file" | "input_audio" | "message") + ), + } +} + +fn sanitize_ask_environment_context_item(item: &mut Value) { + let Some(content) = item.get_mut("content").and_then(Value::as_array_mut) else { + return; + }; + + for content_item in content { + let Some(text) = content_item.get("text").and_then(Value::as_str) else { + continue; + }; + let Some(sanitized) = sanitize_environment_context_fragment(text) else { + continue; + }; + *content_item.get_mut("text").expect("text exists") = Value::String(sanitized); + } +} + +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() +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +struct AskToolCall { + name: String, + arguments: String, +} + +#[allow(dead_code)] +fn ask_tool_arguments(item: &Value) -> String { + item.get("arguments") + .or_else(|| item.get("input")) + .map(ask_value_to_text) + .unwrap_or_default() +} + +#[allow(dead_code)] +fn ask_tool_output(item: &Value) -> String { + item.get("output") + .map(ask_value_to_text) + .unwrap_or_else(|| ask_value_to_text(item)) +} + +#[allow(dead_code)] +fn ask_value_to_text(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + other => canonical_json_string(other), + } +} + +#[allow(dead_code)] +fn ask_execution_evidence_item(item: &Value, calls: &HashMap) -> Value { + let call_id = item.get("call_id").and_then(Value::as_str).unwrap_or(""); + let output = ask_tool_output(item); + let (status_attr, tool, arguments) = if let Some(call) = calls.get(call_id) { + ("", call.name.as_str(), call.arguments.as_str()) + } else { + (" status=\"unpaired\"", "unknown", "") + }; + let evidence = format!( + "\nsource: prior Codex execution\ntool: {tool}\narguments:\n{arguments}\n\nresult:\n{output}\n" + ); + + serde_json::json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": evidence + }] + }) +} pub struct ForwardResult { pub response: ProxyResponse, @@ -106,6 +617,7 @@ pub struct RequestForwarder { current_providers: Arc>>, gemini_shadow: Arc, codex_chat_history: Arc, + interaction_mode: Arc>, /// 故障转移切换管理器 failover_manager: Arc, /// AppHandle,用于发射事件和更新托盘 @@ -194,6 +706,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 +728,7 @@ impl RequestForwarder { current_providers, gemini_shadow, codex_chat_history, + interaction_mode, failover_manager, app_handle, current_provider_id_at_start, @@ -1267,6 +1781,24 @@ 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(&mut effective_body) + } 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!( + "[ClaudeChat] 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 +1884,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 +2120,23 @@ impl RequestForwarder { "[Codex] Restored or enriched {restored} cached function call item(s) for Chat upstream" ); } + let claude_ask_profile_applied = + if matches!(*self.interaction_mode.read().await, InteractionMode::Ask) { + apply_claude_ask_profile(&mut mapped_body) + } 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!( + "[ClaudeAsk] 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 +2734,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,30 +4172,148 @@ 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 { - parts.push(format!("body={}", value_for_log(body))); +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; } - parts.push(format!( - "messages={}", - body.get("messages") - .and_then(Value::as_array) - .map(|values| values.len().to_string()) - .unwrap_or_else(|| "absent".to_string()) - )); - - if let Some(tools) = body.get("tools").and_then(Value::as_array) { - let mut types = tools + 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))); + } + + parts.push(format!( + "messages={}", + body.get("messages") + .and_then(Value::as_array) + .map(|values| values.len().to_string()) + .unwrap_or_else(|| "absent".to_string()) + )); + + if let Some(tools) = body.get("tools").and_then(Value::as_array) { + let mut types = tools .iter() .filter_map(|tool| tool.get("type").and_then(Value::as_str)) .collect::>(); @@ -3650,6 +4324,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", @@ -3680,6 +4363,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 { @@ -3830,6 +4539,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(), @@ -3845,6 +4555,682 @@ 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(&mut body)); + 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(&mut body)); + 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(&mut body)); + 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(&mut body)); + 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(&mut body)); + 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(&mut body)); + 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(&mut body)); + 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(&mut body)); + assert_eq!(body["input"][0]["content"][0]["text"], text); + } + + #[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(&mut body)); + 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 E:\\文档备份\\New project 333\\downloads\\ccswitchmulti-src\n powershell\n 2026-07-07\n Asia/Shanghai\n enabled\n \n enabled\n" + }] + }] + }); + + assert!(apply_claude_ask_profile(&mut body)); + let text = body["input"][0]["content"][0]["text"].as_str().unwrap(); + assert!( + text.contains(r#"E:\文档备份\New project 333\downloads\ccswitchmulti-src"#) + ); + 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(&mut body)); + 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_leaves_agents_fragment_unchanged() { + 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(&mut body)); + assert_eq!(body["input"][0]["content"][0]["text"], agents); + } + + #[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(&mut body)); + 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(&mut body)); + 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(&mut body)); + 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(&mut body)); + 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(&mut body)); + 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(&mut body)); + 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(&mut body)); + 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(&mut body)); + let serialized = body["input"].to_string(); + assert!(!serialized.contains("orphan result")); + } + + #[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/forwarder.rs.before-claude-chat-profile b/src-tauri/src/proxy/forwarder.rs.before-claude-chat-profile new file mode 100644 index 0000000000..98886ea243 --- /dev/null +++ b/src-tauri/src/proxy/forwarder.rs.before-claude-chat-profile @@ -0,0 +1,5248 @@ +//! 请求转发器 +//! +//! 负责将请求转发到上游Provider,支持故障转移 + +use super::hyper_client::ProxyResponse; +use super::{ + body_filter::filter_private_params_with_whitelist, + content_encoding::{decompress_body, get_content_encoding}, + error::*, + failover_switch::FailoverSwitchManager, + json_canonical::{canonicalize_value, short_value_hash}, + log_codes::fwd as log_fwd, + provider_router::ProviderRouter, + providers::{ + codex_chat_history::CodexChatHistoryStore, gemini_shadow::GeminiShadowStore, get_adapter, + AuthInfo, AuthStrategy, ProviderAdapter, ProviderType, + }, + thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget}, + thinking_rectifier::{ + normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature, + }, + types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig}, + ProxyError, +}; +use crate::commands::{CodexOAuthState, CopilotAuthState}; +use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager; +use crate::proxy::providers::copilot_auth::CopilotAuthManager; +use crate::{ + app_config::AppType, + provider::{LocalProxyRequestOverrides, Provider}, +}; +use bytes::Bytes; +use futures::StreamExt; +use http::Extensions; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tauri::Manager; +use tokio::sync::RwLock; + +const PROXY_AUTH_PLACEHOLDER: &str = "PROXY_MANAGED"; +const CODEX_RESPONSES_LITE_FALLBACK_TTL: Duration = Duration::from_secs(24 * 60 * 60); + +pub struct ForwardResult { + pub response: ProxyResponse, + pub provider: Provider, + pub claude_api_format: Option, + /// 实际发往上游的模型名(路由接管/模型映射后的真值)。 + /// + /// usage 归因不能依赖 ctx.request_model(映射前的客户端别名):上游响应 + /// 缺失 model 或回显别名时,接管流量会被记成 claude-* 并按其定价计费。 + pub outbound_model: Option, + /// 活跃连接 RAII guard:随响应一起流转到 response_processor / handle_claude_transform, + /// 最终被 move 进流式 body future(或非流式响应作用域),覆盖整个响应生命周期。 + pub(crate) connection_guard: Option, +} + +pub struct ForwardError { + pub error: ProxyError, + pub provider: Option, +} + +/// 活跃连接 RAII guard +/// +/// 构造时把 `ProxyStatus.active_connections` +1;Drop 时在 tokio runtime 上调度 +/// 一个异步任务执行 -1,从而支持把 guard move 进流式 body future(stream 自然结束 +/// 时 guard 与 future 一起 drop)。 +/// +/// 设计动机:之前在 `forward_with_retry` 出口处同步 -1,但流式响应的 body 实际 +/// 在 `create_logged_passthrough_stream` 内还会继续 yield 字节流,导致 UI 的 +/// `active_connections` 计数过早归零。RAII guard 让"减量"由 Rust 类型系统驱动, +/// 不需要每条出口路径都手动调用。 +pub(crate) struct ActiveConnectionGuard { + status: Arc>, +} + +impl ActiveConnectionGuard { + pub(crate) async fn acquire(status: Arc>) -> Self { + { + let mut s = status.write().await; + s.active_connections = s.active_connections.saturating_add(1); + } + Self { status } + } +} + +impl Drop for ActiveConnectionGuard { + fn drop(&mut self) { + // Drop 不能 await:把减量操作调度到 tokio runtime + let status = self.status.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let mut s = status.write().await; + s.active_connections = s.active_connections.saturating_sub(1); + }); + } + // 没有 runtime 时静默丢失计数(仅 UI 展示用,可接受最终一致性) + } +} + +pub struct RequestForwarder { + /// 共享的 ProviderRouter(持有熔断器状态) + router: Arc, + status: Arc>, + current_providers: Arc>>, + gemini_shadow: Arc, + codex_chat_history: Arc, + /// 故障转移切换管理器 + failover_manager: Arc, + /// AppHandle,用于发射事件和更新托盘 + app_handle: Option, + /// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘) + current_provider_id_at_start: String, + /// 代理会话 ID(用于 Gemini Native shadow replay) + session_id: String, + /// Session ID 是否由客户端提供;生成值不能作为上游缓存身份。 + session_client_provided: bool, + /// 整流器配置 + rectifier_config: RectifierConfig, + /// 优化器配置 + optimizer_config: OptimizerConfig, + /// Copilot 优化器配置 + copilot_optimizer_config: CopilotOptimizerConfig, + /// Codex Responses-Lite 上游能力负缓存。 + /// + /// key 按 provider + 上游 URL path + 模型隔离;value 是过期时间。命中时直接 + /// 去掉 `x-openai-internal-codex-responses-lite`,过期后重新带头探测,避免每次 + /// 请求都先失败一次,也避免永久禁用未来可能支持 Lite 的上游。 + codex_responses_lite_fallbacks: Arc>>, + /// 非流式请求超时(秒) + non_streaming_timeout: std::time::Duration, + /// 流式请求响应头等待超时(秒) + streaming_first_byte_timeout: std::time::Duration, + /// 单个客户端请求最多尝试的 provider 数。 + /// + /// 由 `AppProxyConfig.max_retries` (UI: "请求失败时的重试次数, 0-10") 派生: + /// `max_attempts = max_retries + 1`,所以 max_retries=0 表示仅尝试一家、 + /// max_retries=3(默认)表示最多 4 家。loop 同时受 providers.len() 自然限制。 + max_attempts: usize, +} + +impl RequestForwarder { + /// 预防式 media 降级:发送前对 text-only 模型把图片块替换为标记。 + /// + /// 受 `enabled && request_media_fallback` 管辖;其中"启发式模型名单预测" + /// 再受 `request_media_heuristic` 单独管辖(显式声明 text-only 始终生效)。 + /// 返回被替换的图片块数量(0 = 未触发或开关关闭)。 + fn apply_media_prevention(&self, body: &mut Value, provider: &Provider) -> usize { + if !(self.rectifier_config.enabled && self.rectifier_config.request_media_fallback) { + return 0; + } + let replaced_images = super::media_sanitizer::replace_images_for_text_only_model( + body, + provider, + self.rectifier_config.request_media_heuristic, + ); + if replaced_images > 0 { + let model = body.get("model").and_then(Value::as_str).unwrap_or(""); + log::info!( + "[Media] Replaced {replaced_images} image block(s) with {} for text-only provider={}, model={}", + super::media_sanitizer::UNSUPPORTED_IMAGE_MARKER, + provider.id, + model + ); + } + replaced_images + } + + /// 反应式 media 重试判定:上游因图片输入报错后,是否应替换图片块并对同一供应商重试一次。 + /// + /// 受 `enabled && request_media_fallback` 管辖;不涉及 `request_media_heuristic`—— + /// 这里是上游"实测"错误后的纯恢复,不是预测,故启发式开关与它无关。 + fn media_retry_should_trigger( + &self, + adapter_name: &str, + already_retried: bool, + provider_body: &Value, + error: &ProxyError, + ) -> bool { + matches!(adapter_name, "Claude" | "Codex") + && self.rectifier_config.enabled + && self.rectifier_config.request_media_fallback + && !already_retried + && super::media_sanitizer::contains_image_blocks(provider_body) + && super::media_sanitizer::is_retriable_image_error(error) + } + + #[allow(clippy::too_many_arguments)] + pub fn new( + router: Arc, + non_streaming_timeout: u64, + status: Arc>, + current_providers: Arc>>, + gemini_shadow: Arc, + codex_chat_history: Arc, + failover_manager: Arc, + app_handle: Option, + current_provider_id_at_start: String, + session_id: String, + session_client_provided: bool, + streaming_first_byte_timeout: u64, + _streaming_idle_timeout: u64, + rectifier_config: RectifierConfig, + optimizer_config: OptimizerConfig, + copilot_optimizer_config: CopilotOptimizerConfig, + max_retries: u32, + ) -> Self { + // max_retries 是「失败后重试次数」语义,attempt 上限 = retries + 1。 + // saturating_add 防止 u32::MAX + 1 溢出。 + let max_attempts = (max_retries as usize).saturating_add(1); + Self { + router, + status, + current_providers, + gemini_shadow, + codex_chat_history, + failover_manager, + app_handle, + current_provider_id_at_start, + session_id, + session_client_provided, + rectifier_config, + optimizer_config, + copilot_optimizer_config, + codex_responses_lite_fallbacks: Arc::new(RwLock::new(HashMap::new())), + non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout), + streaming_first_byte_timeout: std::time::Duration::from_secs( + streaming_first_byte_timeout, + ), + max_attempts, + } + } + + /// 判断当前 Codex Responses-Lite fallback 负缓存是否仍然有效。 + /// + /// 参数: + /// - `key`: 已按 provider、上游 URL 与模型归一化后的缓存 key。 + /// 返回: + /// - `true` 表示本次请求应直接去掉 Lite 头;`false` 表示应带头重新探测。 + /// 副作用: + /// - 如果缓存条目已经过期,会顺手删除,避免内存里长期保留无效能力结果。 + async fn codex_responses_lite_fallback_active(&self, key: &str) -> bool { + let mut fallbacks = self.codex_responses_lite_fallbacks.write().await; + codex_responses_lite_fallback_active_at(&mut fallbacks, key, Instant::now()) + } + + /// 记录一个短期 Responses-Lite fallback 负缓存。 + /// + /// 只有上游明确返回 Lite 不支持错误后才调用;缓存过期后下一次请求会重新带头 + /// 探测,防止第三方上游未来支持该协议后仍被永久去头。 + async fn mark_codex_responses_lite_fallback(&self, key: String) { + let now = Instant::now(); + let mut fallbacks = self.codex_responses_lite_fallbacks.write().await; + if fallbacks.len() > 512 { + fallbacks.retain(|_, expires_at| *expires_at > now); + } + fallbacks.insert(key, now + CODEX_RESPONSES_LITE_FALLBACK_TTL); + } + + async fn record_success_result( + &self, + circuit_provider_id: &str, + health_provider_id: &str, + app_type: &str, + used_half_open_permit: bool, + ) { + if used_half_open_permit { + let provider_id = health_provider_id; + if let Err(e) = self + .router + .record_result_with_health_provider( + circuit_provider_id, + health_provider_id, + app_type, + true, + true, + None, + ) + .await + { + log::warn!( + "[{app_type}] 记录 Provider 成功结果失败: provider_id={provider_id}, error={e}" + ); + } + return; + } + + let router = self.router.clone(); + let circuit_provider_id = circuit_provider_id.to_string(); + let health_provider_id = health_provider_id.to_string(); + let app_type = app_type.to_string(); + tokio::spawn(async move { + let provider_id = health_provider_id.clone(); + if let Err(e) = router + .record_result_with_health_provider( + &circuit_provider_id, + &health_provider_id, + &app_type, + false, + true, + None, + ) + .await + { + log::warn!( + "[{app_type}] 异步记录 Provider 成功结果失败: provider_id={provider_id}, error={e}" + ); + } + }); + } + + /// 整流(thinking signature 或 budget)重试失败后的统一收尾。 + /// + /// `None` 表示已记录熔断器、累积 `last_error`/`last_provider`, + /// 调用方应 `continue` 让下一家 provider 继续故障转移; + /// `Some(ForwardError)` 表示是客户端错误,没有 provider 能修复, + /// 调用方应直接 `return` 把错误返回给客户端。 + #[allow(clippy::too_many_arguments)] + async fn handle_rectifier_retry_failure( + &self, + retry_err: ProxyError, + provider: &Provider, + app_type_str: &str, + used_half_open_permit: bool, + rectifier_label: &str, + last_error: &mut Option, + last_provider: &mut Option, + ) -> Option { + // Provider 错误:本家上游/网络确实出问题,下一家 provider 可能可用 → 继续故障转移。 + // 客户端错误:整流后请求仍违法,下一家也修不好 → 直接返回。 + let is_provider_error = match &retry_err { + ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => true, + ProxyError::UpstreamError { status, .. } => *status >= 500, + _ => false, + }; + + if is_provider_error { + let (persistent_provider_id, _) = + super::providers::codex_route_persistent_provider(provider); + let _ = self + .router + .record_result_with_health_provider( + &provider.id, + persistent_provider_id, + app_type_str, + used_half_open_permit, + false, + Some(retry_err.to_string()), + ) + .await; + { + let mut status = self.status.write().await; + status.last_error = Some(format!( + "Provider {} {rectifier_label}重试失败: {}", + provider.name, retry_err + )); + } + *last_error = Some(retry_err); + *last_provider = Some(provider.clone()); + return None; + } + + self.router + .release_permit_neutral(&provider.id, app_type_str, used_half_open_permit) + .await; + let mut status = self.status.write().await; + status.failed_requests += 1; + status.last_error = Some(retry_err.to_string()); + if status.total_requests > 0 { + status.success_rate = + (status.success_requests as f32 / status.total_requests as f32) * 100.0; + } + Some(ForwardError { + error: retry_err, + provider: Some(provider.clone()), + }) + } + + /// 转发请求(带故障转移) + /// + /// 这是 thin wrapper:在客户端请求维度记一次 `total_requests` / 调整 + /// `active_connections` / 刷新 `last_request_at`,无论 inner 走哪条出口路径, + /// 出口处都会把 `active_connections` 回收。Per-attempt 维度(成功/失败/熔断 + /// 等)仍由 inner 内自行更新 `success_requests` / `failed_requests`。 + #[allow(clippy::too_many_arguments)] + pub async fn forward_with_retry( + &self, + app_type: &AppType, + method: http::Method, + endpoint: &str, + body: Value, + headers: axum::http::HeaderMap, + extensions: Extensions, + providers: Vec, + ) -> Result { + let guard = ActiveConnectionGuard::acquire(self.status.clone()).await; + { + let mut s = self.status.write().await; + s.total_requests = s.total_requests.saturating_add(1); + s.last_request_at = Some(chrono::Utc::now().to_rfc3339()); + } + let result = self + .forward_with_retry_inner( + app_type, method, endpoint, body, headers, extensions, providers, + ) + .await; + // 把 guard 注入到 Ok 结果,让它随响应一起流转到 response_processor, + // 在流式 body 的 future 内才真正 drop。 + // Err 路径:guard 在函数 scope 内随返回值落地时自动 drop。 + result.map(|mut fr| { + fr.connection_guard = Some(guard); + fr + }) + } + + /// 实际转发逻辑(不包含客户端维度的入口/出口计数) + /// + /// # Arguments + /// * `app_type` - 应用类型 + /// * `method` - 客户端请求的 HTTP 方法(透传给上游,支持 GET/POST 等) + /// * `endpoint` - API 端点 + /// * `body` - 请求体 + /// * `headers` - 请求头 + /// * `providers` - 已选择的 Provider 列表(由 RequestContext 提供,避免重复调用 select_providers) + #[allow(clippy::too_many_arguments)] + async fn forward_with_retry_inner( + &self, + app_type: &AppType, + method: http::Method, + endpoint: &str, + body: Value, + headers: axum::http::HeaderMap, + extensions: Extensions, + providers: Vec, + ) -> Result { + // 获取适配器 + let adapter = get_adapter(app_type); + let app_type_str = app_type.as_str(); + + if providers.is_empty() { + return Err(ForwardError { + error: ProxyError::NoAvailableProvider, + provider: None, + }); + } + + let attempt_providers = build_forward_attempt_providers_preserving_codex_router_context( + app_type, &providers, &body, + ); + let mut last_error = None; + let mut last_provider = None; + let mut attempted_providers = 0usize; + + // 单 Provider 场景下跳过熔断器检查(故障转移关闭时) + let bypass_circuit_breaker = attempt_providers.len() == 1; + + // 依次尝试每个供应商 + for provider in attempt_providers.iter() { + let attempt_provider_id = provider.id.clone(); + let (persistent_provider_id, persistent_provider_name) = + super::providers::codex_route_persistent_provider(provider); + let persistent_provider_id = persistent_provider_id.to_string(); + let persistent_provider_name = persistent_provider_name.to_string(); + + // 整流器重试标记:每个 provider 独立持有,避免标记跨 provider 短路故障转移 + // —— 首家 provider 整流后被 5xx/timeout 击落时,下家仍能用整流后的请求体走整流流程 + let mut rectifier_retried = false; + let mut budget_rectifier_retried = false; + let mut media_rectifier_retried = false; + + // 上限检查:尊重用户在 AppProxyConfig.max_retries 上配置的「重试次数」。 + // 放在熔断器 allow 检查之前,避免在已经超限时还占用 HalfOpen 探测名额。 + if attempted_providers >= self.max_attempts { + log::warn!( + "[{app_type_str}] 已达最大尝试次数上限 ({}/{}), 停止故障转移", + attempted_providers, + self.max_attempts + ); + break; + } + + // 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额) + // 单 Provider 场景下跳过此检查,避免熔断器阻塞所有请求 + let (allowed, used_half_open_permit) = if bypass_circuit_breaker { + (true, false) + } else { + let permit = self + .router + .allow_provider_request(&provider.id, app_type_str) + .await; + (permit.allowed, permit.used_half_open_permit) + }; + + if !allowed { + continue; + } + + // PRE-SEND 优化器:每个 provider 独立决定是否优化 + // clone body 以避免 Bedrock 优化字段泄漏到非 Bedrock provider(failover 场景) + let mut provider_body = + if self.optimizer_config.enabled && is_bedrock_provider(provider) { + let mut b = body.clone(); + if self.optimizer_config.thinking_optimizer { + super::thinking_optimizer::optimize(&mut b, &self.optimizer_config); + } + if self.optimizer_config.cache_injection { + super::cache_injector::inject(&mut b, &self.optimizer_config); + } + b + } else { + body.clone() + }; + + attempted_providers += 1; + + // 更新状态中的当前 Provider 信息(per-attempt 维度的标识) + // + // total_requests / last_request_at / active_connections 已由 + // forward_with_retry wrapper 在客户端请求维度统一处理,这里只刷 + // 新「正在尝试哪个 provider」的展示字段。 + { + let mut status = self.status.write().await; + status.current_provider = Some(persistent_provider_name.clone()); + status.current_provider_id = Some(persistent_provider_id.clone()); + } + + // 转发请求(每个 Provider 只尝试一次,重试由客户端控制) + match self + .forward( + app_type, + &method, + provider, + endpoint, + &provider_body, + &headers, + &extensions, + adapter.as_ref(), + ) + .await + { + Ok((response, claude_api_format, effective_provider, outbound_model)) => { + // 成功:普通闭合熔断状态异步记录,避免阻塞流式首包返回; + // HalfOpen 探测仍同步等待,保证 permit 与熔断状态及时释放。 + self.record_success_result( + &attempt_provider_id, + &persistent_provider_id, + app_type_str, + used_half_open_permit, + ) + .await; + + // 更新当前应用类型使用的 provider + { + let mut current_providers = self.current_providers.write().await; + current_providers.insert( + app_type_str.to_string(), + ( + persistent_provider_id.clone(), + persistent_provider_name.clone(), + ), + ); + } + + // 更新成功统计 + { + let mut status = self.status.write().await; + status.success_requests += 1; + status.last_error = None; + let should_switch = self.current_provider_id_at_start.as_str() + != persistent_provider_id.as_str(); + if should_switch { + status.failover_count += 1; + + // 异步触发供应商切换,更新 UI/托盘,并把“当前供应商”同步为实际使用的 provider + let fm = self.failover_manager.clone(); + let ah = self.app_handle.clone(); + let pid = persistent_provider_id.clone(); + let pname = persistent_provider_name.clone(); + let at = app_type_str.to_string(); + + tokio::spawn(async move { + let _ = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await; + }); + } + // 重新计算成功率 + if status.total_requests > 0 { + status.success_rate = (status.success_requests as f32 + / status.total_requests as f32) + * 100.0; + } + } + + return Ok(ForwardResult { + response, + provider: effective_provider, + claude_api_format, + outbound_model, + connection_guard: None, + }); + } + Err(e) => { + // 检测是否需要触发整流器(仅 Claude/ClaudeAuth 供应商) + let provider_type = ProviderType::from_app_type_and_config(app_type, provider); + let is_anthropic_provider = matches!( + provider_type, + ProviderType::Claude | ProviderType::ClaudeAuth + ); + let mut signature_rectifier_non_retryable_client_error = false; + + if self.media_retry_should_trigger( + adapter.name(), + media_rectifier_retried, + &provider_body, + &e, + ) { + let mut media_body = provider_body.clone(); + let replaced_images = + super::media_sanitizer::replace_image_blocks_with_marker( + &mut media_body, + ); + + if replaced_images > 0 { + let _ = std::mem::replace(&mut media_rectifier_retried, true); + let model = media_body + .get("model") + .and_then(Value::as_str) + .unwrap_or(""); + log::info!( + "[{app_type_str}] [Media] Upstream rejected image input; retrying provider={} model={} with {replaced_images} image block(s) replaced by {}", + provider.id, + model, + super::media_sanitizer::UNSUPPORTED_IMAGE_MARKER + ); + + match self + .forward( + app_type, + &method, + provider, + endpoint, + &media_body, + &headers, + &extensions, + adapter.as_ref(), + ) + .await + { + Ok(( + response, + claude_api_format, + routed_provider, + outbound_model, + )) => { + log::info!("[{app_type_str}] [Media] Image retry succeeded"); + self.record_success_result( + &attempt_provider_id, + &persistent_provider_id, + app_type_str, + used_half_open_permit, + ) + .await; + + { + let mut current_providers = + self.current_providers.write().await; + current_providers.insert( + app_type_str.to_string(), + ( + persistent_provider_id.clone(), + persistent_provider_name.clone(), + ), + ); + } + + { + let mut status = self.status.write().await; + status.success_requests += 1; + status.last_error = None; + let should_switch = + self.current_provider_id_at_start.as_str() + != persistent_provider_id.as_str(); + if should_switch { + status.failover_count += 1; + let fm = self.failover_manager.clone(); + let ah = self.app_handle.clone(); + let pid = persistent_provider_id.clone(); + let pname = persistent_provider_name.clone(); + let at = app_type_str.to_string(); + + tokio::spawn(async move { + let _ = fm + .try_switch(ah.as_ref(), &at, &pid, &pname) + .await; + }); + } + if status.total_requests > 0 { + status.success_rate = (status.success_requests as f32 + / status.total_requests as f32) + * 100.0; + } + } + + return Ok(ForwardResult { + response, + provider: routed_provider, + claude_api_format, + outbound_model, + connection_guard: None, + }); + } + Err(retry_err) => { + log::warn!( + "[{app_type_str}] [Media] Image retry still failed: {retry_err}" + ); + if let Some(err) = self + .handle_rectifier_retry_failure( + retry_err, + provider, + app_type_str, + used_half_open_permit, + "media 降级", + &mut last_error, + &mut last_provider, + ) + .await + { + return Err(err); + } + continue; + } + } + } + } + + if is_anthropic_provider { + let error_message = extract_error_message(&e); + if should_rectify_thinking_signature( + error_message.as_deref(), + &self.rectifier_config, + ) { + // 已经重试过:直接返回错误(不可重试客户端错误) + if rectifier_retried { + log::warn!("[{app_type_str}] [RECT-005] 整流器已触发过,不再重试"); + // 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题) + self.router + .release_permit_neutral( + &provider.id, + app_type_str, + used_half_open_permit, + ) + .await; + let mut status = self.status.write().await; + status.failed_requests += 1; + status.last_error = Some(e.to_string()); + if status.total_requests > 0 { + status.success_rate = (status.success_requests as f32 + / status.total_requests as f32) + * 100.0; + } + return Err(ForwardError { + error: e, + provider: Some(provider.clone()), + }); + } + + // 首次触发:整流请求体 + let rectified = rectify_anthropic_request(&mut provider_body); + + // 整流未生效:继续尝试 budget 整流路径,避免误判后短路 + if !rectified.applied { + log::warn!( + "[{app_type_str}] [RECT-006] thinking 签名整流器触发但无可整流内容,继续检查 budget;若 budget 也未命中则按客户端错误返回" + ); + signature_rectifier_non_retryable_client_error = true; + } else { + log::info!( + "[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields", + app_type_str, + rectified.removed_thinking_blocks, + rectified.removed_redacted_thinking_blocks, + rectified.removed_signature_fields + ); + + // 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展) + let _ = std::mem::replace(&mut rectifier_retried, true); + + // 使用同一供应商重试(不计入熔断器) + match self + .forward( + app_type, + &method, + provider, + endpoint, + &provider_body, + &headers, + &extensions, + adapter.as_ref(), + ) + .await + { + Ok(( + response, + claude_api_format, + effective_provider, + outbound_model, + )) => { + log::info!("[{app_type_str}] [RECT-002] 整流重试成功"); + self.record_success_result( + &attempt_provider_id, + &persistent_provider_id, + app_type_str, + used_half_open_permit, + ) + .await; + + // 更新当前应用类型使用的 provider + { + let mut current_providers = + self.current_providers.write().await; + current_providers.insert( + app_type_str.to_string(), + ( + persistent_provider_id.clone(), + persistent_provider_name.clone(), + ), + ); + } + + // 更新成功统计 + { + let mut status = self.status.write().await; + status.success_requests += 1; + status.last_error = None; + let should_switch = + self.current_provider_id_at_start.as_str() + != persistent_provider_id.as_str(); + if should_switch { + status.failover_count += 1; + + // 异步触发供应商切换,更新 UI/托盘 + let fm = self.failover_manager.clone(); + let ah = self.app_handle.clone(); + let pid = persistent_provider_id.clone(); + let pname = persistent_provider_name.clone(); + let at = app_type_str.to_string(); + + tokio::spawn(async move { + let _ = fm + .try_switch(ah.as_ref(), &at, &pid, &pname) + .await; + }); + } + if status.total_requests > 0 { + status.success_rate = (status.success_requests + as f32 + / status.total_requests as f32) + * 100.0; + } + } + + return Ok(ForwardResult { + response, + provider: effective_provider, + claude_api_format, + outbound_model, + connection_guard: None, + }); + } + Err(retry_err) => { + log::warn!( + "[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}" + ); + if let Some(err) = self + .handle_rectifier_retry_failure( + retry_err, + provider, + app_type_str, + used_half_open_permit, + "整流", + &mut last_error, + &mut last_provider, + ) + .await + { + return Err(err); + } + continue; + } + } + } + } + } + + // 检测是否需要触发 budget 整流器(仅 Claude/ClaudeAuth 供应商) + if is_anthropic_provider { + let error_message = extract_error_message(&e); + if should_rectify_thinking_budget( + error_message.as_deref(), + &self.rectifier_config, + ) { + // 已经重试过:直接返回错误(不可重试客户端错误) + if budget_rectifier_retried { + log::warn!( + "[{app_type_str}] [RECT-013] budget 整流器已触发过,不再重试" + ); + self.router + .release_permit_neutral( + &provider.id, + app_type_str, + used_half_open_permit, + ) + .await; + let mut status = self.status.write().await; + status.failed_requests += 1; + status.last_error = Some(e.to_string()); + if status.total_requests > 0 { + status.success_rate = (status.success_requests as f32 + / status.total_requests as f32) + * 100.0; + } + return Err(ForwardError { + error: e, + provider: Some(provider.clone()), + }); + } + + let budget_rectified = rectify_thinking_budget(&mut provider_body); + if !budget_rectified.applied { + log::warn!( + "[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容,不做无意义重试" + ); + self.router + .release_permit_neutral( + &provider.id, + app_type_str, + used_half_open_permit, + ) + .await; + let mut status = self.status.write().await; + status.failed_requests += 1; + status.last_error = Some(e.to_string()); + if status.total_requests > 0 { + status.success_rate = (status.success_requests as f32 + / status.total_requests as f32) + * 100.0; + } + return Err(ForwardError { + error: e, + provider: Some(provider.clone()), + }); + } + + log::info!( + "[{}] [RECT-010] thinking budget 整流器触发, before={:?}, after={:?}", + app_type_str, + budget_rectified.before, + budget_rectified.after + ); + + let _ = std::mem::replace(&mut budget_rectifier_retried, true); + + // 使用同一供应商重试(不计入熔断器) + match self + .forward( + app_type, + &method, + provider, + endpoint, + &provider_body, + &headers, + &extensions, + adapter.as_ref(), + ) + .await + { + Ok(( + response, + claude_api_format, + effective_provider, + outbound_model, + )) => { + log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功"); + self.record_success_result( + &attempt_provider_id, + &persistent_provider_id, + app_type_str, + used_half_open_permit, + ) + .await; + + { + let mut current_providers = + self.current_providers.write().await; + current_providers.insert( + app_type_str.to_string(), + ( + persistent_provider_id.clone(), + persistent_provider_name.clone(), + ), + ); + } + + { + let mut status = self.status.write().await; + status.success_requests += 1; + status.last_error = None; + let should_switch = + self.current_provider_id_at_start.as_str() + != persistent_provider_id.as_str(); + if should_switch { + status.failover_count += 1; + let fm = self.failover_manager.clone(); + let ah = self.app_handle.clone(); + let pid = persistent_provider_id.clone(); + let pname = persistent_provider_name.clone(); + let at = app_type_str.to_string(); + tokio::spawn(async move { + let _ = fm + .try_switch(ah.as_ref(), &at, &pid, &pname) + .await; + }); + } + if status.total_requests > 0 { + status.success_rate = (status.success_requests as f32 + / status.total_requests as f32) + * 100.0; + } + } + + return Ok(ForwardResult { + response, + provider: effective_provider, + claude_api_format, + outbound_model, + connection_guard: None, + }); + } + Err(retry_err) => { + log::warn!( + "[{app_type_str}] [RECT-012] budget 整流重试仍失败: {retry_err}" + ); + if let Some(err) = self + .handle_rectifier_retry_failure( + retry_err, + provider, + app_type_str, + used_half_open_permit, + "budget 整流", + &mut last_error, + &mut last_provider, + ) + .await + { + return Err(err); + } + continue; + } + } + } + } + + if signature_rectifier_non_retryable_client_error { + self.router + .release_permit_neutral( + &provider.id, + app_type_str, + used_half_open_permit, + ) + .await; + let mut status = self.status.write().await; + status.failed_requests += 1; + status.last_error = Some(e.to_string()); + if status.total_requests > 0 { + status.success_rate = (status.success_requests as f32 + / status.total_requests as f32) + * 100.0; + } + return Err(ForwardError { + error: e, + provider: Some(provider.clone()), + }); + } + + // 先分类错误,决定是否计入 provider 健康度 + // —— NonRetryable / ClientAbort 是客户端层错误,无论换哪家 provider 都会被拒绝, + // 不应污染熔断器和数据库健康度(与 release_permit_neutral 同语义)。 + let category = self.categorize_proxy_error(&e); + + match category { + ErrorCategory::Retryable => { + // 可重试:真正的 provider 故障 → 记录失败并更新熔断器/DB 健康度 + let _ = self + .router + .record_result_with_health_provider( + &provider.id, + &persistent_provider_id, + app_type_str, + used_half_open_permit, + false, + Some(e.to_string()), + ) + .await; + + { + let mut status = self.status.write().await; + status.last_error = + Some(format!("Provider {} 失败: {}", provider.name, e)); + } + + let (log_code, log_message) = build_retryable_failure_log( + &provider.name, + attempted_providers, + attempt_providers.len(), + &e, + ); + log::warn!("[{app_type_str}] [{log_code}] {log_message}"); + + last_error = Some(e); + last_provider = Some(provider.clone()); + // 继续尝试下一个供应商 + continue; + } + ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => { + // 不可重试:客户端层错误或客户端断连 → 不污染健康度,仅释放 HalfOpen permit + self.router + .release_permit_neutral( + &provider.id, + app_type_str, + used_half_open_permit, + ) + .await; + { + let mut status = self.status.write().await; + status.failed_requests += 1; + status.last_error = Some(e.to_string()); + if status.total_requests > 0 { + status.success_rate = (status.success_requests as f32 + / status.total_requests as f32) + * 100.0; + } + } + return Err(ForwardError { + error: e, + provider: Some(provider.clone()), + }); + } + } + } + } + } + + if attempted_providers == 0 { + // providers 列表非空,但全部被熔断器拒绝(典型:HalfOpen 探测名额被占用) + { + let mut status = self.status.write().await; + status.failed_requests += 1; + status.last_error = Some("所有供应商暂时不可用(熔断器限制)".to_string()); + if status.total_requests > 0 { + status.success_rate = + (status.success_requests as f32 / status.total_requests as f32) * 100.0; + } + } + return Err(ForwardError { + error: ProxyError::NoAvailableProvider, + provider: None, + }); + } + + // 所有供应商都失败了 + { + let mut status = self.status.write().await; + status.failed_requests += 1; + status.last_error = Some("所有供应商都失败".to_string()); + if status.total_requests > 0 { + status.success_rate = + (status.success_requests as f32 / status.total_requests as f32) * 100.0; + } + } + + if let Some((log_code, log_message)) = build_terminal_failure_log( + attempted_providers, + attempt_providers.len(), + last_error.as_ref(), + ) { + log::warn!("[{app_type_str}] [{log_code}] {log_message}"); + } + + Err(ForwardError { + error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded), + provider: last_provider, + }) + } + + /// 转发单个请求(使用适配器) + /// + /// 成功时返回 `(response, claude_api_format, outbound_model)`,其中 + /// `outbound_model` 是最终发往上游的模型名(所有映射/改写之后)。 + #[allow(clippy::too_many_arguments)] + async fn forward( + &self, + app_type: &AppType, + method: &http::Method, + provider: &Provider, + endpoint: &str, + body: &Value, + headers: &axum::http::HeaderMap, + extensions: &Extensions, + adapter: &dyn ProviderAdapter, + ) -> Result<(ProxyResponse, Option, Provider, Option), ProxyError> { + let codex_trace_id = + matches!(app_type, AppType::Codex).then(|| uuid::Uuid::new_v4().to_string()); + let route_started_at = std::time::Instant::now(); + let request_model_for_log = body + .get("model") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + .to_string(); + let outer_provider_id = provider.id.clone(); + let outer_provider_name = provider.name.clone(); + + // Codex v2 是一个复合 provider:Codex 客户端只看到一个 provider bucket, + // Rust proxy 根据请求模型临时解析真实上游 provider,后续 base_url/auth/转换逻辑 + // 都使用这个 effective provider。 + let provider_is_resolved_codex_route = provider + .settings_config + .get("codexResolvedRouteId") + .is_some(); + let codex_router_configured = matches!(app_type, AppType::Codex) + && !provider_is_resolved_codex_route + && codex_provider_has_routing_config(provider); + let routed_provider = if matches!(app_type, AppType::Codex) { + (!provider_is_resolved_codex_route) + .then(|| super::providers::resolve_codex_model_routed_provider(provider, body)) + .flatten() + } else { + None + }; + let routed_provider = if let Some(route_provider) = routed_provider { + if let Some(target_provider_id) = + super::providers::codex_route_target_provider_id(&route_provider) + { + let Some(target_provider) = self + .router + .get_provider_by_id(target_provider_id, app_type.as_str()) + .map_err(|err| { + ProxyError::ConfigError(format!( + "读取 Codex route 目标供应商 '{target_provider_id}' 失败: {err}" + )) + })? + else { + return Err(ProxyError::ConfigError(format!( + "Codex route 引用了不存在的目标供应商 '{target_provider_id}'" + ))); + }; + Some( + super::providers::materialize_codex_routed_provider_from_target( + &route_provider, + &target_provider, + ), + ) + } else { + Some(route_provider) + } + } else { + None + }; + let codex_route_missed = codex_router_configured && routed_provider.is_none(); + let provider = routed_provider.as_ref().unwrap_or(provider); + + if let Some(trace_id) = codex_trace_id.as_deref() { + let route_id = provider + .settings_config + .get("codexResolvedRouteId") + .and_then(|value| value.as_str()) + .unwrap_or(""); + super::codex_router_log::append_event( + "route_resolved", + &[ + ("trace", trace_id.to_string()), + ("session", self.session_id.clone()), + ("endpoint", endpoint.to_string()), + ("model", request_model_for_log.clone()), + ("outer_provider", outer_provider_id.clone()), + ("outer_name", outer_provider_name.clone()), + ("effective_provider", provider.id.clone()), + ("effective_name", provider.name.clone()), + ("route_id", route_id.to_string()), + ("routing_configured", codex_router_configured.to_string()), + ("route_missed", codex_route_missed.to_string()), + ( + "elapsed_ms", + route_started_at.elapsed().as_millis().to_string(), + ), + ], + ); + } + + if let Some(routed_provider) = routed_provider.as_ref() { + let request_model = body + .get("model") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + log::debug!( + "[CodexRouter] model={} routed to provider={} ({})", + request_model, + routed_provider.id, + routed_provider.name + ); + } + // 使用适配器提取 base_url + let mut base_url = adapter.extract_base_url(provider)?; + if codex_route_missed && codex_base_url_points_to_local_proxy(&base_url) { + let request_model = body + .get("model") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + if let Some(trace_id) = codex_trace_id.as_deref() { + super::codex_router_log::append_event( + "route_error", + &[ + ("trace", trace_id.to_string()), + ("session", self.session_id.clone()), + ("endpoint", endpoint.to_string()), + ("model", request_model.to_string()), + ("outer_provider", outer_provider_id.clone()), + ("fallback_base_url", base_url.clone()), + ("reason", "route_miss_local_proxy_fallback".to_string()), + ], + ); + } + return Err(ProxyError::InvalidRequest(format!( + "Codex router provider did not match model '{request_model}', and its fallback base_url points to the local proxy. Refusing to forward recursively; add a route/defaultRouteId or switch away from the router provider." + ))); + } + + let is_full_url = provider + .meta + .as_ref() + .and_then(|meta| meta.is_full_url) + .unwrap_or(false); + + // GitHub Copilot API 使用 /chat/completions(无 /v1 前缀) + let is_copilot = provider + .meta + .as_ref() + .and_then(|m| m.provider_type.as_deref()) + == Some("github_copilot") + || base_url.contains("githubcopilot.com"); + + // 应用模型映射(独立于格式转换) + // 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) + .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); + mapped_body + }; + + // 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口) + let mut mapped_body = normalize_thinking_type(mapped_body); + + if is_copilot { + mapped_body = + super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body); + self.apply_copilot_live_model_resolution(provider, &mut mapped_body) + .await; + } else { + mapped_body = + super::model_mapper::strip_one_m_suffix_for_upstream_from_body(mapped_body); + } + + // --- Copilot 优化器:分类 + 请求体优化(在格式转换之前执行) --- + // 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move + // + // 执行顺序(与 copilot-api 对齐): + // 1. 先在原始 body 上分类(保留 tool_result 语义,避免误判为 user) + // 2. 再清洗孤立 tool_result(防止上游 API 报错) + // 3. 再合并 tool_result + text(减少 premium 计费) + let copilot_optimization = if is_copilot && self.copilot_optimizer_config.enabled { + // 1. 在原始 body 上分类 — 必须在清洗/合并之前执行 + // 孤立 tool_result 仍保持 tool_result 类型,分类能正确识别为 agent + let has_anthropic_beta = headers.contains_key("anthropic-beta"); + let classification = super::copilot_optimizer::classify_request( + &mapped_body, + has_anthropic_beta, + self.copilot_optimizer_config.compact_detection, + self.copilot_optimizer_config.subagent_detection, + ); + + log::debug!( + "[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}, is_subagent={}", + classification.initiator, + classification.is_warmup, + classification.is_compact, + classification.is_subagent + ); + + // 2. 孤立 tool_result 清理 — 分类完成后再清洗 + // 防止上游 API 因不匹配的 tool_result 报错导致重试/重复计费 + mapped_body = super::copilot_optimizer::sanitize_orphan_tool_results(mapped_body); + + // 3. Tool result 合并 — 将 [tool_result, text] 变为 [tool_result(含text)] + if self.copilot_optimizer_config.tool_result_merging { + mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body); + } + + // 3.5. 主动剥离 thinking block — Copilot 走 OpenAI 兼容端点不识别该块 + // 避免上游拒绝后由 rectifier 反应式重试(首次请求已消耗 quota) + if self.copilot_optimizer_config.strip_thinking { + mapped_body = super::copilot_optimizer::strip_thinking_blocks(mapped_body); + } + + // 4. Warmup 小模型降级 + if self.copilot_optimizer_config.warmup_downgrade && classification.is_warmup { + log::info!( + "[Copilot] Warmup 请求降级到模型: {}", + self.copilot_optimizer_config.warmup_model + ); + mapped_body["model"] = + serde_json::json!(&self.copilot_optimizer_config.warmup_model); + } + + // 预计算确定性 Request ID(在 body 被 move 之前) + // Session 提取优先级(与 session.rs extract_from_metadata 对齐): + // 1. metadata.user_id 中的 _session_ 后缀 + // 2. metadata.session_id(直接字段) + // 3. raw metadata.user_id(整串 fallback) + // 4. x-session-id header + let metadata = body.get("metadata"); + let session_id = metadata + .and_then(|m| m.get("user_id")) + .and_then(|v| v.as_str()) + .and_then(super::session::parse_session_from_user_id) + .or_else(|| { + metadata + .and_then(|m| m.get("session_id")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + }) + .or_else(|| { + metadata + .and_then(|m| m.get("user_id")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + }) + .or_else(|| { + headers + .get("x-session-id") + .and_then(|v| v.to_str().ok()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + }) + .unwrap_or_default(); + let det_request_id = if self.copilot_optimizer_config.deterministic_request_id { + Some(super::copilot_optimizer::deterministic_request_id( + &mapped_body, + &session_id, + )) + } else { + None + }; + + // 从 session ID 派生稳定的 interaction ID(同一主对话共享) + let interaction_id = + super::copilot_optimizer::deterministic_interaction_id(&session_id); + + Some((classification, det_request_id, interaction_id)) + } else { + None + }; + + // GitHub Copilot 动态 endpoint 路由 + // 从 CopilotAuthManager 获取缓存的 API endpoint(支持企业版等非默认 endpoint) + if is_copilot && !is_full_url { + if let Some(app_handle) = &self.app_handle { + let copilot_state = app_handle.state::(); + let copilot_auth = copilot_state.0.read().await; + + // 从 provider.meta 获取关联的 GitHub 账号 ID + let account_id = provider + .meta + .as_ref() + .and_then(|m| m.managed_account_id_for("github_copilot")); + + let dynamic_endpoint = match &account_id { + Some(id) => copilot_auth.get_api_endpoint(id).await, + None => copilot_auth.get_default_api_endpoint().await, + }; + + // 只在动态 endpoint 与当前 base_url 不同时替换 + if dynamic_endpoint != base_url { + log::debug!( + "[Copilot] 使用动态 API endpoint: {} (原: {})", + dynamic_endpoint, + base_url + ); + base_url = dynamic_endpoint; + } + } + } + let resolved_claude_api_format = if adapter.name() == "Claude" { + Some( + self.resolve_claude_api_format(provider, &mapped_body, is_copilot) + .await, + ) + } else { + None + }; + if adapter.name() == "Claude" { + if let Some(api_format) = resolved_claude_api_format.as_deref() { + super::providers::normalize_anthropic_messages_for_provider( + &mut mapped_body, + provider, + api_format, + ); + self.apply_media_prevention(&mut mapped_body, provider); + } + } + let needs_transform = match resolved_claude_api_format.as_deref() { + Some(api_format) => super::providers::claude_api_format_needs_transform(api_format), + None => adapter.needs_transform(provider), + }; + let codex_responses_to_chat = matches!(app_type, AppType::Codex) + && super::providers::should_convert_codex_responses_to_chat(provider, endpoint); + let codex_responses_to_messages = matches!(app_type, AppType::Codex) + && super::providers::should_convert_codex_responses_to_messages(provider, endpoint); + let (effective_endpoint, passthrough_query) = if codex_responses_to_chat { + rewrite_codex_responses_endpoint_to_chat(endpoint) + } else if codex_responses_to_messages { + rewrite_codex_responses_endpoint_to_messages(endpoint) + } else if needs_transform && adapter.name() == "Claude" { + let api_format = resolved_claude_api_format + .as_deref() + .unwrap_or_else(|| super::providers::get_claude_api_format(provider)); + rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot, &mapped_body) + } else { + ( + endpoint.to_string(), + split_endpoint_and_query(endpoint) + .1 + .map(ToString::to_string), + ) + }; + + let codex_chat_base_is_full_endpoint = codex_responses_to_chat + && base_url + .trim_end_matches('/') + .to_ascii_lowercase() + .ends_with("/chat/completions"); + + let url = if matches!(resolved_claude_api_format.as_deref(), Some("gemini_native")) { + super::gemini_url::resolve_gemini_native_url( + &base_url, + &effective_endpoint, + is_full_url, + ) + } else if is_full_url || codex_chat_base_is_full_endpoint { + append_query_to_full_url(&base_url, passthrough_query.as_deref()) + } else { + adapter.build_url(&base_url, &effective_endpoint) + }; + + // 记录映射后的出站模型名(此时 mapped_body 已完成接管映射 / [1m] 剥离 / + // Copilot 归一化)。格式转换后若 body 仍带 model 字段会在下方刷新覆盖; + // gemini_native 等模型在 URL 中的格式则保留此处的转换前真值。 + let mut outbound_model = mapped_body + .get("model") + .and_then(|m| m.as_str()) + .filter(|m| !m.is_empty()) + .map(str::to_string); + + // 转换请求体(如果需要) + let request_prepare_started_at = std::time::Instant::now(); + let mut request_body = if codex_responses_to_chat || codex_responses_to_messages { + let mut mapped_body = mapped_body; + let restored = self + .codex_chat_history + .enrich_request(&mut mapped_body) + .await; + if restored > 0 { + log::debug!( + "[Codex] Restored or enriched {restored} cached function call item(s) for Chat upstream" + ); + } + super::providers::apply_codex_chat_upstream_model(provider, &mut mapped_body); + let reasoning_config = + super::providers::resolve_codex_chat_reasoning_config(provider, &mapped_body); + let text_only_override = super::providers::codex_provider_text_only_input(provider); + let cache_config = super::providers::resolve_codex_cache_config(provider, &mapped_body); + super::providers::transform_codex_chat::responses_to_chat_completions_with_reasoning_text_only_and_cache( + mapped_body, + reasoning_config.as_ref(), + text_only_override, + Some(&cache_config), + )? + } else if needs_transform { + if adapter.name() == "Claude" { + let api_format = resolved_claude_api_format + .as_deref() + .unwrap_or_else(|| super::providers::get_claude_api_format(provider)); + super::providers::transform_claude_request_for_api_format( + mapped_body, + provider, + api_format, + self.session_client_provided + .then_some(self.session_id.as_str()), + Some(self.gemini_shadow.as_ref()), + )? + } else { + adapter.transform_request(mapped_body, provider)? + } + } else { + let mut mapped_body = mapped_body; + if matches!(app_type, AppType::Codex) { + super::providers::apply_codex_request_upstream_model(provider, &mut mapped_body); + } + mapped_body + }; + + if matches!(app_type, AppType::Codex) { + self.apply_media_prevention(&mut request_body, provider); + } + + // 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游 + // 默认使用空白名单,过滤所有 _ 前缀字段 + let request_body = if should_normalize_codex_oauth_responses_passthrough_body( + app_type, + provider, + &url, + needs_transform, + codex_responses_to_chat, + codex_responses_to_messages, + ) { + super::providers::openai_compat::normalize_codex_oauth_responses_request(request_body) + } else if should_normalize_codex_responses_passthrough_control_messages( + app_type, + provider, + endpoint, + needs_transform, + codex_responses_to_chat, + codex_responses_to_messages, + ) { + super::providers::openai_compat::normalize_codex_responses_passthrough_request( + request_body, + ) + } else { + request_body + }; + let mut filtered_body = prepare_upstream_request_body(request_body); + if !is_copilot { + if let Some(overrides) = provider + .meta + .as_ref() + .and_then(|meta| meta.local_proxy_request_overrides.as_ref()) + { + if apply_local_proxy_body_overrides(&mut filtered_body, overrides) { + filtered_body = prepare_upstream_request_body(filtered_body); + } + } + } + // 出站 body 定稿后刷新真值(覆盖 Codex chat 上游模型覆写、转换层模型改写) + if let Some(m) = filtered_body + .get("model") + .and_then(|m| m.as_str()) + .filter(|m| !m.is_empty()) + { + outbound_model = Some(m.to_string()); + } + log_prompt_cache_trace( + app_type, + provider, + &effective_endpoint, + resolved_claude_api_format.as_deref(), + &filtered_body, + self.session_client_provided, + ); + let request_is_streaming = + is_streaming_request(&effective_endpoint, &filtered_body, headers); + let force_identity_encoding = needs_transform + || codex_responses_to_chat + || codex_responses_to_messages + || request_is_streaming; + + let codex_chat_request_shape = + codex_responses_to_chat.then(|| summarize_codex_chat_request_shape(&filtered_body)); + if let Some(trace_id) = codex_trace_id.as_deref() { + let mut fields = vec![ + ("trace", trace_id.to_string()), + ("session", self.session_id.clone()), + ("endpoint", endpoint.to_string()), + ("effective_endpoint", effective_endpoint.clone()), + ("model", request_model_for_log.clone()), + ("provider", provider.id.clone()), + ("upstream_url", url.clone()), + ("responses_to_chat", codex_responses_to_chat.to_string()), + ( + "responses_to_messages", + codex_responses_to_messages.to_string(), + ), + ("streaming", request_is_streaming.to_string()), + ( + "elapsed_ms", + request_prepare_started_at.elapsed().as_millis().to_string(), + ), + ]; + if let Some(shape) = codex_chat_request_shape.as_ref() { + fields.push(("request_shape", shape.clone())); + } + super::codex_router_log::append_event("request_prepared", &fields); + } + + // Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充) + let mut codex_oauth_account_id: Option = None; + let mut should_send_codex_oauth_session_headers = false; + + // 获取认证头(提前准备,用于内联替换) + let auth_started_at = std::time::Instant::now(); + let mut auth_strategy_for_log = "none".to_string(); + let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) { + // GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token + if auth.strategy == AuthStrategy::GitHubCopilot { + if let Some(app_handle) = &self.app_handle { + let copilot_state = app_handle.state::(); + let copilot_auth: tokio::sync::RwLockReadGuard<'_, CopilotAuthManager> = + copilot_state.0.read().await; + + // 从 provider.meta 获取关联的 GitHub 账号 ID(多账号支持) + let account_id = provider + .meta + .as_ref() + .and_then(|m| m.managed_account_id_for("github_copilot")); + + // 根据账号 ID 获取对应 token(向后兼容:无账号 ID 时使用第一个账号) + let token_result = match &account_id { + Some(id) => { + log::debug!("[Copilot] 使用指定账号 {id} 获取 token"); + copilot_auth.get_valid_token_for_account(id).await + } + None => { + log::debug!("[Copilot] 使用默认账号获取 token"); + copilot_auth.get_valid_token().await + } + }; + + match token_result { + Ok(token) => { + auth = AuthInfo::new(token, AuthStrategy::GitHubCopilot); + log::debug!( + "[Copilot] 成功获取 Copilot token (account={})", + account_id.as_deref().unwrap_or("default") + ); + } + Err(e) => { + log::error!( + "[Copilot] 获取 Copilot token 失败 (account={}): {e}", + account_id.as_deref().unwrap_or("default") + ); + return Err(ProxyError::AuthError(format!( + "GitHub Copilot 认证失败: {e}" + ))); + } + } + } else { + log::error!("[Copilot] AppHandle 不可用"); + return Err(ProxyError::AuthError( + "GitHub Copilot 认证不可用(无 AppHandle)".to_string(), + )); + } + } + + // Codex OAuth 特殊处理:从 CodexOAuthManager 获取真实 access_token + if auth.strategy == AuthStrategy::CodexOAuth { + if let Some(app_handle) = &self.app_handle { + let codex_state = app_handle.state::(); + let codex_auth: tokio::sync::RwLockReadGuard<'_, CodexOAuthManager> = + codex_state.0.read().await; + + // 从 provider.meta 获取关联的 ChatGPT 账号 ID + let account_id = provider + .meta + .as_ref() + .and_then(|m| m.managed_account_id_for("codex_oauth")); + + let token_result = match &account_id { + Some(id) => { + log::debug!("[CodexOAuth] 使用指定账号 {id} 获取 token"); + codex_auth.get_valid_token_for_account(id).await + } + None => { + log::debug!("[CodexOAuth] 使用默认账号获取 token"); + codex_auth.get_valid_token().await + } + }; + + match token_result { + Ok(token) => { + auth = AuthInfo::new(token, AuthStrategy::CodexOAuth); + should_send_codex_oauth_session_headers = true; + // 解析使用的 account_id(用于注入 ChatGPT-Account-Id header) + codex_oauth_account_id = match account_id { + Some(id) => Some(id), + None => codex_auth.default_account_id().await, + }; + log::debug!( + "[CodexOAuth] 成功获取 access_token (account={})", + codex_oauth_account_id.as_deref().unwrap_or("default") + ); + } + Err(e) => { + log::error!("[CodexOAuth] 获取 access_token 失败: {e}"); + return Err(ProxyError::AuthError(format!( + "Codex OAuth 认证失败: {e}" + ))); + } + } + } else { + log::error!("[CodexOAuth] AppHandle 不可用"); + return Err(ProxyError::AuthError( + "Codex OAuth 认证不可用(无 AppHandle)".to_string(), + )); + } + } + + auth_strategy_for_log = format!("{:?}", auth.strategy); + adapter.get_auth_headers(&auth)? + } else { + Vec::new() + }; + + // 注入 Codex OAuth 的 ChatGPT-Account-Id header(如果有 account_id) + if let Some(ref account_id) = codex_oauth_account_id { + if let Ok(hv) = http::HeaderValue::from_str(account_id) { + auth_headers.push((http::HeaderName::from_static("chatgpt-account-id"), hv)); + } + } + + let codex_oauth_session_headers = + if should_send_codex_oauth_session_headers && self.session_client_provided { + build_codex_oauth_session_headers(&self.session_id) + } else { + Vec::new() + }; + + if let Some(trace_id) = codex_trace_id.as_deref() { + super::codex_router_log::append_event( + "auth_prepared", + &[ + ("trace", trace_id.to_string()), + ("session", self.session_id.clone()), + ("model", request_model_for_log.clone()), + ("provider", provider.id.clone()), + ("auth_strategy", auth_strategy_for_log.clone()), + ("auth_header_count", auth_headers.len().to_string()), + ( + "oauth_session_header_count", + codex_oauth_session_headers.len().to_string(), + ), + ( + "elapsed_ms", + auth_started_at.elapsed().as_millis().to_string(), + ), + ], + ); + } + + // 自定义 User-Agent:与 stream_check / model_fetch 共用 parse_custom_user_agent, + // 运行时静默忽略非法值(前端在输入处给非阻断提示,不在保存时阻断)。 + // Copilot 指纹 UA 不可覆盖。 + let custom_user_agent = if is_copilot { + None + } else { + provider + .meta + .as_ref() + .and_then(|meta| meta.custom_user_agent_header().ok().flatten()) + }; + + // --- Copilot 优化器:动态 header 注入 --- + if let Some((ref classification, ref det_request_id, ref interaction_id)) = + copilot_optimization + { + for (name, value) in auth_headers.iter_mut() { + match name.as_str() { + "x-initiator" if self.copilot_optimizer_config.request_classification => { + *value = http::HeaderValue::from_static(classification.initiator); + } + "x-interaction-type" if classification.is_subagent => { + // 子代理请求:conversation-subagent 不计 premium interaction + *value = http::HeaderValue::from_static("conversation-subagent"); + } + "x-request-id" | "x-agent-task-id" => { + if let Some(ref det_id) = det_request_id { + if let Ok(hv) = http::HeaderValue::from_str(det_id) { + *value = hv; + } + } + } + _ => {} + } + } + + // x-interaction-id:仅在有 session 时注入(不在 get_auth_headers 中) + if let Some(ref iid) = interaction_id { + if let Ok(hv) = http::HeaderValue::from_str(iid) { + auth_headers.push((http::HeaderName::from_static("x-interaction-id"), hv)); + } + } + + if classification.is_subagent { + log::info!( + "[Copilot] 子代理请求: x-initiator=agent, x-interaction-type=conversation-subagent" + ); + } + } + + // Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重) + let copilot_fingerprint_headers: &[&str] = if is_copilot { + &[ + "user-agent", + "editor-version", + "editor-plugin-version", + "copilot-integration-id", + "x-github-api-version", + "openai-intent", + // 新增 headers + "x-initiator", + "x-interaction-type", + "x-interaction-id", + "x-vscode-user-agent-library-version", + "x-request-id", + "x-agent-task-id", + ] + } else { + &[] + }; + + // 预计算上游 host 值(用于在原位替换 host header) + let upstream_host = url + .parse::() + .ok() + .and_then(|u| u.authority().map(|a| a.to_string())); + + let should_send_anthropic_headers = adapter.name() == "Claude" + && matches!(resolved_claude_api_format.as_deref(), Some("anthropic")); + + // 预计算 anthropic-beta 值(仅 Claude) + let anthropic_beta_value = if should_send_anthropic_headers { + const CLAUDE_CODE_BETA: &str = "claude-code-20250219"; + Some(if let Some(beta) = headers.get("anthropic-beta") { + if let Ok(beta_str) = beta.to_str() { + if beta_str.contains(CLAUDE_CODE_BETA) { + beta_str.to_string() + } else { + format!("{CLAUDE_CODE_BETA},{beta_str}") + } + } else { + CLAUDE_CODE_BETA.to_string() + } + } else { + CLAUDE_CODE_BETA.to_string() + }) + } else { + None + }; + + // ============================================================ + // 构建有序 HeaderMap — 内联替换,保持客户端原始顺序 + // ============================================================ + let mut ordered_headers = http::HeaderMap::new(); + let mut saw_auth = false; + let mut saw_accept_encoding = false; + let mut saw_user_agent = false; + let mut saw_anthropic_beta = false; + let mut saw_anthropic_version = false; + + for (key, value) in headers { + let key_str = key.as_str(); + + // --- host — 原位替换为上游 host(保持客户端原始位置) --- + if key_str.eq_ignore_ascii_case("host") { + if let Some(ref host_val) = upstream_host { + if let Ok(hv) = http::HeaderValue::from_str(host_val) { + ordered_headers.append(key.clone(), hv); + } + } + continue; + } + + // --- 连接 / 追踪 / CDN 类 — 无条件跳过 --- + if matches!( + key_str, + "content-length" + | "transfer-encoding" + | "x-forwarded-host" + | "x-forwarded-port" + | "x-forwarded-proto" + | "forwarded" + | "cf-connecting-ip" + | "cf-ipcountry" + | "cf-ray" + | "cf-visitor" + | "true-client-ip" + | "fastly-client-ip" + | "x-azure-clientip" + | "x-azure-fdid" + | "x-azure-ref" + | "akamai-origin-hop" + | "x-akamai-config-log-detail" + | "x-request-id" + | "x-correlation-id" + | "x-trace-id" + | "x-amzn-trace-id" + | "x-b3-traceid" + | "x-b3-spanid" + | "x-b3-parentspanid" + | "x-b3-sampled" + | "traceparent" + | "tracestate" + ) { + continue; + } + + // --- 认证类 — 用 adapter 提供的认证头替换(在原始位置) --- + if key_str.eq_ignore_ascii_case("authorization") + || key_str.eq_ignore_ascii_case("x-api-key") + || key_str.eq_ignore_ascii_case("x-goog-api-key") + { + if !saw_auth { + saw_auth = true; + for (ah_name, ah_value) in &auth_headers { + ordered_headers.append(ah_name.clone(), ah_value.clone()); + } + } + continue; + } + + // --- accept-encoding — transform / SSE 路径强制 identity,其余保留原值 --- + if key_str.eq_ignore_ascii_case("accept-encoding") { + if !saw_accept_encoding { + saw_accept_encoding = true; + if force_identity_encoding { + ordered_headers.append( + http::header::ACCEPT_ENCODING, + http::HeaderValue::from_static("identity"), + ); + } else { + ordered_headers.append(key.clone(), value.clone()); + } + } + continue; + } + + // --- user-agent: provider-level override for local proxy routing --- + if !is_copilot && key_str.eq_ignore_ascii_case("user-agent") { + if !saw_user_agent { + saw_user_agent = true; + if let Some(ref ua) = custom_user_agent { + ordered_headers.append(http::header::USER_AGENT, ua.clone()); + } else { + ordered_headers.append(key.clone(), value.clone()); + } + } + continue; + } + + // --- anthropic-beta — 用重建值替换(确保含 claude-code 标记) --- + if key_str.eq_ignore_ascii_case("anthropic-beta") { + if !saw_anthropic_beta { + saw_anthropic_beta = true; + if let Some(ref beta_val) = anthropic_beta_value { + if let Ok(hv) = http::HeaderValue::from_str(beta_val) { + ordered_headers.append("anthropic-beta", hv); + } + } + } + continue; + } + + // --- anthropic-version — 透传客户端值 --- + if key_str.eq_ignore_ascii_case("anthropic-version") { + if should_send_anthropic_headers { + saw_anthropic_version = true; + ordered_headers.append(key.clone(), value.clone()); + } + continue; + } + + // --- Copilot 指纹头 — 跳过(由 auth_headers 提供) --- + if copilot_fingerprint_headers + .iter() + .any(|h| key_str.eq_ignore_ascii_case(h)) + { + continue; + } + + // --- 默认:透传 --- + ordered_headers.append(key.clone(), value.clone()); + } + + // 如果原始请求中没有认证头,在末尾追加 + if !saw_auth && !auth_headers.is_empty() { + for (ah_name, ah_value) in &auth_headers { + ordered_headers.append(ah_name.clone(), ah_value.clone()); + } + } + + // transform / SSE 路径在缺失时补 identity;普通透传不主动补 accept-encoding + if !saw_accept_encoding && force_identity_encoding { + ordered_headers.append( + http::header::ACCEPT_ENCODING, + http::HeaderValue::from_static("identity"), + ); + } + + if !saw_user_agent { + if let Some(ref ua) = custom_user_agent { + ordered_headers.append(http::header::USER_AGENT, ua.clone()); + } + } + + // 如果原始请求中没有 anthropic-beta 且有值需要添加,追加 + if !saw_anthropic_beta { + if let Some(ref beta_val) = anthropic_beta_value { + if let Ok(hv) = http::HeaderValue::from_str(beta_val) { + ordered_headers.append("anthropic-beta", hv); + } + } + } + + // anthropic-version:仅在缺失时补充默认值 + if should_send_anthropic_headers && !saw_anthropic_version { + ordered_headers.append( + "anthropic-version", + http::HeaderValue::from_static("2023-06-01"), + ); + } + + // Codex OAuth 反代尽量对齐官方 Codex CLI 的会话路由信号。 + // 只发送客户端提供的 session_id;生成的 UUID 每次不同,反而会破坏前缀缓存。 + for (name, value) in codex_oauth_session_headers { + if !ordered_headers.contains_key(&name) { + ordered_headers.insert(name, value); + } + } + + // 序列化请求体。GET/HEAD 是 idempotent/safe 方法,按 HTTP 语义不应携带 body; + // 强行附带 JSON body 会让某些上游(如 Google Gemini 的 models.list)拒绝请求。 + let body_bytes = if matches!(method, &http::Method::GET | &http::Method::HEAD) { + Vec::new() + } else { + serde_json::to_vec(&filtered_body).map_err(|e| { + ProxyError::Internal(format!("Failed to serialize request body: {e}")) + })? + }; + let request_bytes_len = body_bytes.len(); + + // 确保 content-type 存在 + if !ordered_headers.contains_key(http::header::CONTENT_TYPE) { + ordered_headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + } + + apply_local_proxy_header_overrides( + &mut ordered_headers, + provider + .meta + .as_ref() + .and_then(|meta| meta.local_proxy_request_overrides.as_ref()), + is_copilot, + ); + + reject_proxy_placeholder_for_managed_account_upstream(&url, &ordered_headers)?; + + // 输出请求信息日志 + let tag = adapter.name(); + let request_model = filtered_body + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let responses_lite_fallback_key = + codex_responses_lite_fallback_key(&provider.id, &url, request_model); + if matches!(app_type, AppType::Codex) + && ordered_headers.contains_key(http::HeaderName::from_static( + "x-openai-internal-codex-responses-lite", + )) + && self + .codex_responses_lite_fallback_active(&responses_lite_fallback_key) + .await + { + ordered_headers.remove(http::HeaderName::from_static( + "x-openai-internal-codex-responses-lite", + )); + log::info!( + "[{tag}] 命中 Codex Responses-Lite fallback 缓存,按 provider/url/model 直接去头发送 (model={request_model})" + ); + if let Some(trace_id) = codex_trace_id.as_deref() { + super::codex_router_log::append_event( + "responses_lite_fallback_cache_hit", + &[ + ("trace", trace_id.to_string()), + ("session", self.session_id.clone()), + ("model", request_model.to_string()), + ("provider", provider.id.clone()), + ], + ); + } + } + log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})"); + if log::log_enabled!(log::Level::Debug) { + log::debug!( + "[{tag}] >>> 请求体摘要: bytes={}, body_hash={}", + request_bytes_len, + short_value_hash(Some(&filtered_body)) + ); + } + + // 确定超时 + let timeout = if self.non_streaming_timeout.is_zero() { + std::time::Duration::from_secs(600) // 默认 600 秒 + } else { + self.non_streaming_timeout + }; + + // 获取全局代理 URL + let upstream_proxy_url: Option = super::http_client::get_current_proxy_url(); + + // SOCKS5 代理不支持 CONNECT 隧道,需要用 reqwest + let is_socks_proxy = upstream_proxy_url + .as_deref() + .map(|u| u.starts_with("socks5")) + .unwrap_or(false); + + let preserve_exact_header_case = should_preserve_exact_header_case( + adapter.name(), + provider, + resolved_claude_api_format.as_deref(), + is_copilot, + ); + let transport_for_log = if is_socks_proxy || !preserve_exact_header_case { + "reqwest" + } else { + "hyper" + }; + let upstream_started_at = std::time::Instant::now(); + if let Some(trace_id) = codex_trace_id.as_deref() { + super::codex_router_log::append_event( + "upstream_send", + &[ + ("trace", trace_id.to_string()), + ("session", self.session_id.clone()), + ("model", request_model_for_log.clone()), + ("provider", provider.id.clone()), + ("transport", transport_for_log.to_string()), + ("request_bytes", request_bytes_len.to_string()), + ("header_count", ordered_headers.len().to_string()), + ("streaming", request_is_streaming.to_string()), + ("timeout_ms", timeout.as_millis().to_string()), + ( + "uses_upstream_proxy", + upstream_proxy_url.is_some().to_string(), + ), + ], + ); + } + + // 发送请求。默认保留 Codex Responses-Lite 协商头;只有上游明确返回 + // Lite 不支持错误时,才在错误响应体读取后剥头重发一次。 + let send_upstream_request = |headers: http::HeaderMap, body_bytes: Vec| { + let method = method.clone(); + let url = url.clone(); + let extensions = extensions.clone(); + let upstream_proxy_url = upstream_proxy_url.clone(); + async move { + send_forwarder_upstream_request( + method, + url, + headers, + extensions, + body_bytes, + timeout, + request_is_streaming, + self.non_streaming_timeout, + self.streaming_first_byte_timeout, + is_socks_proxy, + preserve_exact_header_case, + upstream_proxy_url.as_deref(), + ) + .await + } + }; + + let mut response = send_upstream_request(ordered_headers.clone(), body_bytes.clone()) + .await + .inspect_err(|err| { + if let Some(trace_id) = codex_trace_id.as_deref() { + let transport = if is_socks_proxy || !preserve_exact_header_case { + "reqwest" + } else { + "hyper" + }; + super::codex_router_log::append_event( + "upstream_send_error", + &[ + ("trace", trace_id.to_string()), + ("session", self.session_id.clone()), + ("model", request_model_for_log.clone()), + ("provider", provider.id.clone()), + ("transport", transport.to_string()), + ( + "elapsed_ms", + upstream_started_at.elapsed().as_millis().to_string(), + ), + ("error", err.to_string()), + ], + ); + } + })?; + + // 检查响应状态 + let mut status = response.status(); + let upstream_elapsed_ms = upstream_started_at.elapsed().as_millis().to_string(); + if let Some(trace_id) = codex_trace_id.as_deref() { + super::codex_router_log::append_event( + "upstream_status", + &[ + ("trace", trace_id.to_string()), + ("session", self.session_id.clone()), + ("model", request_model_for_log.clone()), + ("provider", provider.id.clone()), + ("status", status.as_u16().to_string()), + ("streaming", request_is_streaming.to_string()), + ("elapsed_ms", upstream_elapsed_ms.clone()), + ], + ); + } + + if !status.is_success() { + let status_code = status.as_u16(); + let body_text = read_decoded_error_body(response).await?; + if let Some(trace_id) = codex_trace_id.as_deref() { + append_upstream_error_event( + trace_id, + &self.session_id, + &request_model_for_log, + &provider.id, + status_code, + body_text.as_deref(), + codex_chat_request_shape.as_deref(), + ); + } + + if should_retry_without_codex_responses_lite_header( + app_type, + &ordered_headers, + status_code, + body_text.as_deref(), + ) { + self.mark_codex_responses_lite_fallback(responses_lite_fallback_key.clone()) + .await; + let mut retry_headers = ordered_headers.clone(); + retry_headers.remove(http::HeaderName::from_static( + "x-openai-internal-codex-responses-lite", + )); + log::warn!( + "[{tag}] 上游拒绝 Codex Responses-Lite,剥离内部协商头后重试一次 (model={request_model})" + ); + if let Some(trace_id) = codex_trace_id.as_deref() { + super::codex_router_log::append_event( + "upstream_retry_without_responses_lite", + &[ + ("trace", trace_id.to_string()), + ("session", self.session_id.clone()), + ("model", request_model_for_log.clone()), + ("provider", provider.id.clone()), + ("status", status_code.to_string()), + ( + "body_summary", + body_text + .as_deref() + .map(summarize_upstream_body) + .unwrap_or_else(|| "".to_string()), + ), + ], + ); + } + response = send_upstream_request(retry_headers, body_bytes.clone()) + .await + .inspect_err(|err| { + if let Some(trace_id) = codex_trace_id.as_deref() { + let transport = if is_socks_proxy || !preserve_exact_header_case { + "reqwest" + } else { + "hyper" + }; + super::codex_router_log::append_event( + "upstream_send_error", + &[ + ("trace", trace_id.to_string()), + ("session", self.session_id.clone()), + ("model", request_model_for_log.clone()), + ("provider", provider.id.clone()), + ("transport", transport.to_string()), + ( + "elapsed_ms", + upstream_started_at.elapsed().as_millis().to_string(), + ), + ("error", err.to_string()), + ], + ); + } + })?; + status = response.status(); + if let Some(trace_id) = codex_trace_id.as_deref() { + super::codex_router_log::append_event( + "upstream_status", + &[ + ("trace", trace_id.to_string()), + ("session", self.session_id.clone()), + ("model", request_model_for_log.clone()), + ("provider", provider.id.clone()), + ("status", status.as_u16().to_string()), + ("streaming", request_is_streaming.to_string()), + ( + "elapsed_ms", + upstream_started_at.elapsed().as_millis().to_string(), + ), + ("retry", "without_responses_lite".to_string()), + ], + ); + } + } else { + return Err(ProxyError::UpstreamError { + status: status_code, + body: body_text, + }); + } + } + + if status.is_success() { + let response_prepare_started_at = std::time::Instant::now(); + let response = self + .prepare_success_response_for_failover(response, request_is_streaming) + .await?; + if let Some(trace_id) = codex_trace_id.as_deref() { + super::codex_router_log::append_event( + "response_ready", + &[ + ("trace", trace_id.to_string()), + ("session", self.session_id.clone()), + ("model", request_model_for_log.clone()), + ("provider", provider.id.clone()), + ("status", status.as_u16().to_string()), + ("streaming", request_is_streaming.to_string()), + ( + "elapsed_ms", + response_prepare_started_at + .elapsed() + .as_millis() + .to_string(), + ), + ], + ); + } + Ok(( + response, + resolved_claude_api_format, + provider.clone(), + outbound_model, + )) + } else { + let status_code = status.as_u16(); + let body_text = read_decoded_error_body(response).await?; + if let Some(trace_id) = codex_trace_id.as_deref() { + append_upstream_error_event( + trace_id, + &self.session_id, + &request_model_for_log, + &provider.id, + status_code, + body_text.as_deref(), + codex_chat_request_shape.as_deref(), + ); + } + + Err(ProxyError::UpstreamError { + status: status_code, + body: body_text, + }) + } + } + + /// 故障转移开启时,成功不能只看上游响应头。 + /// + /// - 非流式:先把完整 body 读到内存,读超时/连接中断会回到 retry loop 尝试下一家。 + /// - 流式:至少等首个 chunk 到达,避免上游返回 200 后一直不吐 SSE 时被误记成功。 + async fn prepare_success_response_for_failover( + &self, + response: ProxyResponse, + request_is_streaming: bool, + ) -> Result { + if request_is_streaming { + return self.prime_streaming_response(response).await; + } + + if self.non_streaming_timeout.is_zero() { + return Ok(response); + } + + let status = response.status(); + let headers = response.headers().clone(); + let body_timeout = self.non_streaming_timeout; + let body = tokio::time::timeout(body_timeout, response.bytes()) + .await + .map_err(|_| { + ProxyError::Timeout(format!( + "响应体读取超时: {}s(上游发完响应头后 body 未到达)", + body_timeout.as_secs() + )) + })??; + + Ok(ProxyResponse::buffered(status, headers, body)) + } + + async fn prime_streaming_response( + &self, + response: ProxyResponse, + ) -> Result { + if self.streaming_first_byte_timeout.is_zero() { + return Ok(response); + } + + let status = response.status(); + let headers = response.headers().clone(); + let timeout = self.streaming_first_byte_timeout; + let mut stream = Box::pin(response.bytes_stream()); + + let first = tokio::time::timeout(timeout, stream.next()) + .await + .map_err(|_| { + ProxyError::Timeout(format!( + "流式响应首包超时: {}s(上游已返回响应头但未返回数据)", + timeout.as_secs() + )) + })?; + + let Some(first) = first else { + return Err(ProxyError::ForwardFailed( + "流式响应在首包到达前结束".to_string(), + )); + }; + + let first = + first.map_err(|e| ProxyError::ForwardFailed(format!("读取流式响应首包失败: {e}")))?; + + if let Some(message) = retryable_error_from_primed_sse_chunk(&first) { + return Err(ProxyError::UpstreamError { + status: 503, + body: Some(message), + }); + } + + let replay = futures::stream::once(async move { Ok(first) }).chain(stream); + Ok(ProxyResponse::streamed(status, headers, replay)) + } + + async fn resolve_claude_api_format( + &self, + provider: &Provider, + body: &Value, + is_copilot: bool, + ) -> String { + if !is_copilot { + return super::providers::get_claude_api_format(provider).to_string(); + } + + let model = body.get("model").and_then(|value| value.as_str()); + if let Some(model_id) = model { + if self + .is_copilot_openai_vendor_model(provider, model_id) + .await + { + return "openai_responses".to_string(); + } + } + + "openai_chat".to_string() + } + + /// 用 Copilot live `/models` 列表确认 model ID 真实可用,找不到时按 family 降级。 + /// 命中缓存后是同步的;首次请求或 5 min 缓存过期后会触发一次 HTTP。 + async fn apply_copilot_live_model_resolution( + &self, + provider: &Provider, + body: &mut serde_json::Value, + ) { + let Some(model_id) = body.get("model").and_then(|v| v.as_str()) else { + return; + }; + let model_id = model_id.to_string(); + + let Some(app_handle) = &self.app_handle else { + return; + }; + let copilot_state = app_handle.state::(); + let copilot_auth = copilot_state.0.read().await; + let account_id = provider + .meta + .as_ref() + .and_then(|m| m.managed_account_id_for("github_copilot")); + + let models_result = match account_id.as_deref() { + Some(id) => copilot_auth.fetch_models_for_account(id).await, + None => copilot_auth.fetch_models().await, + }; + + let models = match models_result { + Ok(m) => m, + Err(err) => { + log::debug!("[Copilot] live model list unavailable, skip resolution: {err}"); + return; + } + }; + + if let Some(resolved) = + super::providers::copilot_model_map::resolve_against_models(&model_id, &models) + { + log::info!("[Copilot] live-model resolve: {model_id} → {resolved}"); + body["model"] = serde_json::Value::String(resolved); + } + } + + async fn is_copilot_openai_vendor_model(&self, provider: &Provider, model_id: &str) -> bool { + let Some(app_handle) = &self.app_handle else { + log::debug!("[Copilot] AppHandle unavailable, fallback to chat/completions"); + return false; + }; + + let copilot_state = app_handle.state::(); + let copilot_auth = copilot_state.0.read().await; + let account_id = provider + .meta + .as_ref() + .and_then(|m| m.managed_account_id_for("github_copilot")); + + let vendor_result = match account_id.as_deref() { + Some(id) => { + copilot_auth + .get_model_vendor_for_account(id, model_id) + .await + } + None => copilot_auth.get_model_vendor(model_id).await, + }; + + match vendor_result { + Ok(Some(vendor)) => vendor.eq_ignore_ascii_case("openai"), + Ok(None) => { + log::debug!( + "[Copilot] Model vendor unavailable for {model_id}, fallback to chat/completions" + ); + false + } + Err(err) => { + log::warn!( + "[Copilot] Failed to resolve model vendor for {model_id}, fallback to chat/completions: {err}" + ); + false + } + } + } + + fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory { + match error { + // 网络和上游错误:都应该尝试下一个供应商 + ProxyError::Timeout(_) => ErrorCategory::Retryable, + ProxyError::ForwardFailed(_) => ErrorCategory::Retryable, + ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable, + // 上游 HTTP 错误:按状态码分桶。 + // + // 客户端请求自身有问题的状态码无论换哪个 provider 都会被拒绝, + // 继续轮询只会放大错误率、污染熔断器健康度、浪费配额: + // 400 Bad Request / 422 Unprocessable Entity ← 请求体格式或语义错误 + // 405 Method Not Allowed / 406 Not Acceptable ← 方法或 Accept 错误 + // 413 Payload Too Large / 414 URI Too Long ← 客户端构造超限 + // 415 Unsupported Media Type ← Content-Type 错误 + // 501 Not Implemented ← 上游协议确实不支持 + // + // 其他 4xx(401/403/404/408/409/429/451 等)和全部 5xx 都保留 + // Retryable —— 换一家 provider 可能持有不同的 key、配额、地域或模型映射。 + ProxyError::UpstreamError { status, .. } => match *status { + 400 | 405 | 406 | 413 | 414 | 415 | 422 | 501 => ErrorCategory::NonRetryable, + _ => ErrorCategory::Retryable, + }, + // Provider 级配置/转换问题:换一个 Provider 可能就能成功 + ProxyError::ConfigError(_) => ErrorCategory::Retryable, + ProxyError::TransformError(_) => ErrorCategory::Retryable, + ProxyError::AuthError(_) => ErrorCategory::Retryable, + ProxyError::StreamIdleTimeout(_) => ErrorCategory::Retryable, + // 无可用供应商:所有供应商都试过了,无法重试 + ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable, + // 其他错误(数据库/内部错误等):不是换供应商能解决的问题 + _ => ErrorCategory::NonRetryable, + } + } +} + +/// 从 ProxyError 中提取错误消息 +fn extract_error_message(error: &ProxyError) -> Option { + match error { + ProxyError::UpstreamError { body, .. } => body.clone(), + _ => Some(error.to_string()), + } +} + +/// 检测 Provider 是否为 Bedrock(通过 CLAUDE_CODE_USE_BEDROCK 环境变量判断) +fn is_bedrock_provider(provider: &Provider) -> bool { + provider + .settings_config + .get("env") + .and_then(|e| e.get("CLAUDE_CODE_USE_BEDROCK")) + .and_then(|v| v.as_str()) + .map(|v| v == "1") + .unwrap_or(false) +} + +fn build_retryable_failure_log( + provider_name: &str, + attempted_providers: usize, + total_providers: usize, + error: &ProxyError, +) -> (&'static str, String) { + let error_summary = summarize_proxy_error(error); + + if total_providers <= 1 { + ( + log_fwd::SINGLE_PROVIDER_FAILED, + format!("Provider {provider_name} 请求失败: {error_summary}"), + ) + } else { + ( + log_fwd::PROVIDER_FAILED_RETRY, + format!( + "Provider {provider_name} 失败,继续尝试下一个 ({attempted_providers}/{total_providers}): {error_summary}" + ), + ) + } +} + +fn build_terminal_failure_log( + attempted_providers: usize, + total_providers: usize, + last_error: Option<&ProxyError>, +) -> Option<(&'static str, String)> { + if total_providers <= 1 { + return None; + } + + let error_summary = last_error + .map(summarize_proxy_error) + .unwrap_or_else(|| "未知错误".to_string()); + + Some(( + log_fwd::ALL_PROVIDERS_FAILED, + format!( + "已尝试 {attempted_providers}/{total_providers} 个 Provider,均失败。最后错误: {error_summary}" + ), + )) +} + +fn summarize_proxy_error(error: &ProxyError) -> String { + match error { + ProxyError::UpstreamError { status, body } => { + let body_summary = body + .as_deref() + .map(summarize_upstream_body) + .filter(|summary| !summary.is_empty()); + + match body_summary { + Some(summary) => format!("上游 HTTP {status}: {summary}"), + None => format!("上游 HTTP {status}"), + } + } + ProxyError::Timeout(message) => { + format!("请求超时: {}", summarize_text_for_log(message, 180)) + } + ProxyError::ForwardFailed(message) => { + format!("请求转发失败: {}", summarize_text_for_log(message, 180)) + } + ProxyError::TransformError(message) => { + format!("响应转换失败: {}", summarize_text_for_log(message, 180)) + } + ProxyError::ConfigError(message) => { + format!("配置错误: {}", summarize_text_for_log(message, 180)) + } + ProxyError::AuthError(message) => { + format!("认证失败: {}", summarize_text_for_log(message, 180)) + } + _ => summarize_text_for_log(&error.to_string(), 180), + } +} + +/// 从已经预读到的首个 SSE 分块里识别“上游还没真正开始生成就失败”的错误。 +/// +/// 这类错误常见于 ChatGPT/Codex OAuth 在高负载时返回 HTTP 200 + `event: error` +/// 或 `event: response.failed`。如果此时直接把响应头交给 Codex,后续已经无法在同一个 +/// HTTP 请求里切换到下一条路由;在首包阶段把它还原为 503,才能复用现有 failover/retry +/// 机制。普通 `response.created` / delta 事件必须原样放行。 +fn retryable_error_from_primed_sse_chunk(first: &Bytes) -> Option { + let text = std::str::from_utf8(first).ok()?; + for block in text.split("\n\n") { + let mut event_name: Option<&str> = None; + let mut data_lines = Vec::new(); + + for line in block.lines() { + if let Some(value) = line.strip_prefix("event:") { + event_name = Some(value.trim()); + } else if let Some(value) = line.strip_prefix("data:") { + data_lines.push(value.trim()); + } + } + + if data_lines.is_empty() { + continue; + } + + let data = data_lines.join("\n"); + let parsed = serde_json::from_str::(&data).ok(); + let event_is_error = matches!( + event_name, + Some("error" | "response.failed" | "response.error") + ); + let payload_is_error = parsed.as_ref().is_some_and(|value| { + value.get("error").is_some() + || value + .get("type") + .and_then(Value::as_str) + .is_some_and(|kind| matches!(kind, "error" | "response.failed")) + || value + .pointer("/response/status") + .and_then(Value::as_str) + .is_some_and(|status| status == "failed") + }); + + if event_is_error || payload_is_error { + return Some(extract_sse_error_message(parsed.as_ref()).unwrap_or(data)); + } + } + + None +} + +/// 提取 SSE 错误体里最适合写入日志/返回给重试分类器的消息。 +fn extract_sse_error_message(value: Option<&Value>) -> Option { + let value = value?; + for pointer in [ + "/error/message", + "/message", + "/response/error/message", + "/response/incomplete_details/reason", + ] { + if let Some(message) = value + .pointer(pointer) + .and_then(Value::as_str) + .map(str::trim) + .filter(|message| !message.is_empty()) + { + return Some(message.to_string()); + } + } + + Some(value.to_string()) +} + +fn summarize_upstream_body(body: &str) -> String { + if let Ok(json_body) = serde_json::from_str::(body) { + if let Some(message) = extract_json_error_message(&json_body) { + return summarize_text_for_log(&message, 180); + } + + if let Ok(compact_json) = serde_json::to_string(&json_body) { + return summarize_text_for_log(&compact_json, 180); + } + } + + summarize_text_for_log(body, 180) +} + +fn extract_json_error_message(body: &Value) -> Option { + let candidates = [ + body.pointer("/error/message"), + body.pointer("/message"), + body.pointer("/detail"), + body.pointer("/error"), + ]; + + candidates + .into_iter() + .flatten() + .find_map(|value| value.as_str().map(ToString::to_string)) +} + +fn split_endpoint_and_query(endpoint: &str) -> (&str, Option<&str>) { + endpoint + .split_once('?') + .map_or((endpoint, None), |(path, query)| (path, Some(query))) +} + +fn strip_beta_query(query: Option<&str>) -> Option { + let filtered = query.map(|query| { + query + .split('&') + .filter(|pair| !pair.is_empty() && !pair.starts_with("beta=")) + .collect::>() + .join("&") + }); + + match filtered.as_deref() { + Some("") | None => None, + Some(_) => filtered, + } +} + +fn is_claude_messages_path(path: &str) -> bool { + matches!(path, "/v1/messages" | "/claude/v1/messages") +} + +fn rewrite_codex_responses_endpoint_to_chat(endpoint: &str) -> (String, Option) { + let (_path, query) = split_endpoint_and_query(endpoint); + let passthrough_query = query.map(ToString::to_string); + let target_path = "/chat/completions"; + let rewritten = match passthrough_query.as_deref() { + Some(query) if !query.is_empty() => format!("{target_path}?{query}"), + _ => target_path.to_string(), + }; + + (rewritten, passthrough_query) +} + +fn rewrite_codex_responses_endpoint_to_messages(endpoint: &str) -> (String, Option) { + let (_path, query) = split_endpoint_and_query(endpoint); + let passthrough_query = query.map(ToString::to_string); + let target_path = "/v1/messages"; + let rewritten = match passthrough_query.as_deref() { + Some(query) if !query.is_empty() => format!("{target_path}?{query}"), + _ => target_path.to_string(), + }; + + (rewritten, passthrough_query) +} + +fn rewrite_claude_transform_endpoint( + endpoint: &str, + api_format: &str, + is_copilot: bool, + body: &Value, +) -> (String, Option) { + let (path, query) = split_endpoint_and_query(endpoint); + let passthrough_query = if is_claude_messages_path(path) { + strip_beta_query(query) + } else { + query.map(ToString::to_string) + }; + + if !is_claude_messages_path(path) { + return (endpoint.to_string(), passthrough_query); + } + + if api_format == "gemini_native" { + let model = + super::providers::transform_gemini::extract_gemini_model(body).unwrap_or("unknown"); + // Accept both bare ids (`gemini-2.5-pro`) and the resource-name + // form (`models/gemini-2.5-pro`) that Gemini SDKs emit. See + // `normalize_gemini_model_id` for rationale. + let model = super::gemini_url::normalize_gemini_model_id(model); + let is_stream = body + .get("stream") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let target_path = if is_stream { + format!("/v1beta/models/{model}:streamGenerateContent") + } else { + format!("/v1beta/models/{model}:generateContent") + }; + + let rewritten_query = merge_query_params( + passthrough_query.as_deref(), + if is_stream { Some("alt=sse") } else { None }, + ); + + let rewritten = match rewritten_query.as_deref() { + Some(query) if !query.is_empty() => format!("{target_path}?{query}"), + _ => target_path, + }; + + return (rewritten, rewritten_query); + } + + let target_path = if is_copilot && api_format == "openai_responses" { + "/v1/responses" + } else if is_copilot { + "/chat/completions" + } else if api_format == "openai_responses" { + "/v1/responses" + } else { + "/v1/chat/completions" + }; + + let rewritten = match passthrough_query.as_deref() { + Some(query) if !query.is_empty() => format!("{target_path}?{query}"), + _ => target_path.to_string(), + }; + + (rewritten, passthrough_query) +} + +fn merge_query_params(base_query: Option<&str>, extra_param: Option<&str>) -> Option { + let mut params: Vec = base_query + .into_iter() + .flat_map(|query| query.split('&')) + .filter(|pair| !pair.is_empty()) + .filter(|pair| !pair.starts_with("alt=")) + .map(ToString::to_string) + .collect(); + + if let Some(extra_param) = extra_param { + params.push(extra_param.to_string()); + } + + if params.is_empty() { + None + } else { + Some(params.join("&")) + } +} + +fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String { + match query { + Some(query) if !query.is_empty() => { + if base_url.contains('?') { + format!("{base_url}&{query}") + } else { + format!("{base_url}?{query}") + } + } + _ => base_url.to_string(), + } +} + +fn build_codex_oauth_session_headers( + session_id: &str, +) -> Vec<(http::HeaderName, http::HeaderValue)> { + let session_id = session_id.trim(); + if session_id.is_empty() { + return Vec::new(); + } + + let mut headers = Vec::new(); + if let Ok(value) = http::HeaderValue::from_str(session_id) { + headers.push((http::HeaderName::from_static("session-id"), value.clone())); + headers.push((http::HeaderName::from_static("thread-id"), value.clone())); + headers.push((http::HeaderName::from_static("x-client-request-id"), value)); + } + + let window_id = format!("{session_id}:0"); + if let Ok(value) = http::HeaderValue::from_str(&window_id) { + headers.push((http::HeaderName::from_static("x-codex-window-id"), value)); + } + + headers +} + +fn reject_proxy_placeholder_for_managed_account_upstream( + url: &str, + headers: &http::HeaderMap, +) -> Result<(), ProxyError> { + if !is_managed_account_upstream_url(url) || !headers_contain_proxy_placeholder(headers) { + return Ok(()); + } + + Err(ProxyError::AuthError( + "Managed account proxy auth was not resolved; PROXY_MANAGED must not be sent upstream" + .to_string(), + )) +} + +fn is_managed_account_upstream_url(url: &str) -> bool { + let Ok(uri) = url.parse::() else { + return false; + }; + + let Some(host) = uri.host().map(str::to_ascii_lowercase) else { + return false; + }; + + host == "githubcopilot.com" + || host.ends_with(".githubcopilot.com") + || (host == "chatgpt.com" && uri.path().starts_with("/backend-api/codex")) +} + +/// 判断某个 Codex 客户端私有头是否应该在转发到上游前移除。 +/// +/// 这个策略只处理 CCSwitchMulti 作为 Codex 本地代理时的上游边界: +/// - 非 Codex app 流量不处理,避免误删其它客户端自定义 header; +/// - 托管 ChatGPT Codex OAuth 是官方后端协议路径,保留内部协商头; +/// - 第三方 OpenAI-compatible / MultiRouter 目标不承诺支持官方私有头,默认剥离。 +fn should_retry_without_codex_responses_lite_header( + app_type: &AppType, + headers: &http::HeaderMap, + status: u16, + body: Option<&str>, +) -> bool { + matches!(app_type, AppType::Codex) + && matches!(status, 400 | 404 | 422 | 501) + && headers.contains_key(http::HeaderName::from_static( + "x-openai-internal-codex-responses-lite", + )) + && body + .map(|body| { + body.contains( + "This model is not supported when using X-OpenAI-Internal-Codex-Responses-Lite", + ) + }) + .unwrap_or(false) +} + +/// 生成 Codex Responses-Lite fallback 的能力缓存 key。 +/// +/// 参数: +/// - `provider_id`: 已解析后的 effective provider id,避免不同上游互相污染。 +/// - `url`: 实际请求 URL,只保留 scheme/host/port/path,忽略 query 中可能出现的敏感参数。 +/// - `model`: 实际请求模型;Lite 支持通常是模型维度能力,不能只按 provider 缓存。 +/// 返回: +/// - 稳定字符串 key,用于短期负缓存。 +fn codex_responses_lite_fallback_key(provider_id: &str, url: &str, model: &str) -> String { + let upstream_scope = url + .parse::() + .ok() + .and_then(|uri| { + let scheme = uri.scheme_str().unwrap_or("http").to_ascii_lowercase(); + let host = uri.host()?.to_ascii_lowercase(); + let port = uri + .port_u16() + .map(|port| format!(":{port}")) + .unwrap_or_default(); + Some(format!("{scheme}://{host}{port}{}", uri.path())) + }) + .unwrap_or_else(|| url.trim().to_ascii_lowercase()); + format!( + "{}|{}|{}", + provider_id.trim(), + upstream_scope, + model.trim().to_ascii_lowercase() + ) +} + +/// 判断 fallback 负缓存条目在指定时间点是否有效。 +/// +/// 副作用: +/// - 过期条目会被删除,避免缓存随着模型/上游组合不断增长。 +fn codex_responses_lite_fallback_active_at( + fallbacks: &mut HashMap, + key: &str, + now: Instant, +) -> bool { + match fallbacks.get(key).copied() { + Some(expires_at) if expires_at > now => true, + Some(_) => { + fallbacks.remove(key); + false + } + None => false, + } +} + +/// 发送一次上游请求,不做业务级重试。 +/// +/// 调用方负责决定是否根据错误体重放请求;这里只封装 reqwest/hyper 两条传输路径, +/// 避免 Responses-Lite fallback 和常规发送逻辑出现分叉。 +#[allow(clippy::too_many_arguments)] +async fn send_forwarder_upstream_request( + method: http::Method, + url: String, + headers: http::HeaderMap, + extensions: Extensions, + body_bytes: Vec, + timeout: std::time::Duration, + request_is_streaming: bool, + non_streaming_timeout: std::time::Duration, + streaming_first_byte_timeout: std::time::Duration, + is_socks_proxy: bool, + preserve_exact_header_case: bool, + upstream_proxy_url: Option<&str>, +) -> Result { + if is_socks_proxy || !preserve_exact_header_case { + log::debug!( + "[Forwarder] Using pooled reqwest client (preserve_exact_header_case={preserve_exact_header_case}, socks_proxy={is_socks_proxy})" + ); + let client = super::http_client::get(); + let mut request = client.request(method.clone(), &url); + if request_is_streaming { + request = request.timeout(std::time::Duration::from_secs(24 * 60 * 60)); + } else if !non_streaming_timeout.is_zero() { + request = request.timeout(non_streaming_timeout); + } + for (key, value) in &headers { + request = request.header(key, value); + } + let send = request.body(body_bytes).send(); + let send_result = if request_is_streaming { + let header_timeout = if streaming_first_byte_timeout.is_zero() { + timeout + } else { + streaming_first_byte_timeout + }; + match tokio::time::timeout(header_timeout, send).await { + Ok(result) => result, + Err(_) => { + return Err(ProxyError::Timeout(format!( + "流式响应首包超时: {}s(上游未返回响应头)", + header_timeout.as_secs() + ))); + } + } + } else { + send.await + }; + return send_result + .map(ProxyResponse::Reqwest) + .map_err(map_reqwest_send_error); + } + + let uri: http::Uri = url + .parse() + .map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?; + super::hyper_client::send_request( + uri, + method, + headers, + extensions, + body_bytes, + timeout, + upstream_proxy_url, + ) + .await +} + +/// 读取并解压上游错误响应体,保留可读错误摘要给日志、fallback 判断和客户端。 +async fn read_decoded_error_body(response: ProxyResponse) -> Result, ProxyError> { + let encoding = get_content_encoding(response.headers()); + let raw = response.bytes().await?; + let decoded = match encoding { + Some(encoding) => match decompress_body(&encoding, &raw) { + Ok(Some(decompressed)) => decompressed, + _ => raw.to_vec(), + }, + None => raw.to_vec(), + }; + Ok(String::from_utf8(decoded).ok()) +} + +/// 记录上游错误响应。body 只进入摘要,避免把完整 prompt 或大响应写入日志。 +fn append_upstream_error_event( + trace_id: &str, + session_id: &str, + request_model: &str, + provider_id: &str, + status: u16, + body_text: Option<&str>, + request_shape: Option<&str>, +) { + let mut fields = vec![ + ("trace", trace_id.to_string()), + ("session", session_id.to_string()), + ("model", request_model.to_string()), + ("provider", provider_id.to_string()), + ("status", status.to_string()), + ( + "body_summary", + body_text + .map(summarize_upstream_body) + .unwrap_or_else(|| "".to_string()), + ), + ]; + if let Some(shape) = request_shape { + fields.push(("request_shape", shape.to_string())); + } + super::codex_router_log::append_event("upstream_error", &fields); +} + +/// 识别会触发上游 Responses-Lite 分支的 Codex 内部请求头。 +#[allow(dead_code)] +fn is_codex_responses_lite_header(name: &http::HeaderName) -> bool { + name.as_str() + .eq_ignore_ascii_case("x-openai-internal-codex-responses-lite") +} + +fn headers_contain_proxy_placeholder(headers: &http::HeaderMap) -> bool { + headers.values().any(|value| { + value + .to_str() + .map(|value| value.contains(PROXY_AUTH_PLACEHOLDER)) + .unwrap_or(false) + }) +} + +fn should_preserve_exact_header_case( + adapter_name: &str, + provider: &Provider, + resolved_claude_api_format: Option<&str>, + is_copilot: bool, +) -> bool { + if matches!(adapter_name, "Codex" | "Gemini") { + return false; + } + + if is_copilot || provider.is_codex_oauth() { + return false; + } + + matches!(resolved_claude_api_format, None | Some("anthropic")) +} + +/// 判断本次请求是否是 ChatGPT Codex 官方后端的 Responses 透传路径。 +/// +/// 参数: +/// - `app_type`: 当前客户端应用类型,只有 Codex Desktop/CLI 请求需要该兼容层。 +/// - `provider`: 已经由 MultiRouter 解析后的 effective provider。 +/// - `url`: forwarder 最终要访问的上游 URL。 +/// - `needs_transform`: 是否已经走了 Claude/Anthropic 转换管线。 +/// - `codex_responses_to_chat`: 是否已经被改写到 Chat Completions 上游。 +/// - `codex_responses_to_messages`: 是否已经被改写到 Messages 上游。 +/// 返回: +/// - `true` 表示需要在透传前补齐 ChatGPT Codex backend 的必填字段。 +/// 副作用: +/// - 无。该函数只读入参,用来把修复范围限制在 official managed Codex OAuth。 +fn should_normalize_codex_oauth_responses_passthrough_body( + app_type: &AppType, + provider: &Provider, + url: &str, + needs_transform: bool, + codex_responses_to_chat: bool, + codex_responses_to_messages: bool, +) -> bool { + matches!(app_type, AppType::Codex) + && provider.is_codex_oauth() + && !needs_transform + && !codex_responses_to_chat + && !codex_responses_to_messages + && is_chatgpt_codex_responses_upstream_url(url) +} + +/// 判断是否需要规整第三方 Responses 透传请求中的 Codex 控制消息。 +/// +/// 参数: +/// - `app_type`: 当前客户端应用类型,只有 Codex 的 Responses 历史会携带这类角色。 +/// - `provider`: 已经由 MultiRouter 解析后的 effective provider。 +/// - `endpoint`: 本地代理收到的 endpoint。 +/// - `needs_transform`: 是否已进入其它格式转换管线。 +/// - `codex_responses_to_chat`: 是否已转成 Chat Completions。 +/// - `codex_responses_to_messages`: 是否已转成 Messages。 +/// 返回: +/// - `true` 表示该请求会原生透传到第三方 Responses API,需要把 developer/system +/// input item 提升到 instructions。 +/// 副作用: +/// - 无。 +fn should_normalize_codex_responses_passthrough_control_messages( + app_type: &AppType, + provider: &Provider, + endpoint: &str, + needs_transform: bool, + codex_responses_to_chat: bool, + codex_responses_to_messages: bool, +) -> bool { + matches!(app_type, AppType::Codex) + && !provider.is_codex_oauth() + && !needs_transform + && !codex_responses_to_chat + && !codex_responses_to_messages + && super::providers::is_codex_responses_endpoint(endpoint) +} + +/// 判断 URL 是否指向 ChatGPT 的 Codex Responses backend。 +/// +/// 参数: +/// - `url`: 已拼接完成的上游 URL。 +/// 返回: +/// - `true` 表示 host/path 是 `chatgpt.com/backend-api/codex/responses` 系列。 +/// 副作用: +/// - 无。解析失败时保守返回 `false`,避免影响普通 OpenAI/兼容厂商。 +fn is_chatgpt_codex_responses_upstream_url(url: &str) -> bool { + let Ok(uri) = url.parse::() else { + return false; + }; + + let Some(host) = uri.host().map(str::to_ascii_lowercase) else { + return false; + }; + if host != "chatgpt.com" { + return false; + } + + matches!( + uri.path().trim_end_matches('/'), + "/backend-api/codex/responses" | "/backend-api/codex/responses/compact" + ) +} + +fn is_streaming_request(endpoint: &str, body: &Value, headers: &axum::http::HeaderMap) -> bool { + if body + .get("stream") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return true; + } + + if endpoint.contains("streamGenerateContent") || endpoint.contains("alt=sse") { + return true; + } + + headers + .get(axum::http::header::ACCEPT) + .and_then(|value| value.to_str().ok()) + .map(|accept| accept.contains("text/event-stream")) + .unwrap_or(false) +} + +#[cfg(test)] +fn should_force_identity_encoding( + endpoint: &str, + body: &Value, + headers: &axum::http::HeaderMap, +) -> bool { + is_streaming_request(endpoint, body, headers) +} + +fn map_reqwest_send_error(error: reqwest::Error) -> ProxyError { + if error.is_timeout() { + ProxyError::Timeout(format!("请求超时: {error}")) + } else if error.is_connect() { + ProxyError::ForwardFailed(format!("连接失败: {error}")) + } else { + ProxyError::ForwardFailed(error.to_string()) + } +} + +fn summarize_text_for_log(text: &str, max_chars: usize) -> String { + let normalized = text.split_whitespace().collect::>().join(" "); + let trimmed = normalized.trim(); + + if trimmed.chars().count() <= max_chars { + return trimmed.to_string(); + } + + let truncated: String = trimmed.chars().take(max_chars).collect(); + let truncated = truncated.trim_end(); + format!("{truncated}...") +} + +fn apply_local_proxy_body_overrides( + body: &mut Value, + overrides: &LocalProxyRequestOverrides, +) -> bool { + let Some(override_body) = overrides.body.as_ref() else { + return false; + }; + + if !override_body.is_object() { + log::warn!("[LocalProxyOverrides] Ignoring body override because it is not an object"); + return false; + } + + merge_json_override(body, override_body) +} + +fn merge_json_override(target: &mut Value, patch: &Value) -> bool { + merge_json_override_inner(target, patch, true) +} + +fn merge_json_override_inner(target: &mut Value, patch: &Value, is_top_level: bool) -> bool { + match (target, patch) { + (Value::Object(target_map), Value::Object(patch_map)) => { + let mut changed = false; + for (key, patch_value) in patch_map { + if is_top_level && key == "stream" { + log::warn!( + "[LocalProxyOverrides] Ignoring body override for protected field: stream" + ); + continue; + } + match target_map.get_mut(key) { + Some(target_value) => { + changed |= merge_json_override_inner(target_value, patch_value, false); + } + None => { + target_map.insert(key.clone(), patch_value.clone()); + changed = true; + } + } + } + changed + } + (target_value, patch_value) => { + if target_value == patch_value { + false + } else { + *target_value = patch_value.clone(); + true + } + } + } +} + +fn apply_local_proxy_header_overrides( + headers: &mut http::HeaderMap, + overrides: Option<&LocalProxyRequestOverrides>, + is_copilot: bool, +) { + if is_copilot { + return; + } + + let Some(header_overrides) = overrides.map(|overrides| &overrides.headers) else { + return; + }; + + for (raw_name, raw_value) in header_overrides { + let header_name = raw_name.trim().to_ascii_lowercase(); + if header_name.is_empty() { + log::warn!("[LocalProxyOverrides] Ignoring header override with empty name"); + continue; + } + + let Ok(name) = http::HeaderName::from_bytes(header_name.as_bytes()) else { + log::warn!("[LocalProxyOverrides] Ignoring invalid header override name: {raw_name}"); + continue; + }; + + if is_protected_local_proxy_override_header(&name) { + log::debug!( + "[LocalProxyOverrides] Ignoring protected header override: {}", + name.as_str() + ); + continue; + } + + let Ok(value) = http::HeaderValue::from_str(raw_value) else { + log::warn!( + "[LocalProxyOverrides] Ignoring invalid header override value for {}", + name.as_str() + ); + continue; + }; + + headers.insert(name, value); + } +} + +fn is_protected_local_proxy_override_header(name: &http::HeaderName) -> bool { + matches!( + name.as_str(), + "host" + | "content-length" + | "transfer-encoding" + | "connection" + | "proxy-authorization" + | "proxy-authenticate" + | "te" + | "trailer" + | "upgrade" + | "accept-encoding" + | "content-type" + | "authorization" + | "x-api-key" + | "x-goog-api-key" + | "chatgpt-account-id" + | "session_id" + | "x-client-request-id" + | "x-codex-window-id" + | "x-forwarded-host" + | "x-forwarded-port" + | "x-forwarded-proto" + | "forwarded" + | "cf-connecting-ip" + | "cf-ipcountry" + | "cf-ray" + | "cf-visitor" + | "true-client-ip" + | "fastly-client-ip" + | "x-azure-clientip" + | "x-azure-fdid" + | "x-azure-ref" + | "akamai-origin-hop" + | "x-akamai-config-log-detail" + | "x-request-id" + | "x-correlation-id" + | "x-trace-id" + | "x-amzn-trace-id" + | "x-b3-traceid" + | "x-b3-spanid" + | "x-b3-parentspanid" + | "x-b3-sampled" + | "traceparent" + | "tracestate" + ) +} + +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 { + parts.push(format!("body={}", value_for_log(body))); + } + + parts.push(format!( + "messages={}", + body.get("messages") + .and_then(Value::as_array) + .map(|values| values.len().to_string()) + .unwrap_or_else(|| "absent".to_string()) + )); + + if let Some(tools) = body.get("tools").and_then(Value::as_array) { + let mut types = tools + .iter() + .filter_map(|tool| tool.get("type").and_then(Value::as_str)) + .collect::>(); + types.sort_unstable(); + types.dedup(); + parts.push(format!("tools={}", tools.len())); + parts.push(format!("tool_types=[{}]", types.join(","))); + } else { + parts.push("tools=absent".to_string()); + } + + for key in [ + "tool_choice", + "parallel_tool_calls", + "metadata", + "service_tier", + "stream_options", + "response_format", + "reasoning_effort", + "enable_thinking", + "reasoning", + ] { + parts.push(format!( + "{key}={}", + body.get(key) + .map(value_for_shape_log) + .unwrap_or_else(|| "absent".to_string()) + )); + } + + parts.push(format!( + "thinking={}", + body.get("thinking") + .map(value_for_shape_log) + .unwrap_or_else(|| "absent".to_string()) + )); + + parts.join(";") +} + +/// 把 JSON 值压缩成不含正文内容的形态描述。 +fn value_for_shape_log(value: &Value) -> String { + match value { + Value::Object(object) => { + let mut keys = object.keys().map(String::as_str).collect::>(); + keys.sort_unstable(); + format!("object(keys=[{}])", keys.join(",")) + } + Value::Array(values) => format!("array(len={})", values.len()), + Value::Bool(_) => "bool".to_string(), + Value::Number(_) => "number".to_string(), + Value::String(_) => "string".to_string(), + Value::Null => "null".to_string(), + } +} + +/// 构建本次转发要尝试的 provider 列表。 +/// +/// Codex MultiRouter 必须保留父 provider 进入 `forward()`,再由 `forward()` 在请求上下文内解析 +/// 具体 route。提前展开成 `parent::route::*` 会让状态页、健康记录、日志 outer_provider 和后续 +/// 转换器判定丢掉父 router 身份,表现为“监听正常但没有命中当前 MultiRouter”。 +fn build_forward_attempt_providers_preserving_codex_router_context( + app_type: &AppType, + providers: &[Provider], + body: &Value, +) -> Vec { + let _ = (app_type, body); + providers.to_vec() +} + +fn log_prompt_cache_trace( + app_type: &AppType, + provider: &Provider, + endpoint: &str, + api_format: Option<&str>, + body: &Value, + session_client_provided: bool, +) { + if !log::log_enabled!(log::Level::Debug) { + return; + } + + let prompt_cache_key = body + .get("prompt_cache_key") + .and_then(|value| value.as_str()) + .map(|key| format!("present(len={})", key.len())) + .unwrap_or_else(|| "absent".to_string()); + let store = body + .get("store") + .map(value_for_log) + .unwrap_or_else(|| "absent".to_string()); + let stream = body + .get("stream") + .map(value_for_log) + .unwrap_or_else(|| "absent".to_string()); + + log::debug!( + "[CacheTrace] app={}, provider={}, endpoint={}, api_format={}, session_client_provided={}, prompt_cache_key={}, store={}, stream={}, instructions_hash={}, tools_hash={}, input_hash={}, include_hash={}, body_hash={}", + app_type.as_str(), + provider.id, + endpoint, + api_format.unwrap_or("native"), + session_client_provided, + prompt_cache_key, + store, + stream, + short_value_hash(body.get("instructions")), + short_value_hash(body.get("tools")), + short_value_hash(body.get("input")), + short_value_hash(body.get("include")), + short_value_hash(Some(body)), + ); +} + +fn value_for_log(value: &Value) -> String { + match value { + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => value.clone(), + Value::Null => "null".to_string(), + Value::Array(values) => format!("array(len={})", values.len()), + Value::Object(values) => format!("object(len={})", values.len()), + } +} + +/// 判断 Codex provider 是否声明了本地模型路由。 +/// +/// 这个标记用于区分普通 Codex provider 与“外层 bucket router”。只有 router +/// provider 发生 route miss 时,才需要额外防止回退到自身本地代理地址。 +fn codex_provider_has_routing_config(provider: &Provider) -> bool { + provider.settings_config.get("codexRouting").is_some() + || provider.settings_config.get("codexModelRoutes").is_some() + || provider.settings_config.get("modelRoutes").is_some() +} + +/// 判断 fallback base_url 是否指向本机代理入口。 +/// +/// 这里不依赖端口固定值,因为历史配置里出现过 15721 Rust proxy 和 15722 +/// Node sidecar;只要 router provider 在 route miss 后回到本机地址,就有递归或 +/// 未运行服务导致超时的风险。 +fn codex_base_url_points_to_local_proxy(base_url: &str) -> bool { + let normalized = base_url.trim().to_ascii_lowercase(); + normalized.contains("://127.0.0.1") + || normalized.contains("://localhost") + || normalized.contains("://[::1]") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::Database; + use crate::provider::LocalProxyRequestOverrides; + use axum::http::header::{HeaderValue, ACCEPT}; + use axum::http::HeaderMap; + use bytes::Bytes; + use http::StatusCode; + use serde_json::json; + + fn test_provider_with_type(provider_type: Option<&str>) -> Provider { + Provider { + id: "provider-1".to_string(), + name: "Provider 1".to_string(), + settings_config: json!({}), + website_url: None, + category: None, + created_at: None, + sort_index: None, + notes: None, + meta: provider_type.map(|value| crate::provider::ProviderMeta { + provider_type: Some(value.to_string()), + ..Default::default() + }), + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + + fn test_forwarder( + non_streaming_timeout: Duration, + streaming_first_byte_timeout: Duration, + ) -> RequestForwarder { + let db = Arc::new(Database::memory().expect("memory db")); + + RequestForwarder { + router: Arc::new(ProviderRouter::new(db.clone())), + status: Arc::new(RwLock::new(ProxyStatus::default())), + current_providers: Arc::new(RwLock::new(HashMap::new())), + gemini_shadow: Arc::new(GeminiShadowStore::new()), + codex_chat_history: Arc::new(CodexChatHistoryStore::default()), + failover_manager: Arc::new(FailoverSwitchManager::new(db)), + app_handle: None, + current_provider_id_at_start: String::new(), + session_id: String::new(), + session_client_provided: false, + rectifier_config: RectifierConfig::default(), + optimizer_config: OptimizerConfig::default(), + copilot_optimizer_config: CopilotOptimizerConfig::default(), + codex_responses_lite_fallbacks: Arc::new(RwLock::new(HashMap::new())), + non_streaming_timeout, + streaming_first_byte_timeout, + max_attempts: 1, + } + } + + // 验证只有上游明确返回 Responses-Lite 不支持时,才触发剥头重试。 + #[test] + fn codex_responses_lite_error_triggers_retry_without_header() { + let header = http::HeaderName::from_static("x-openai-internal-codex-responses-lite"); + let mut headers = http::HeaderMap::new(); + headers.insert(header, http::HeaderValue::from_static("true")); + + assert!(should_retry_without_codex_responses_lite_header( + &AppType::Codex, + &headers, + 400, + Some("This model is not supported when using X-OpenAI-Internal-Codex-Responses-Lite.") + )); + } + + // 验证普通 400 不触发剥头重试,避免隐藏真实请求错误。 + #[test] + fn ordinary_upstream_error_does_not_trigger_responses_lite_retry() { + let header = http::HeaderName::from_static("x-openai-internal-codex-responses-lite"); + let mut headers = http::HeaderMap::new(); + headers.insert(header, http::HeaderValue::from_static("true")); + + assert!(!should_retry_without_codex_responses_lite_header( + &AppType::Codex, + &headers, + 400, + Some("invalid_request_error: missing required field") + )); + } + + // 验证非 Codex app 流量不应用 Codex Responses-Lite fallback。 + #[test] + fn non_codex_app_does_not_trigger_responses_lite_retry() { + let header = http::HeaderName::from_static("x-openai-internal-codex-responses-lite"); + let mut headers = http::HeaderMap::new(); + headers.insert(header, http::HeaderValue::from_static("true")); + + assert!(!should_retry_without_codex_responses_lite_header( + &AppType::Claude, + &headers, + 400, + Some("This model is not supported when using X-OpenAI-Internal-Codex-Responses-Lite.") + )); + } + + // 验证 header 名识别只匹配已知 Codex Responses-Lite 私有头。 + #[test] + fn codex_responses_lite_header_name_is_detected_precisely() { + let lite_header = http::HeaderName::from_static("x-openai-internal-codex-responses-lite"); + let custom_header = http::HeaderName::from_static("x-custom-feature"); + + assert!(is_codex_responses_lite_header(&lite_header)); + assert!(!is_codex_responses_lite_header(&custom_header)); + } + + // 验证 fallback key 按 provider、上游 path 和模型隔离,避免一个模型失败后误伤其它模型。 + #[test] + fn codex_responses_lite_fallback_key_scopes_provider_url_and_model() { + let key_a = codex_responses_lite_fallback_key( + "provider-a", + "https://api.example.com/v1/responses?token=secret", + "gpt-5.5", + ); + let key_b = codex_responses_lite_fallback_key( + "provider-a", + "https://api.example.com/v1/responses?token=other", + "gpt-5.5", + ); + let key_other_model = codex_responses_lite_fallback_key( + "provider-a", + "https://api.example.com/v1/responses", + "gpt-5.4", + ); + let key_other_provider = codex_responses_lite_fallback_key( + "provider-b", + "https://api.example.com/v1/responses", + "gpt-5.5", + ); + + assert_eq!(key_a, key_b); + assert_ne!(key_a, key_other_model); + assert_ne!(key_a, key_other_provider); + assert!(!key_a.contains("secret")); + } + + // 验证短期负缓存命中期间有效,过期后会自动删除并允许下一次重新带头探测。 + #[test] + fn codex_responses_lite_fallback_cache_expires() { + let now = Instant::now(); + let key = "provider|https://api.example.com/v1/responses|gpt-5.5".to_string(); + let mut fallbacks = HashMap::new(); + fallbacks.insert(key.clone(), now + CODEX_RESPONSES_LITE_FALLBACK_TTL); + + assert!(codex_responses_lite_fallback_active_at( + &mut fallbacks, + &key, + now + Duration::from_secs(60) + )); + assert!(!codex_responses_lite_fallback_active_at( + &mut fallbacks, + &key, + now + CODEX_RESPONSES_LITE_FALLBACK_TTL + Duration::from_secs(1) + )); + assert!(!fallbacks.contains_key(&key)); + } + + #[test] + fn codex_chat_request_shape_omits_prompt_text_and_records_field_shapes() { + let body = json!({ + "model": "glm-5.2", + "messages": [ + {"role": "user", "content": "secret prompt should not appear"} + ], + "thinking": {"type": "enabled"}, + "reasoning_effort": "max", + "stream_options": {"include_usage": true}, + "tools": [{ + "type": "function", + "function": { + "name": "read_secret", + "parameters": {"type": "object"} + } + }] + }); + + let summary = summarize_codex_chat_request_shape(&body); + + assert!(summary.contains("model")); + assert!(summary.contains("messages=1")); + assert!(summary.contains("tools=1")); + assert!(summary.contains("tool_types=[function]")); + assert!(summary.contains("thinking=object(keys=[type])")); + assert!(summary.contains("reasoning_effort=string")); + assert!(summary.contains("stream_options=object(keys=[include_usage])")); + assert!(!summary.contains("secret prompt")); + assert!(!summary.contains("read_secret")); + } + + #[test] + fn single_provider_retryable_log_uses_single_provider_code() { + let error = ProxyError::UpstreamError { + status: 429, + body: Some(r#"{"error":{"message":"rate limit exceeded"}}"#.to_string()), + }; + + let (code, message) = build_retryable_failure_log("PackyCode-response", 1, 1, &error); + + assert_eq!(code, log_fwd::SINGLE_PROVIDER_FAILED); + assert!(message.contains("Provider PackyCode-response 请求失败")); + assert!(message.contains("上游 HTTP 429")); + assert!(message.contains("rate limit exceeded")); + assert!(!message.contains("切换下一个")); + } + + #[test] + fn multi_provider_retryable_log_keeps_failover_wording() { + let error = ProxyError::Timeout("upstream timed out after 30s".to_string()); + + let (code, message) = build_retryable_failure_log("primary", 1, 3, &error); + + assert_eq!(code, log_fwd::PROVIDER_FAILED_RETRY); + assert!(message.contains("继续尝试下一个 (1/3)")); + assert!(message.contains("请求超时")); + } + + #[test] + fn single_provider_has_no_terminal_all_failed_log() { + assert!(build_terminal_failure_log(1, 1, None).is_none()); + } + + #[test] + fn multi_provider_terminal_log_contains_last_error_summary() { + let error = ProxyError::ForwardFailed("connection reset by peer".to_string()); + + let (code, message) = + build_terminal_failure_log(2, 2, Some(&error)).expect("expected terminal log"); + + assert_eq!(code, log_fwd::ALL_PROVIDERS_FAILED); + assert!(message.contains("已尝试 2/2 个 Provider,均失败")); + assert!(message.contains("connection reset by peer")); + } + + #[test] + fn summarize_upstream_body_prefers_json_message() { + let body = json!({ + "error": { + "message": "invalid_request_error: unsupported field" + }, + "request_id": "req_123" + }); + + let summary = summarize_upstream_body(&body.to_string()); + + assert_eq!(summary, "invalid_request_error: unsupported field"); + } + + #[test] + fn summarize_text_for_log_collapses_whitespace_and_truncates() { + let summary = summarize_text_for_log("line1\n\n line2 line3", 12); + + assert_eq!(summary, "line1 line2..."); + } + + #[test] + fn codex_local_router_fallback_detection_covers_known_loopback_urls() { + assert!(codex_base_url_points_to_local_proxy( + "http://127.0.0.1:15721/v1" + )); + assert!(codex_base_url_points_to_local_proxy( + "http://localhost:15722/v1" + )); + assert!(!codex_base_url_points_to_local_proxy( + "https://api.openai.com/v1" + )); + } + + #[test] + fn codex_routing_config_detection_reads_new_and_legacy_fields() { + let mut provider = test_provider_with_type(None); + assert!(!codex_provider_has_routing_config(&provider)); + + provider.settings_config = json!({ "codexRouting": { "routes": [] } }); + assert!(codex_provider_has_routing_config(&provider)); + + provider.settings_config = json!({ "modelRoutes": [] }); + assert!(codex_provider_has_routing_config(&provider)); + } + + #[test] + fn codex_multirouter_attempts_keep_parent_provider_context() { + let mut provider = test_provider_with_type(None); + provider.id = "codex-openai-router".to_string(); + provider.name = "OpenAI Multi-Model Router".to_string(); + provider.settings_config = json!({ + "codexRouting": { + "enabled": true, + "routes": [ + { + "id": "qwen-local", + "name": "Qwen Local vLLM", + "enabled": true, + "models": ["qwen3.6"], + "base_url": "https://example.test/v1", + "wireApi": "chat" + } + ] + } + }); + + let attempts = build_forward_attempt_providers_preserving_codex_router_context( + &AppType::Codex, + &[provider.clone()], + &json!({ "model": "qwen3.6" }), + ); + + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].id, "codex-openai-router"); + assert!(attempts[0] + .settings_config + .get("codexResolvedRouteId") + .is_none()); + } + + #[test] + fn canonical_json_sorts_object_keys_for_cache_trace_hashes() { + let left = json!({ + "tools": [ + { + "parameters": { + "properties": { + "b": {"type": "string"}, + "a": {"type": "number"} + }, + "type": "object" + }, + "name": "lookup" + } + ] + }); + let right = json!({ + "tools": [ + { + "name": "lookup", + "parameters": { + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "string"} + } + } + } + ] + }); + + assert_eq!( + crate::proxy::json_canonical::canonical_json_string(&left), + crate::proxy::json_canonical::canonical_json_string(&right) + ); + assert_eq!( + short_value_hash(Some(&left)), + short_value_hash(Some(&right)) + ); + } + + #[test] + fn prepare_upstream_request_body_filters_private_fields_and_canonicalizes_order() { + let body = json!({ + "z": 1, + "_internal": "drop", + "tools": [ + { + "name": "lookup", + "parameters": { + "type": "object", + "properties": { + "_id": { + "_private_note": "drop", + "type": "string" + }, + "b": {"type": "number"}, + "a": {"type": "string"} + } + } + } + ], + "a": 2 + }); + + let prepared = prepare_upstream_request_body(body); + + assert!(prepared.get("_internal").is_none()); + assert!(prepared["tools"][0]["parameters"]["properties"] + .get("_id") + .is_some()); + assert!(prepared["tools"][0]["parameters"]["properties"]["_id"] + .get("_private_note") + .is_none()); + assert_eq!( + serde_json::to_string(&prepared).unwrap(), + r#"{"a":2,"tools":[{"name":"lookup","parameters":{"properties":{"_id":{"type":"string"},"a":{"type":"string"},"b":{"type":"number"}},"type":"object"}}],"z":1}"# + ); + } + + #[test] + fn codex_oauth_responses_passthrough_normalizer_is_scoped() { + let codex_oauth = test_provider_with_type(Some("codex_oauth")); + let regular = test_provider_with_type(None); + let official_url = "https://chatgpt.com/backend-api/codex/responses"; + + assert!(should_normalize_codex_oauth_responses_passthrough_body( + &AppType::Codex, + &codex_oauth, + official_url, + false, + false, + false + )); + assert!(!should_normalize_codex_oauth_responses_passthrough_body( + &AppType::Codex, + ®ular, + official_url, + false, + false, + false + )); + assert!(!should_normalize_codex_oauth_responses_passthrough_body( + &AppType::Codex, + &codex_oauth, + "https://api.openai.com/v1/responses", + false, + false, + false + )); + assert!(!should_normalize_codex_oauth_responses_passthrough_body( + &AppType::Claude, + &codex_oauth, + official_url, + false, + false, + false + )); + assert!(!should_normalize_codex_oauth_responses_passthrough_body( + &AppType::Codex, + &codex_oauth, + official_url, + true, + false, + false + )); + assert!(!should_normalize_codex_oauth_responses_passthrough_body( + &AppType::Codex, + &codex_oauth, + official_url, + false, + true, + false + )); + } + + #[test] + fn codex_responses_passthrough_control_message_normalizer_is_scoped() { + let codex_oauth = test_provider_with_type(Some("codex_oauth")); + let regular = test_provider_with_type(None); + + assert!( + should_normalize_codex_responses_passthrough_control_messages( + &AppType::Codex, + ®ular, + "/v1/responses", + false, + false, + false + ) + ); + assert!( + should_normalize_codex_responses_passthrough_control_messages( + &AppType::Codex, + ®ular, + "/responses/compact?conversation=1", + false, + false, + false + ) + ); + assert!( + !should_normalize_codex_responses_passthrough_control_messages( + &AppType::Codex, + &codex_oauth, + "/v1/responses", + false, + false, + false + ) + ); + assert!( + !should_normalize_codex_responses_passthrough_control_messages( + &AppType::Claude, + ®ular, + "/v1/responses", + false, + false, + false + ) + ); + assert!( + !should_normalize_codex_responses_passthrough_control_messages( + &AppType::Codex, + ®ular, + "/v1/chat/completions", + false, + false, + false + ) + ); + assert!( + !should_normalize_codex_responses_passthrough_control_messages( + &AppType::Codex, + ®ular, + "/v1/responses", + true, + false, + false + ) + ); + assert!( + !should_normalize_codex_responses_passthrough_control_messages( + &AppType::Codex, + ®ular, + "/v1/responses", + false, + true, + false + ) + ); + } + + #[test] + fn local_proxy_body_overrides_deep_merge_final_body_without_stream() { + let mut body = json!({ + "model": "before", + "stream": false, + "metadata": { + "keep": true, + "temperature": 1 + }, + "messages": [{ "role": "user", "content": "hello" }] + }); + let overrides = LocalProxyRequestOverrides { + headers: HashMap::new(), + body: Some(json!({ + "model": "after", + "stream": true, + "metadata": { + "temperature": 0.2, + "top_p": 0.9 + }, + "messages": [] + })), + }; + + assert!(apply_local_proxy_body_overrides(&mut body, &overrides)); + + assert_eq!(body["model"], "after"); + assert_eq!(body["stream"], false); + assert_eq!(body["metadata"]["keep"], true); + assert_eq!(body["metadata"]["temperature"], 0.2); + assert_eq!(body["metadata"]["top_p"], 0.9); + assert_eq!(body["messages"], json!([])); + } + + #[test] + fn local_proxy_header_overrides_replace_allowed_headers_only() { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::USER_AGENT, + http::HeaderValue::from_static("original"), + ); + headers.insert( + http::header::AUTHORIZATION, + http::HeaderValue::from_static("Bearer good"), + ); + headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + + let overrides = LocalProxyRequestOverrides { + headers: HashMap::from([ + ("User-Agent".to_string(), "custom".to_string()), + ("X-Test".to_string(), "ok".to_string()), + ("Authorization".to_string(), "Bearer bad".to_string()), + ("Content-Type".to_string(), "text/plain".to_string()), + ("X-Bad".to_string(), "bad\nvalue".to_string()), + ]), + body: None, + }; + + apply_local_proxy_header_overrides(&mut headers, Some(&overrides), false); + + assert_eq!( + headers + .get(http::header::USER_AGENT) + .and_then(|value| value.to_str().ok()), + Some("custom") + ); + assert_eq!( + headers + .get(http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer good") + ); + assert_eq!( + headers + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/json") + ); + assert_eq!( + headers.get("x-test").and_then(|value| value.to_str().ok()), + Some("ok") + ); + assert!(headers.get("x-bad").is_none()); + } + + #[test] + fn local_proxy_header_overrides_are_skipped_for_copilot() { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::USER_AGENT, + http::HeaderValue::from_static("copilot"), + ); + let overrides = LocalProxyRequestOverrides { + headers: HashMap::from([("User-Agent".to_string(), "custom".to_string())]), + body: None, + }; + + apply_local_proxy_header_overrides(&mut headers, Some(&overrides), true); + + assert_eq!( + headers + .get(http::header::USER_AGENT) + .and_then(|value| value.to_str().ok()), + Some("copilot") + ); + } + + #[tokio::test] + async fn non_streaming_success_is_buffered_before_marking_provider_successful() { + let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1)); + let response = ProxyResponse::streamed( + StatusCode::OK, + HeaderMap::new(), + futures::stream::once(async { + tokio::time::sleep(Duration::from_millis(10)).await; + Ok::(Bytes::from_static(b"{\"ok\":true}")) + }), + ); + + let prepared = forwarder + .prepare_success_response_for_failover(response, false) + .await + .expect("response should be buffered"); + + assert_eq!( + prepared.bytes().await.unwrap(), + Bytes::from_static(b"{\"ok\":true}") + ); + } + + #[tokio::test] + async fn non_streaming_body_read_error_is_retryable_before_success_record() { + let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1)); + let response = ProxyResponse::streamed( + StatusCode::OK, + HeaderMap::new(), + futures::stream::once(async { + Err::(std::io::Error::other("body boom")) + }), + ); + + let err = match forwarder + .prepare_success_response_for_failover(response, false) + .await + { + Ok(_) => panic!("body read errors should fail the attempt"), + Err(err) => err, + }; + + assert!(matches!(err, ProxyError::ForwardFailed(_))); + } + + #[tokio::test] + async fn streaming_success_primes_first_chunk_and_replays_it() { + let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1)); + let response = ProxyResponse::streamed( + StatusCode::OK, + HeaderMap::new(), + futures::stream::iter(vec![ + Ok::(Bytes::from_static(b"first")), + Ok::(Bytes::from_static(b"second")), + ]), + ); + + let prepared = forwarder + .prepare_success_response_for_failover(response, true) + .await + .expect("stream should be primed"); + + assert_eq!( + prepared.bytes().await.unwrap(), + Bytes::from_static(b"firstsecond") + ); + } + + #[tokio::test] + async fn streaming_first_chunk_error_is_retryable_before_success_record() { + let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1)); + let response = ProxyResponse::streamed( + StatusCode::OK, + HeaderMap::new(), + futures::stream::once(async { + Err::(std::io::Error::other("first chunk boom")) + }), + ); + + let err = match forwarder + .prepare_success_response_for_failover(response, true) + .await + { + Ok(_) => panic!("first chunk errors should fail the attempt"), + Err(err) => err, + }; + + assert!(matches!(err, ProxyError::ForwardFailed(_))); + } + + #[tokio::test] + async fn streaming_first_sse_error_event_is_retryable_before_response_is_returned() { + let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1)); + let response = ProxyResponse::streamed( + StatusCode::OK, + HeaderMap::new(), + futures::stream::once(async { + Ok::(Bytes::from_static( + b"event: error\ndata: {\"error\":{\"message\":\"We're currently experiencing high demand\",\"type\":\"server_error\"}}\n\n", + )) + }), + ); + + let err = match forwarder + .prepare_success_response_for_failover(response, true) + .await + { + Ok(_) => panic!("first SSE error event should fail the attempt before streaming"), + Err(err) => err, + }; + + assert!(matches!( + err, + ProxyError::UpstreamError { + status: 503, + body: Some(message), + } if message.contains("high demand") + )); + } + + #[tokio::test] + async fn streaming_first_normal_sse_event_is_replayed_to_client() { + let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1)); + let response = ProxyResponse::streamed( + StatusCode::OK, + HeaderMap::new(), + futures::stream::iter(vec![ + Ok::(Bytes::from_static( + b"event: response.created\ndata: {\"type\":\"response.created\"}\n\n", + )), + Ok::(Bytes::from_static( + b"event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n", + )), + ]), + ); + + let prepared = forwarder + .prepare_success_response_for_failover(response, true) + .await + .expect("normal first SSE event should be replayed"); + + let body = prepared.bytes().await.unwrap(); + assert!(String::from_utf8_lossy(&body).contains("response.created")); + assert!(String::from_utf8_lossy(&body).contains("response.completed")); + } + + #[test] + fn codex_oauth_session_headers_match_codex_cache_identity() { + let headers = build_codex_oauth_session_headers("session-123"); + let mut map = HeaderMap::new(); + for (name, value) in headers { + map.insert(name, value); + } + + assert_eq!( + map.get("session-id"), + Some(&HeaderValue::from_static("session-123")) + ); + assert_eq!( + map.get("thread-id"), + Some(&HeaderValue::from_static("session-123")) + ); + assert_eq!( + map.get("x-client-request-id"), + Some(&HeaderValue::from_static("session-123")) + ); + assert_eq!( + map.get("x-codex-window-id"), + Some(&HeaderValue::from_static("session-123:0")) + ); + } + + #[test] + fn managed_account_upstream_rejects_proxy_managed_placeholder_header() { + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + HeaderValue::from_static("Bearer PROXY_MANAGED"), + ); + + let err = reject_proxy_placeholder_for_managed_account_upstream( + "https://api.githubcopilot.com/chat/completions", + &headers, + ) + .expect_err("placeholder should be rejected before upstream"); + + assert!(matches!( + err, + ProxyError::AuthError(message) if message.contains("PROXY_MANAGED") + )); + } + + #[test] + fn codex_oauth_upstream_rejects_proxy_managed_placeholder_header() { + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + HeaderValue::from_static("Bearer PROXY_MANAGED"), + ); + + let err = reject_proxy_placeholder_for_managed_account_upstream( + "https://chatgpt.com/backend-api/codex/responses", + &headers, + ) + .expect_err("placeholder should be rejected before upstream"); + + assert!(matches!( + err, + ProxyError::AuthError(message) if message.contains("PROXY_MANAGED") + )); + } + + #[test] + fn non_managed_upstream_allows_proxy_managed_placeholder_guard() { + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + HeaderValue::from_static("Bearer PROXY_MANAGED"), + ); + + reject_proxy_placeholder_for_managed_account_upstream( + "https://api.example.com/v1/messages", + &headers, + ) + .expect("guard is scoped to managed-account upstreams"); + } + + #[test] + fn exact_header_case_preserved_for_native_claude_only() { + let provider = test_provider_with_type(None); + + assert!(should_preserve_exact_header_case( + "Claude", + &provider, + Some("anthropic"), + false + )); + assert!(!should_preserve_exact_header_case( + "Claude", + &provider, + Some("openai_responses"), + false + )); + assert!(!should_preserve_exact_header_case( + "Codex", &provider, None, false + )); + assert!(!should_preserve_exact_header_case( + "Gemini", &provider, None, false + )); + } + + #[test] + fn exact_header_case_skipped_for_codex_oauth_and_copilot() { + let codex_oauth = test_provider_with_type(Some("codex_oauth")); + let copilot = test_provider_with_type(Some("github_copilot")); + + assert!(!should_preserve_exact_header_case( + "Claude", + &codex_oauth, + Some("openai_responses"), + false + )); + assert!(!should_preserve_exact_header_case( + "Claude", + &copilot, + Some("openai_chat"), + true + )); + } + + #[test] + fn rewrite_claude_transform_endpoint_strips_beta_for_chat_completions() { + let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( + "/v1/messages?beta=true&foo=bar", + "openai_chat", + false, + &json!({ "model": "gpt-5.4" }), + ); + + assert_eq!(endpoint, "/v1/chat/completions?foo=bar"); + assert_eq!(passthrough_query.as_deref(), Some("foo=bar")); + } + + #[test] + fn rewrite_claude_transform_endpoint_strips_beta_for_responses() { + let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( + "/claude/v1/messages?beta=true&x-id=1", + "openai_responses", + false, + &json!({ "model": "gpt-5.4" }), + ); + + assert_eq!(endpoint, "/v1/responses?x-id=1"); + assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); + } + + #[test] + fn rewrite_codex_responses_endpoint_to_chat_preserves_query() { + let (endpoint, passthrough_query) = + rewrite_codex_responses_endpoint_to_chat("/v1/responses?foo=bar"); + + assert_eq!(endpoint, "/chat/completions?foo=bar"); + assert_eq!(passthrough_query.as_deref(), Some("foo=bar")); + } + + #[test] + fn rewrite_codex_responses_compact_endpoint_to_chat_preserves_query() { + let (endpoint, passthrough_query) = + rewrite_codex_responses_endpoint_to_chat("/v1/responses/compact?foo=bar"); + + assert_eq!(endpoint, "/chat/completions?foo=bar"); + assert_eq!(passthrough_query.as_deref(), Some("foo=bar")); + } + + #[test] + fn rewrite_claude_transform_endpoint_uses_copilot_path() { + let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( + "/v1/messages?beta=true&x-id=1", + "anthropic", + true, + &json!({ "model": "claude-sonnet-4-6" }), + ); + + assert_eq!(endpoint, "/chat/completions?x-id=1"); + assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); + } + + #[test] + fn rewrite_claude_transform_endpoint_uses_copilot_responses_path() { + let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( + "/v1/messages?beta=true&x-id=1", + "openai_responses", + true, + &json!({ "model": "gpt-5.4" }), + ); + + assert_eq!(endpoint, "/v1/responses?x-id=1"); + assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); + } + + #[test] + fn rewrite_claude_transform_endpoint_maps_gemini_generate_content() { + let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( + "/v1/messages?beta=true&x-id=1", + "gemini_native", + false, + &json!({ "model": "gemini-2.5-pro" }), + ); + + assert_eq!( + endpoint, + "/v1beta/models/gemini-2.5-pro:generateContent?x-id=1" + ); + assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); + } + + /// Regression: body.model arriving as the resource-name form + /// `models/gemini-2.5-pro` must not produce a doubled + /// `/v1beta/models/models/...` path. + #[test] + fn rewrite_claude_transform_endpoint_strips_gemini_model_resource_prefix() { + let (endpoint, _) = rewrite_claude_transform_endpoint( + "/v1/messages", + "gemini_native", + false, + &json!({ "model": "models/gemini-2.5-pro" }), + ); + + assert_eq!(endpoint, "/v1beta/models/gemini-2.5-pro:generateContent"); + } + + #[test] + fn rewrite_claude_transform_endpoint_maps_gemini_streaming() { + let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( + "/v1/messages?beta=true", + "gemini_native", + false, + &json!({ "model": "gemini-2.5-flash", "stream": true }), + ); + + assert_eq!( + endpoint, + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + assert_eq!(passthrough_query.as_deref(), Some("alt=sse")); + } + + #[test] + fn append_query_to_full_url_preserves_existing_query_string() { + let url = append_query_to_full_url("https://relay.example/api?foo=bar", Some("x-id=1")); + + assert_eq!(url, "https://relay.example/api?foo=bar&x-id=1"); + } + + #[test] + fn build_gemini_native_url_uses_origin_when_base_ends_with_v1beta() { + let url = crate::proxy::gemini_url::build_gemini_native_url( + "https://generativelanguage.googleapis.com/v1beta", + "/v1beta/models/gemini-2.5-pro:generateContent", + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent" + ); + } + + #[test] + fn build_gemini_native_url_uses_origin_when_base_already_contains_models_prefix() { + let url = crate::proxy::gemini_url::build_gemini_native_url( + "https://generativelanguage.googleapis.com/v1beta/models", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + #[test] + fn resolve_gemini_native_url_keeps_opaque_full_url_as_is() { + let url = crate::proxy::gemini_url::resolve_gemini_native_url( + "https://relay.example/custom/generate-content", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse"); + } + + #[test] + fn force_identity_for_stream_flag_requests() { + let headers = HeaderMap::new(); + + assert!(should_force_identity_encoding( + "/v1/responses", + &json!({ "stream": true }), + &headers + )); + } + + #[test] + fn force_identity_for_gemini_stream_endpoints() { + let headers = HeaderMap::new(); + + assert!(should_force_identity_encoding( + "/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse", + &json!({ "model": "gemini-2.5-pro" }), + &headers + )); + } + + #[test] + fn streaming_request_detects_gemini_sse_without_body_stream_flag() { + let headers = HeaderMap::new(); + + assert!(is_streaming_request( + "/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse", + &json!({ "model": "gemini-2.5-pro" }), + &headers + )); + } + + #[test] + fn force_identity_for_sse_accept_header() { + let mut headers = HeaderMap::new(); + headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream")); + + assert!(should_force_identity_encoding( + "/v1/responses", + &json!({ "model": "gpt-5" }), + &headers + )); + } + + #[test] + fn non_streaming_requests_allow_automatic_compression() { + let headers = HeaderMap::new(); + + assert!(!should_force_identity_encoding( + "/v1/responses", + &json!({ "model": "gpt-5" }), + &headers + )); + } + + // ==================== Copilot 动态 endpoint 路由相关测试 ==================== + + /// 验证 is_copilot 检测逻辑:通过 provider_type 判断 + #[test] + fn copilot_detection_via_provider_type() { + use crate::provider::{Provider, ProviderMeta}; + + let provider = Provider { + id: "test".to_string(), + name: "Test Copilot".to_string(), + settings_config: serde_json::json!({}), + website_url: None, + category: None, + created_at: None, + sort_index: None, + notes: None, + meta: Some(ProviderMeta { + provider_type: Some("github_copilot".to_string()), + ..Default::default() + }), + icon: None, + icon_color: None, + in_failover_queue: false, + }; + + let is_copilot = provider + .meta + .as_ref() + .and_then(|m| m.provider_type.as_deref()) + == Some("github_copilot"); + + assert!(is_copilot, "应该通过 provider_type 检测为 Copilot"); + } + + /// 验证 is_copilot 检测逻辑:通过 base_url 判断 + #[test] + fn copilot_detection_via_base_url() { + let base_url = "https://api.githubcopilot.com"; + let is_copilot = base_url.contains("githubcopilot.com"); + assert!(is_copilot, "应该通过 base_url 检测为 Copilot"); + + let non_copilot_url = "https://api.anthropic.com"; + let is_not_copilot = non_copilot_url.contains("githubcopilot.com"); + assert!(!is_not_copilot, "非 Copilot URL 不应被检测为 Copilot"); + } + + /// 验证企业版 endpoint(不包含 githubcopilot.com)场景下 is_copilot 仍然正确 + #[test] + fn copilot_detection_for_enterprise_endpoint() { + use crate::provider::{Provider, ProviderMeta}; + + // 企业版场景:provider_type 是 github_copilot,但 base_url 可能是企业内部域名 + let provider = Provider { + id: "enterprise".to_string(), + name: "Enterprise Copilot".to_string(), + settings_config: serde_json::json!({}), + website_url: None, + category: None, + created_at: None, + sort_index: None, + notes: None, + meta: Some(ProviderMeta { + provider_type: Some("github_copilot".to_string()), + ..Default::default() + }), + icon: None, + icon_color: None, + in_failover_queue: false, + }; + + let enterprise_base_url = "https://copilot-api.corp.example.com"; + + // is_copilot 应该通过 provider_type 检测成功,即使 base_url 不包含 githubcopilot.com + let is_copilot = provider + .meta + .as_ref() + .and_then(|m| m.provider_type.as_deref()) + == Some("github_copilot") + || enterprise_base_url.contains("githubcopilot.com"); + + assert!( + is_copilot, + "企业版 Copilot 应该通过 provider_type 被正确检测" + ); + } + + /// 验证动态 endpoint 替换条件 + #[test] + fn dynamic_endpoint_replacement_conditions() { + // 条件:is_copilot && !is_full_url + let test_cases = [ + (true, false, true, "Copilot + 非 full_url 应该替换"), + (true, true, false, "Copilot + full_url 不应替换"), + (false, false, false, "非 Copilot 不应替换"), + (false, true, false, "非 Copilot + full_url 不应替换"), + ]; + + for (is_copilot, is_full_url, should_replace, desc) in test_cases { + let will_replace = is_copilot && !is_full_url; + assert_eq!(will_replace, should_replace, "{desc}"); + } + } + + // ===== P3: forwarder 层 media 开关回归测试 ===== + // 验证 gate 在 forwarder 这一层的"接线",而非 media_sanitizer 纯函数本身。 + + fn forwarder_with_rectifier(config: RectifierConfig) -> RequestForwarder { + let mut fwd = test_forwarder(Duration::from_secs(1), Duration::from_secs(1)); + fwd.rectifier_config = config; + fwd + } + + fn provider_with_settings(settings_config: Value) -> Provider { + let mut p = test_provider_with_type(Some("anthropic")); + p.settings_config = settings_config; + p + } + + fn body_with_image(model: &str) -> Value { + json!({ + "model": model, + "messages": [{ + "role": "user", + "content": [ + { "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } } + ] + }] + }) + } + + fn body_with_codex_input_image(model: &str) -> Value { + json!({ + "model": model, + "input": [{ + "role": "user", + "content": [ + { "type": "input_image", "image_url": "data:image/png;base64,abc" } + ] + }] + }) + } + + fn image_unsupported_error() -> ProxyError { + ProxyError::UpstreamError { + status: 400, + body: Some( + r#"{"error":{"message":"This model does not support image input"}}"#.to_string(), + ), + } + } + + fn minimax_sensitive_image_error() -> ProxyError { + ProxyError::UpstreamError { + status: 400, + body: Some( + r#"{"base_resp":{"status_code":1026,"status_msg":"input new_sensitive, messages[61]'s content[0] image is sensitive, please check your input"}}"# + .to_string(), + ), + } + } + #[test] + fn prevention_replaces_when_all_switches_on_and_model_in_heuristic_list() { + let fwd = forwarder_with_rectifier(RectifierConfig::default()); + let provider = provider_with_settings(json!({})); + let mut body = body_with_image("deepseek-v4-pro"); + + let replaced = fwd.apply_media_prevention(&mut body, &provider); + + assert_eq!(replaced, 1, "默认全开 + 名单内模型应预替换"); + assert_eq!(body["messages"][0]["content"][0]["type"], "text"); + } + + #[test] + fn prevention_skipped_when_media_fallback_off() { + // 关闭 request_media_fallback:即使名单命中也不预替换。 + let fwd = forwarder_with_rectifier(RectifierConfig { + request_media_fallback: false, + ..RectifierConfig::default() + }); + let provider = provider_with_settings(json!({})); + let mut body = body_with_image("deepseek-v4-pro"); + + let replaced = fwd.apply_media_prevention(&mut body, &provider); + + assert_eq!(replaced, 0); + assert_eq!(body["messages"][0]["content"][0]["type"], "image"); + } + + #[test] + fn prevention_skipped_when_master_switch_off() { + let fwd = forwarder_with_rectifier(RectifierConfig { + enabled: false, + ..RectifierConfig::default() + }); + let provider = provider_with_settings(json!({})); + let mut body = body_with_image("deepseek-v4-pro"); + + assert_eq!(fwd.apply_media_prevention(&mut body, &provider), 0); + assert_eq!(body["messages"][0]["content"][0]["type"], "image"); + } + + #[test] + fn prevention_heuristic_off_skips_list_but_keeps_explicit_text_only() { + // 关闭 request_media_heuristic:名单预测失效,但显式声明 text-only 仍预替换。 + let fwd = forwarder_with_rectifier(RectifierConfig { + request_media_heuristic: false, + ..RectifierConfig::default() + }); + + // (a) 名单内模型、无显式声明 → 不再预替换 + let bare_provider = provider_with_settings(json!({})); + let mut list_body = body_with_image("deepseek-v4-pro"); + assert_eq!( + fwd.apply_media_prevention(&mut list_body, &bare_provider), + 0, + "heuristic 关闭后名单模型不应被预替换" + ); + assert_eq!(list_body["messages"][0]["content"][0]["type"], "image"); + + // (b) 显式声明 text-only → 仍预替换(声明驱动,不受 heuristic 开关影响) + let declared_provider = provider_with_settings(json!({ + "models": [ { "id": "some-text-model", "input": ["text"] } ] + })); + let mut declared_body = body_with_image("some-text-model"); + assert_eq!( + fwd.apply_media_prevention(&mut declared_body, &declared_provider), + 1, + "显式 text-only 即使关闭 heuristic 也应预替换" + ); + assert_eq!(declared_body["messages"][0]["content"][0]["type"], "text"); + } + + #[test] + fn reactive_triggers_when_all_switches_on() { + let fwd = forwarder_with_rectifier(RectifierConfig::default()); + let body = body_with_image("any-model"); + assert!(fwd.media_retry_should_trigger("Claude", false, &body, &image_unsupported_error())); + } + + #[test] + fn reactive_triggers_for_codex_image_url_deserialize_errors() { + let fwd = forwarder_with_rectifier(RectifierConfig::default()); + let body = body_with_codex_input_image("deepseek-v4-flash"); + let error = ProxyError::UpstreamError { + status: 400, + body: Some( + r#"{"error":{"message":"Failed to deserialize the JSON body into the target type: messages[11]: unknown variant image_url, expected text"}}"# + .to_string(), + ), + }; + + assert!(fwd.media_retry_should_trigger("Codex", false, &body, &error)); + } + + #[test] + fn reactive_triggers_for_codex_sensitive_image_errors() { + let fwd = forwarder_with_rectifier(RectifierConfig::default()); + let body = body_with_codex_input_image("MiniMax-M3"); + + assert!(fwd.media_retry_should_trigger( + "Codex", + false, + &body, + &minimax_sensitive_image_error() + )); + } + + #[test] + fn reactive_sensitive_image_error_still_requires_image_body() { + let fwd = forwarder_with_rectifier(RectifierConfig::default()); + let body = json!({ + "model": "MiniMax-M3", + "input": [{ + "role": "user", + "content": [{ "type": "input_text", "text": "hello" }] + }] + }); + + assert!(!fwd.media_retry_should_trigger( + "Codex", + false, + &body, + &minimax_sensitive_image_error() + )); + } + + #[test] + fn reactive_skipped_when_media_fallback_off() { + // 关闭 request_media_fallback:上游报图片错误也不触发兜底重试。 + let fwd = forwarder_with_rectifier(RectifierConfig { + request_media_fallback: false, + ..RectifierConfig::default() + }); + let body = body_with_image("any-model"); + assert!(!fwd.media_retry_should_trigger( + "Claude", + false, + &body, + &image_unsupported_error() + )); + } + + #[test] + fn reactive_skipped_when_master_switch_off() { + let fwd = forwarder_with_rectifier(RectifierConfig { + enabled: false, + ..RectifierConfig::default() + }); + let body = body_with_image("any-model"); + assert!(!fwd.media_retry_should_trigger( + "Claude", + false, + &body, + &image_unsupported_error() + )); + } + + #[test] + fn reactive_unaffected_by_heuristic_switch() { + // 关闭 request_media_heuristic 不影响反应式兜底——它是上游实测错误后的恢复,不是预测。 + let fwd = forwarder_with_rectifier(RectifierConfig { + request_media_heuristic: false, + ..RectifierConfig::default() + }); + let body = body_with_image("any-model"); + assert!(fwd.media_retry_should_trigger("Claude", false, &body, &image_unsupported_error())); + } +} 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 2450457b0b..e908bcb580 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, )?; } @@ -384,6 +386,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 私有字段; @@ -694,6 +702,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(); @@ -716,6 +725,7 @@ fn append_responses_input_as_chat_messages( &mut pending_reasoning, &mut last_assistant_index, tool_context, + preserve_reasoning_content, text_only_model, )?; } @@ -728,6 +738,7 @@ fn append_responses_input_as_chat_messages( &mut pending_reasoning, &mut last_assistant_index, tool_context, + preserve_reasoning_content, text_only_model, )?; } @@ -739,8 +750,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(()) } @@ -751,6 +765,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()); @@ -776,6 +791,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") { @@ -795,6 +811,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); @@ -806,7 +823,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); @@ -818,6 +836,7 @@ fn append_responses_item_as_chat_message( pending_tool_calls, pending_reasoning, last_assistant_index, + preserve_reasoning_content, ); let role = item .get("role") @@ -834,7 +853,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(()); @@ -850,11 +873,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); @@ -867,11 +892,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); @@ -888,6 +915,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; @@ -898,7 +926,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); } @@ -906,6 +938,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"); @@ -922,7 +955,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(); } @@ -1003,7 +1040,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; }; @@ -2953,6 +2996,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!({ @@ -2979,7 +3058,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"); @@ -3016,7 +3096,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"); @@ -3043,7 +3124,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"); @@ -3077,7 +3159,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"); @@ -3106,7 +3189,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"); @@ -3136,7 +3220,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 +3253,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/services/proxy.rs b/src-tauri/src/services/proxy.rs index 1789ff6408..841c72752f 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::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider)); + Self::attach_codex_model_catalog_from_provider( + &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 @@ -2709,6 +2738,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); + } } } @@ -3025,7 +3057,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 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 3d634c42b9..8990f81056 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.ts b/src/types.ts index ba5c0895b2..4946cb9966 100644 --- a/src/types.ts +++ b/src/types.ts @@ -217,6 +217,8 @@ export interface ProviderMeta { | "openai_responses" | "openai_messages" | "gemini_native"; + // Codex Claude provider: treat claude-sonnet-5 as normal chat instead of coding agent + claudeChatMode?: boolean; // Codex MultiRouter 向导里的协议选择来源;manual 表示用户已显式锁定,不再被探测推荐覆盖。 apiFormatSource?: "manual" | "probe" | "inferred" | string; // 通用认证绑定 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 { From 7acc6cf617c0be88b2182e469ac000860a7c6d33 Mon Sep 17 00:00:00 2001 From: z13321812367-sys <1478191279@qq.com> Date: Fri, 10 Jul 2026 07:15:53 +0800 Subject: [PATCH 02/13] fix: harden Codex router catalog and cleanup --- src-tauri/src/codex_config.rs | 164 ++++++++++++++++++++++++++++++++ src-tauri/src/services/proxy.rs | 7 +- 2 files changed, 166 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index b8ec7cad8e..a45ee92eed 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -769,6 +769,13 @@ 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)); + // Newer Codex Desktop builds distinguish Chat-visible models from + // code-mode-only models via `tool_mode`. A JSON null matches official + // chat-capable entries and avoids marking routed models as code-only. + 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); @@ -1281,6 +1288,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 +1350,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)) @@ -2917,6 +2938,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::*; @@ -3704,6 +3775,74 @@ 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 merge_openai_router_config_uses_builtin_openai_history_bucket() { let live_config = r#"model = "gpt-5.5" @@ -4272,6 +4411,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") @@ -5466,6 +5616,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" diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 841c72752f..603ecbd05f 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -2149,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); } @@ -2163,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) } From a737f87ffa5729bddf233811288360f5577cb695 Mon Sep 17 00:00:00 2001 From: z13321812367-sys <1478191279@qq.com> Date: Fri, 10 Jul 2026 08:36:33 +0800 Subject: [PATCH 03/13] fix: scope Codex Chat and Ask profiles to routed providers --- src-tauri/src/proxy/forwarder.rs | 444 +++++++++++++++++++++++++++---- 1 file changed, 391 insertions(+), 53 deletions(-) diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 87f6c8c9a7..5a84211863 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -52,13 +52,28 @@ const CLAUDE_ASK_ALLOWED_TOOLS: &[&str] = &[ ]; const CLAUDE_ASK_PROJECT_MCP_SERVER: &str = "ccswitch_readonly"; -fn apply_claude_chat_profile(body: &mut Value) -> bool { - let is_chat_model = body - .get("model") - .and_then(Value::as_str) - .is_some_and(|model| model == CLAUDE_CHAT_MODEL); +fn provider_supports_chat_ask_profiles(provider: &Provider) -> bool { + if provider.uses_managed_account_auth() { + return false; + } - if !is_chat_model { + 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; } @@ -145,13 +160,11 @@ fn is_agents_instructions_fragment(trimmed: &str) -> bool { && trimmed.ends_with("
") } -fn apply_claude_ask_profile(body: &mut Value) -> bool { - let is_ask_model = body - .get("model") - .and_then(Value::as_str) - .is_some_and(|model| model == CLAUDE_CHAT_MODEL); - - if !is_ask_model { +fn apply_claude_ask_profile_for_provider( + body: &mut Value, + provider_supports_profiles: bool, +) -> bool { + if !provider_supports_profiles { return false; } @@ -162,7 +175,7 @@ fn apply_claude_ask_profile(body: &mut Value) -> bool { obj.remove("instructions"); let filter_result = filter_claude_ask_tools(obj); log::info!( - "[ClaudeAsk] filtered_tools kept={} original={}", + "[CodexAsk] filtered_tools kept={} original={}", filter_result.kept_count, filter_result.original_count ); @@ -201,10 +214,7 @@ fn filter_claude_ask_tools(obj: &mut serde_json::Map) -> AskToolF .iter() .filter_map(response_tool_name_for_ask_log) .collect::>(); - log::info!( - "[ClaudeAsk] available_tools=[{}]", - available_tools.join(",") - ); + log::info!("[CodexAsk] available_tools=[{}]", available_tools.join(",")); let allowed_tools = tools .iter() @@ -425,18 +435,32 @@ fn should_keep_ask_conversation_item(item: &Value) -> bool { } fn sanitize_ask_environment_context_item(item: &mut Value) { - let Some(content) = item.get_mut("content").and_then(Value::as_array_mut) else { - return; - }; + 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); + } + } - for content_item in content { - let Some(text) = content_item.get("text").and_then(Value::as_str) else { - continue; - }; - let Some(sanitized) = sanitize_environment_context_fragment(text) else { - continue; - }; - *content_item.get_mut("text").expect("text exists") = Value::String(sanitized); + 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); + } + } + Value::Array(content_items) => { + for content_item in content_items { + let Some(text) = content_item.get("text").and_then(Value::as_str) else { + continue; + }; + let Some(sanitized) = sanitize_environment_context_fragment(text) else { + continue; + }; + *content_item.get_mut("text").expect("text exists") = Value::String(sanitized); + } + } + _ => {} + } } } @@ -1784,7 +1808,10 @@ impl RequestForwarder { let mut effective_body = body.clone(); let claude_chat_profile_applied = if matches!(*self.interaction_mode.read().await, InteractionMode::Chat) { - apply_claude_chat_profile(&mut effective_body) + apply_claude_chat_profile_for_provider( + &mut effective_body, + provider_supports_chat_ask_profiles(provider), + ) } else { false }; @@ -1794,7 +1821,7 @@ impl RequestForwarder { .and_then(Value::as_str) .unwrap_or(CLAUDE_CHAT_MODEL); log::info!( - "[ClaudeChat] applied chat profile to model={} effective_provider={}", + "[CodexChat] applied chat profile to model={} effective_provider={}", model, provider.id ); @@ -2120,9 +2147,32 @@ 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(&mut mapped_body) + apply_claude_ask_profile_for_provider( + &mut mapped_body, + provider_supports_chat_ask_profiles(provider), + ) } else { false }; @@ -2132,7 +2182,7 @@ impl RequestForwarder { .and_then(Value::as_str) .unwrap_or(CLAUDE_CHAT_MODEL); log::info!( - "[ClaudeAsk] applied ask profile to model={} effective_provider={}", + "[CodexAsk] applied ask profile to model={} effective_provider={}", model, provider.id ); @@ -4585,7 +4635,7 @@ mod tests { ] }); - assert!(apply_claude_chat_profile(&mut body)); + 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()); @@ -4619,7 +4669,7 @@ mod tests { }] }); - assert!(apply_claude_chat_profile(&mut body)); + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); let serialized = body["input"].to_string(); assert!(serialized.contains("真实用户问题")); assert!(!serialized.contains("environment_context")); @@ -4640,7 +4690,7 @@ mod tests { }] }); - assert!(apply_claude_chat_profile(&mut body)); + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); assert_eq!(body["input"].as_array().unwrap().len(), 0); } @@ -4664,7 +4714,7 @@ mod tests { }] }); - assert!(apply_claude_chat_profile(&mut body)); + 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")); @@ -4685,7 +4735,7 @@ mod tests { }] }); - assert!(apply_claude_chat_profile(&mut body)); + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); assert_eq!(body["input"].as_array().unwrap().len(), 0); } @@ -4703,7 +4753,7 @@ mod tests { }] }); - assert!(apply_claude_chat_profile(&mut body)); + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); assert_eq!(body["input"].as_array().unwrap().len(), 0); } @@ -4722,7 +4772,7 @@ mod tests { }] }); - assert!(apply_claude_chat_profile(&mut body)); + assert!(apply_claude_chat_profile_for_provider(&mut body, true)); assert_eq!(body["input"][0]["content"][0]["text"], text); } @@ -4741,10 +4791,135 @@ mod tests { }] }); - assert!(apply_claude_chat_profile(&mut body)); + 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!({ @@ -4808,7 +4983,7 @@ mod tests { ] }); - assert!(apply_claude_ask_profile(&mut body)); + 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); @@ -4843,7 +5018,7 @@ mod tests { }] }); - assert!(apply_claude_ask_profile(&mut body)); + 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#"E:\文档备份\New project 333\downloads\ccswitchmulti-src"#) @@ -4873,7 +5048,7 @@ mod tests { }] }); - assert!(apply_claude_ask_profile(&mut body)); + 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"#)); @@ -4885,6 +5060,43 @@ mod tests { 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_leaves_agents_fragment_unchanged() { let agents = "# AGENTS.md instructions\n\n\n- Read files first.\n- Do not weaken safety gates.\n"; @@ -4900,7 +5112,7 @@ mod tests { }] }); - assert!(apply_claude_ask_profile(&mut body)); + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); assert_eq!(body["input"][0]["content"][0]["text"], agents); } @@ -4919,7 +5131,7 @@ mod tests { }] }); - assert!(apply_claude_ask_profile(&mut body)); + assert!(apply_claude_ask_profile_for_provider(&mut body, true)); assert_eq!(body["input"][0]["content"][0]["text"], text); } @@ -4954,7 +5166,7 @@ mod tests { ] }); - assert!(apply_claude_ask_profile(&mut body)); + 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")); @@ -4992,7 +5204,7 @@ mod tests { "input": "Inspect only." }); - assert!(apply_claude_ask_profile(&mut body)); + 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"); @@ -5051,7 +5263,7 @@ mod tests { ] }); - assert!(apply_claude_ask_profile(&mut body)); + 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")); @@ -5095,7 +5307,7 @@ mod tests { ] }); - assert!(apply_claude_ask_profile(&mut body)); + 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")); @@ -5119,7 +5331,7 @@ mod tests { "input": "Inspect only." }); - assert!(apply_claude_ask_profile(&mut body)); + 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); @@ -5141,7 +5353,7 @@ mod tests { "input": "Inspect only." }); - assert!(apply_claude_ask_profile(&mut body)); + 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"); @@ -5160,11 +5372,137 @@ mod tests { }] }); - assert!(apply_claude_ask_profile(&mut body)); + 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!({ From 48402d9ced3533a2f1d51c24613e3a2f2e4a1c79 Mon Sep 17 00:00:00 2001 From: z13321812367-sys <1478191279@qq.com> Date: Fri, 10 Jul 2026 09:01:55 +0800 Subject: [PATCH 04/13] chore: remove local backup artifacts --- src-tauri/Cargo.toml.before-default-run | 126 - .../forwarder.rs.before-claude-chat-profile | 5248 ----------------- 2 files changed, 5374 deletions(-) delete mode 100644 src-tauri/Cargo.toml.before-default-run delete mode 100644 src-tauri/src/proxy/forwarder.rs.before-claude-chat-profile diff --git a/src-tauri/Cargo.toml.before-default-run b/src-tauri/Cargo.toml.before-default-run deleted file mode 100644 index 1a300730bf..0000000000 --- a/src-tauri/Cargo.toml.before-default-run +++ /dev/null @@ -1,126 +0,0 @@ -[package] -name = "cc-switch" -version = "3.16.4-13" -description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" -authors = ["Jason Young"] -license = "MIT" -repository = "https://github.com/BigStrongSun/ccswitchmulti" -edition = "2021" -rust-version = "1.85.0" -autobins = false - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[lib] -name = "cc_switch_lib" -crate-type = ["staticlib", "cdylib", "rlib"] -doctest = false - -[[bin]] -name = "cc-switch" -path = "src/main.rs" - -[[bin]] -name = "codex-history-repairer" -path = "src/bin/codex-history-repairer.rs" -required-features = ["history-repairer"] - -[features] -default = [] -history-repairer = [] -test-hooks = [] - -[build-dependencies] -tauri-build = { version = "2.4.0", features = [] } - -[dependencies] -serde_json = { version = "1.0", features = ["preserve_order"] } -serde = { version = "1.0", features = ["derive"] } -log = "0.4" -chrono = { version = "0.4", features = ["serde"] } -tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset", "image-png"] } -tauri-plugin-log = "2" -tauri-plugin-opener = "2" -tauri-plugin-process = "2" -tauri-plugin-updater = "2" -tauri-plugin-dialog = "2" -tauri-plugin-store = "2" -tauri-plugin-deep-link = "2" -tauri-plugin-window-state = "2" -dirs = "5.0" -toml = "0.8" -toml_edit = "0.22" -reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] } -arboard = "3.6" -flate2 = "1" -filetime = "0.2" -brotli = "7" -zstd = "0.13" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] } -tokio-tungstenite = "0.24" -futures = "0.3" -async-stream = "0.3" -bytes = "1.5" -axum = { version = "0.7", features = ["ws"] } -tower = { version = "0.4", features = ["util"] } -tower-http = { version = "0.5", features = ["cors"] } -hyper = { version = "1.0", features = ["full"] } -hyper-util = { version = "0.1", features = ["tokio", "http1", "client-legacy"] } -hyper-rustls = { version = "0.27", features = ["http1", "tls12", "ring", "webpki-tokio"] } -http = "1" -http-body = "1" -http-body-util = "0.1" -httparse = "1" -tokio-rustls = "0.26" -rustls = "0.23" -webpki-roots = "0.26" -rustls-native-certs = "0.8" -regex = "1.10" -rquickjs = { version = "0.8", features = ["array-buffer", "classes"] } -thiserror = "2.0" -anyhow = "1.0" -zip = "2.2" -serde_yaml = "0.9" -tempfile = "3" -url = "2.5" -auto-launch = "0.5" -once_cell = "1.21.3" -base64 = "0.22" -rusqlite = { version = "0.31", features = ["bundled", "backup", "hooks"] } -indexmap = { version = "2", features = ["serde"] } -rust_decimal = "1.33" -uuid = { version = "1.11", features = ["v4"] } -sha2 = "0.10" -hmac = "0.12" -json5 = "0.4" -json-five = "0.3.1" - -[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] -tauri-plugin-single-instance = "2" - -[target.'cfg(target_os = "linux")'.dependencies] -webkit2gtk = { version = "2.0.1", features = ["v2_16"] } - -[target.'cfg(target_os = "windows")'.dependencies] -winreg = "0.52" -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_UI_Controls", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } - -[target.'cfg(all(target_os = "windows", target_arch = "aarch64"))'.dependencies] -rquickjs = { version = "0.8", features = ["bindgen"] } - -[target.'cfg(target_os = "macos")'.dependencies] -objc2 = "0.5" -objc2-app-kit = { version = "0.2", features = ["NSColor"] } - -# Optimize release binary size to help reduce AppImage footprint -[profile.release] -codegen-units = 1 -lto = "thin" -opt-level = "s" -# 使用 unwind 以便 panic hook 能捕获 backtrace(abort 会直接终止无法捕获) -panic = "unwind" -strip = "symbols" - -[dev-dependencies] -serial_test = "3" -tempfile = "3" diff --git a/src-tauri/src/proxy/forwarder.rs.before-claude-chat-profile b/src-tauri/src/proxy/forwarder.rs.before-claude-chat-profile deleted file mode 100644 index 98886ea243..0000000000 --- a/src-tauri/src/proxy/forwarder.rs.before-claude-chat-profile +++ /dev/null @@ -1,5248 +0,0 @@ -//! 请求转发器 -//! -//! 负责将请求转发到上游Provider,支持故障转移 - -use super::hyper_client::ProxyResponse; -use super::{ - body_filter::filter_private_params_with_whitelist, - content_encoding::{decompress_body, get_content_encoding}, - error::*, - failover_switch::FailoverSwitchManager, - json_canonical::{canonicalize_value, short_value_hash}, - log_codes::fwd as log_fwd, - provider_router::ProviderRouter, - providers::{ - codex_chat_history::CodexChatHistoryStore, gemini_shadow::GeminiShadowStore, get_adapter, - AuthInfo, AuthStrategy, ProviderAdapter, ProviderType, - }, - thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget}, - thinking_rectifier::{ - normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature, - }, - types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig}, - ProxyError, -}; -use crate::commands::{CodexOAuthState, CopilotAuthState}; -use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager; -use crate::proxy::providers::copilot_auth::CopilotAuthManager; -use crate::{ - app_config::AppType, - provider::{LocalProxyRequestOverrides, Provider}, -}; -use bytes::Bytes; -use futures::StreamExt; -use http::Extensions; -use serde_json::Value; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tauri::Manager; -use tokio::sync::RwLock; - -const PROXY_AUTH_PLACEHOLDER: &str = "PROXY_MANAGED"; -const CODEX_RESPONSES_LITE_FALLBACK_TTL: Duration = Duration::from_secs(24 * 60 * 60); - -pub struct ForwardResult { - pub response: ProxyResponse, - pub provider: Provider, - pub claude_api_format: Option, - /// 实际发往上游的模型名(路由接管/模型映射后的真值)。 - /// - /// usage 归因不能依赖 ctx.request_model(映射前的客户端别名):上游响应 - /// 缺失 model 或回显别名时,接管流量会被记成 claude-* 并按其定价计费。 - pub outbound_model: Option, - /// 活跃连接 RAII guard:随响应一起流转到 response_processor / handle_claude_transform, - /// 最终被 move 进流式 body future(或非流式响应作用域),覆盖整个响应生命周期。 - pub(crate) connection_guard: Option, -} - -pub struct ForwardError { - pub error: ProxyError, - pub provider: Option, -} - -/// 活跃连接 RAII guard -/// -/// 构造时把 `ProxyStatus.active_connections` +1;Drop 时在 tokio runtime 上调度 -/// 一个异步任务执行 -1,从而支持把 guard move 进流式 body future(stream 自然结束 -/// 时 guard 与 future 一起 drop)。 -/// -/// 设计动机:之前在 `forward_with_retry` 出口处同步 -1,但流式响应的 body 实际 -/// 在 `create_logged_passthrough_stream` 内还会继续 yield 字节流,导致 UI 的 -/// `active_connections` 计数过早归零。RAII guard 让"减量"由 Rust 类型系统驱动, -/// 不需要每条出口路径都手动调用。 -pub(crate) struct ActiveConnectionGuard { - status: Arc>, -} - -impl ActiveConnectionGuard { - pub(crate) async fn acquire(status: Arc>) -> Self { - { - let mut s = status.write().await; - s.active_connections = s.active_connections.saturating_add(1); - } - Self { status } - } -} - -impl Drop for ActiveConnectionGuard { - fn drop(&mut self) { - // Drop 不能 await:把减量操作调度到 tokio runtime - let status = self.status.clone(); - if let Ok(handle) = tokio::runtime::Handle::try_current() { - handle.spawn(async move { - let mut s = status.write().await; - s.active_connections = s.active_connections.saturating_sub(1); - }); - } - // 没有 runtime 时静默丢失计数(仅 UI 展示用,可接受最终一致性) - } -} - -pub struct RequestForwarder { - /// 共享的 ProviderRouter(持有熔断器状态) - router: Arc, - status: Arc>, - current_providers: Arc>>, - gemini_shadow: Arc, - codex_chat_history: Arc, - /// 故障转移切换管理器 - failover_manager: Arc, - /// AppHandle,用于发射事件和更新托盘 - app_handle: Option, - /// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘) - current_provider_id_at_start: String, - /// 代理会话 ID(用于 Gemini Native shadow replay) - session_id: String, - /// Session ID 是否由客户端提供;生成值不能作为上游缓存身份。 - session_client_provided: bool, - /// 整流器配置 - rectifier_config: RectifierConfig, - /// 优化器配置 - optimizer_config: OptimizerConfig, - /// Copilot 优化器配置 - copilot_optimizer_config: CopilotOptimizerConfig, - /// Codex Responses-Lite 上游能力负缓存。 - /// - /// key 按 provider + 上游 URL path + 模型隔离;value 是过期时间。命中时直接 - /// 去掉 `x-openai-internal-codex-responses-lite`,过期后重新带头探测,避免每次 - /// 请求都先失败一次,也避免永久禁用未来可能支持 Lite 的上游。 - codex_responses_lite_fallbacks: Arc>>, - /// 非流式请求超时(秒) - non_streaming_timeout: std::time::Duration, - /// 流式请求响应头等待超时(秒) - streaming_first_byte_timeout: std::time::Duration, - /// 单个客户端请求最多尝试的 provider 数。 - /// - /// 由 `AppProxyConfig.max_retries` (UI: "请求失败时的重试次数, 0-10") 派生: - /// `max_attempts = max_retries + 1`,所以 max_retries=0 表示仅尝试一家、 - /// max_retries=3(默认)表示最多 4 家。loop 同时受 providers.len() 自然限制。 - max_attempts: usize, -} - -impl RequestForwarder { - /// 预防式 media 降级:发送前对 text-only 模型把图片块替换为标记。 - /// - /// 受 `enabled && request_media_fallback` 管辖;其中"启发式模型名单预测" - /// 再受 `request_media_heuristic` 单独管辖(显式声明 text-only 始终生效)。 - /// 返回被替换的图片块数量(0 = 未触发或开关关闭)。 - fn apply_media_prevention(&self, body: &mut Value, provider: &Provider) -> usize { - if !(self.rectifier_config.enabled && self.rectifier_config.request_media_fallback) { - return 0; - } - let replaced_images = super::media_sanitizer::replace_images_for_text_only_model( - body, - provider, - self.rectifier_config.request_media_heuristic, - ); - if replaced_images > 0 { - let model = body.get("model").and_then(Value::as_str).unwrap_or(""); - log::info!( - "[Media] Replaced {replaced_images} image block(s) with {} for text-only provider={}, model={}", - super::media_sanitizer::UNSUPPORTED_IMAGE_MARKER, - provider.id, - model - ); - } - replaced_images - } - - /// 反应式 media 重试判定:上游因图片输入报错后,是否应替换图片块并对同一供应商重试一次。 - /// - /// 受 `enabled && request_media_fallback` 管辖;不涉及 `request_media_heuristic`—— - /// 这里是上游"实测"错误后的纯恢复,不是预测,故启发式开关与它无关。 - fn media_retry_should_trigger( - &self, - adapter_name: &str, - already_retried: bool, - provider_body: &Value, - error: &ProxyError, - ) -> bool { - matches!(adapter_name, "Claude" | "Codex") - && self.rectifier_config.enabled - && self.rectifier_config.request_media_fallback - && !already_retried - && super::media_sanitizer::contains_image_blocks(provider_body) - && super::media_sanitizer::is_retriable_image_error(error) - } - - #[allow(clippy::too_many_arguments)] - pub fn new( - router: Arc, - non_streaming_timeout: u64, - status: Arc>, - current_providers: Arc>>, - gemini_shadow: Arc, - codex_chat_history: Arc, - failover_manager: Arc, - app_handle: Option, - current_provider_id_at_start: String, - session_id: String, - session_client_provided: bool, - streaming_first_byte_timeout: u64, - _streaming_idle_timeout: u64, - rectifier_config: RectifierConfig, - optimizer_config: OptimizerConfig, - copilot_optimizer_config: CopilotOptimizerConfig, - max_retries: u32, - ) -> Self { - // max_retries 是「失败后重试次数」语义,attempt 上限 = retries + 1。 - // saturating_add 防止 u32::MAX + 1 溢出。 - let max_attempts = (max_retries as usize).saturating_add(1); - Self { - router, - status, - current_providers, - gemini_shadow, - codex_chat_history, - failover_manager, - app_handle, - current_provider_id_at_start, - session_id, - session_client_provided, - rectifier_config, - optimizer_config, - copilot_optimizer_config, - codex_responses_lite_fallbacks: Arc::new(RwLock::new(HashMap::new())), - non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout), - streaming_first_byte_timeout: std::time::Duration::from_secs( - streaming_first_byte_timeout, - ), - max_attempts, - } - } - - /// 判断当前 Codex Responses-Lite fallback 负缓存是否仍然有效。 - /// - /// 参数: - /// - `key`: 已按 provider、上游 URL 与模型归一化后的缓存 key。 - /// 返回: - /// - `true` 表示本次请求应直接去掉 Lite 头;`false` 表示应带头重新探测。 - /// 副作用: - /// - 如果缓存条目已经过期,会顺手删除,避免内存里长期保留无效能力结果。 - async fn codex_responses_lite_fallback_active(&self, key: &str) -> bool { - let mut fallbacks = self.codex_responses_lite_fallbacks.write().await; - codex_responses_lite_fallback_active_at(&mut fallbacks, key, Instant::now()) - } - - /// 记录一个短期 Responses-Lite fallback 负缓存。 - /// - /// 只有上游明确返回 Lite 不支持错误后才调用;缓存过期后下一次请求会重新带头 - /// 探测,防止第三方上游未来支持该协议后仍被永久去头。 - async fn mark_codex_responses_lite_fallback(&self, key: String) { - let now = Instant::now(); - let mut fallbacks = self.codex_responses_lite_fallbacks.write().await; - if fallbacks.len() > 512 { - fallbacks.retain(|_, expires_at| *expires_at > now); - } - fallbacks.insert(key, now + CODEX_RESPONSES_LITE_FALLBACK_TTL); - } - - async fn record_success_result( - &self, - circuit_provider_id: &str, - health_provider_id: &str, - app_type: &str, - used_half_open_permit: bool, - ) { - if used_half_open_permit { - let provider_id = health_provider_id; - if let Err(e) = self - .router - .record_result_with_health_provider( - circuit_provider_id, - health_provider_id, - app_type, - true, - true, - None, - ) - .await - { - log::warn!( - "[{app_type}] 记录 Provider 成功结果失败: provider_id={provider_id}, error={e}" - ); - } - return; - } - - let router = self.router.clone(); - let circuit_provider_id = circuit_provider_id.to_string(); - let health_provider_id = health_provider_id.to_string(); - let app_type = app_type.to_string(); - tokio::spawn(async move { - let provider_id = health_provider_id.clone(); - if let Err(e) = router - .record_result_with_health_provider( - &circuit_provider_id, - &health_provider_id, - &app_type, - false, - true, - None, - ) - .await - { - log::warn!( - "[{app_type}] 异步记录 Provider 成功结果失败: provider_id={provider_id}, error={e}" - ); - } - }); - } - - /// 整流(thinking signature 或 budget)重试失败后的统一收尾。 - /// - /// `None` 表示已记录熔断器、累积 `last_error`/`last_provider`, - /// 调用方应 `continue` 让下一家 provider 继续故障转移; - /// `Some(ForwardError)` 表示是客户端错误,没有 provider 能修复, - /// 调用方应直接 `return` 把错误返回给客户端。 - #[allow(clippy::too_many_arguments)] - async fn handle_rectifier_retry_failure( - &self, - retry_err: ProxyError, - provider: &Provider, - app_type_str: &str, - used_half_open_permit: bool, - rectifier_label: &str, - last_error: &mut Option, - last_provider: &mut Option, - ) -> Option { - // Provider 错误:本家上游/网络确实出问题,下一家 provider 可能可用 → 继续故障转移。 - // 客户端错误:整流后请求仍违法,下一家也修不好 → 直接返回。 - let is_provider_error = match &retry_err { - ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => true, - ProxyError::UpstreamError { status, .. } => *status >= 500, - _ => false, - }; - - if is_provider_error { - let (persistent_provider_id, _) = - super::providers::codex_route_persistent_provider(provider); - let _ = self - .router - .record_result_with_health_provider( - &provider.id, - persistent_provider_id, - app_type_str, - used_half_open_permit, - false, - Some(retry_err.to_string()), - ) - .await; - { - let mut status = self.status.write().await; - status.last_error = Some(format!( - "Provider {} {rectifier_label}重试失败: {}", - provider.name, retry_err - )); - } - *last_error = Some(retry_err); - *last_provider = Some(provider.clone()); - return None; - } - - self.router - .release_permit_neutral(&provider.id, app_type_str, used_half_open_permit) - .await; - let mut status = self.status.write().await; - status.failed_requests += 1; - status.last_error = Some(retry_err.to_string()); - if status.total_requests > 0 { - status.success_rate = - (status.success_requests as f32 / status.total_requests as f32) * 100.0; - } - Some(ForwardError { - error: retry_err, - provider: Some(provider.clone()), - }) - } - - /// 转发请求(带故障转移) - /// - /// 这是 thin wrapper:在客户端请求维度记一次 `total_requests` / 调整 - /// `active_connections` / 刷新 `last_request_at`,无论 inner 走哪条出口路径, - /// 出口处都会把 `active_connections` 回收。Per-attempt 维度(成功/失败/熔断 - /// 等)仍由 inner 内自行更新 `success_requests` / `failed_requests`。 - #[allow(clippy::too_many_arguments)] - pub async fn forward_with_retry( - &self, - app_type: &AppType, - method: http::Method, - endpoint: &str, - body: Value, - headers: axum::http::HeaderMap, - extensions: Extensions, - providers: Vec, - ) -> Result { - let guard = ActiveConnectionGuard::acquire(self.status.clone()).await; - { - let mut s = self.status.write().await; - s.total_requests = s.total_requests.saturating_add(1); - s.last_request_at = Some(chrono::Utc::now().to_rfc3339()); - } - let result = self - .forward_with_retry_inner( - app_type, method, endpoint, body, headers, extensions, providers, - ) - .await; - // 把 guard 注入到 Ok 结果,让它随响应一起流转到 response_processor, - // 在流式 body 的 future 内才真正 drop。 - // Err 路径:guard 在函数 scope 内随返回值落地时自动 drop。 - result.map(|mut fr| { - fr.connection_guard = Some(guard); - fr - }) - } - - /// 实际转发逻辑(不包含客户端维度的入口/出口计数) - /// - /// # Arguments - /// * `app_type` - 应用类型 - /// * `method` - 客户端请求的 HTTP 方法(透传给上游,支持 GET/POST 等) - /// * `endpoint` - API 端点 - /// * `body` - 请求体 - /// * `headers` - 请求头 - /// * `providers` - 已选择的 Provider 列表(由 RequestContext 提供,避免重复调用 select_providers) - #[allow(clippy::too_many_arguments)] - async fn forward_with_retry_inner( - &self, - app_type: &AppType, - method: http::Method, - endpoint: &str, - body: Value, - headers: axum::http::HeaderMap, - extensions: Extensions, - providers: Vec, - ) -> Result { - // 获取适配器 - let adapter = get_adapter(app_type); - let app_type_str = app_type.as_str(); - - if providers.is_empty() { - return Err(ForwardError { - error: ProxyError::NoAvailableProvider, - provider: None, - }); - } - - let attempt_providers = build_forward_attempt_providers_preserving_codex_router_context( - app_type, &providers, &body, - ); - let mut last_error = None; - let mut last_provider = None; - let mut attempted_providers = 0usize; - - // 单 Provider 场景下跳过熔断器检查(故障转移关闭时) - let bypass_circuit_breaker = attempt_providers.len() == 1; - - // 依次尝试每个供应商 - for provider in attempt_providers.iter() { - let attempt_provider_id = provider.id.clone(); - let (persistent_provider_id, persistent_provider_name) = - super::providers::codex_route_persistent_provider(provider); - let persistent_provider_id = persistent_provider_id.to_string(); - let persistent_provider_name = persistent_provider_name.to_string(); - - // 整流器重试标记:每个 provider 独立持有,避免标记跨 provider 短路故障转移 - // —— 首家 provider 整流后被 5xx/timeout 击落时,下家仍能用整流后的请求体走整流流程 - let mut rectifier_retried = false; - let mut budget_rectifier_retried = false; - let mut media_rectifier_retried = false; - - // 上限检查:尊重用户在 AppProxyConfig.max_retries 上配置的「重试次数」。 - // 放在熔断器 allow 检查之前,避免在已经超限时还占用 HalfOpen 探测名额。 - if attempted_providers >= self.max_attempts { - log::warn!( - "[{app_type_str}] 已达最大尝试次数上限 ({}/{}), 停止故障转移", - attempted_providers, - self.max_attempts - ); - break; - } - - // 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额) - // 单 Provider 场景下跳过此检查,避免熔断器阻塞所有请求 - let (allowed, used_half_open_permit) = if bypass_circuit_breaker { - (true, false) - } else { - let permit = self - .router - .allow_provider_request(&provider.id, app_type_str) - .await; - (permit.allowed, permit.used_half_open_permit) - }; - - if !allowed { - continue; - } - - // PRE-SEND 优化器:每个 provider 独立决定是否优化 - // clone body 以避免 Bedrock 优化字段泄漏到非 Bedrock provider(failover 场景) - let mut provider_body = - if self.optimizer_config.enabled && is_bedrock_provider(provider) { - let mut b = body.clone(); - if self.optimizer_config.thinking_optimizer { - super::thinking_optimizer::optimize(&mut b, &self.optimizer_config); - } - if self.optimizer_config.cache_injection { - super::cache_injector::inject(&mut b, &self.optimizer_config); - } - b - } else { - body.clone() - }; - - attempted_providers += 1; - - // 更新状态中的当前 Provider 信息(per-attempt 维度的标识) - // - // total_requests / last_request_at / active_connections 已由 - // forward_with_retry wrapper 在客户端请求维度统一处理,这里只刷 - // 新「正在尝试哪个 provider」的展示字段。 - { - let mut status = self.status.write().await; - status.current_provider = Some(persistent_provider_name.clone()); - status.current_provider_id = Some(persistent_provider_id.clone()); - } - - // 转发请求(每个 Provider 只尝试一次,重试由客户端控制) - match self - .forward( - app_type, - &method, - provider, - endpoint, - &provider_body, - &headers, - &extensions, - adapter.as_ref(), - ) - .await - { - Ok((response, claude_api_format, effective_provider, outbound_model)) => { - // 成功:普通闭合熔断状态异步记录,避免阻塞流式首包返回; - // HalfOpen 探测仍同步等待,保证 permit 与熔断状态及时释放。 - self.record_success_result( - &attempt_provider_id, - &persistent_provider_id, - app_type_str, - used_half_open_permit, - ) - .await; - - // 更新当前应用类型使用的 provider - { - let mut current_providers = self.current_providers.write().await; - current_providers.insert( - app_type_str.to_string(), - ( - persistent_provider_id.clone(), - persistent_provider_name.clone(), - ), - ); - } - - // 更新成功统计 - { - let mut status = self.status.write().await; - status.success_requests += 1; - status.last_error = None; - let should_switch = self.current_provider_id_at_start.as_str() - != persistent_provider_id.as_str(); - if should_switch { - status.failover_count += 1; - - // 异步触发供应商切换,更新 UI/托盘,并把“当前供应商”同步为实际使用的 provider - let fm = self.failover_manager.clone(); - let ah = self.app_handle.clone(); - let pid = persistent_provider_id.clone(); - let pname = persistent_provider_name.clone(); - let at = app_type_str.to_string(); - - tokio::spawn(async move { - let _ = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await; - }); - } - // 重新计算成功率 - if status.total_requests > 0 { - status.success_rate = (status.success_requests as f32 - / status.total_requests as f32) - * 100.0; - } - } - - return Ok(ForwardResult { - response, - provider: effective_provider, - claude_api_format, - outbound_model, - connection_guard: None, - }); - } - Err(e) => { - // 检测是否需要触发整流器(仅 Claude/ClaudeAuth 供应商) - let provider_type = ProviderType::from_app_type_and_config(app_type, provider); - let is_anthropic_provider = matches!( - provider_type, - ProviderType::Claude | ProviderType::ClaudeAuth - ); - let mut signature_rectifier_non_retryable_client_error = false; - - if self.media_retry_should_trigger( - adapter.name(), - media_rectifier_retried, - &provider_body, - &e, - ) { - let mut media_body = provider_body.clone(); - let replaced_images = - super::media_sanitizer::replace_image_blocks_with_marker( - &mut media_body, - ); - - if replaced_images > 0 { - let _ = std::mem::replace(&mut media_rectifier_retried, true); - let model = media_body - .get("model") - .and_then(Value::as_str) - .unwrap_or(""); - log::info!( - "[{app_type_str}] [Media] Upstream rejected image input; retrying provider={} model={} with {replaced_images} image block(s) replaced by {}", - provider.id, - model, - super::media_sanitizer::UNSUPPORTED_IMAGE_MARKER - ); - - match self - .forward( - app_type, - &method, - provider, - endpoint, - &media_body, - &headers, - &extensions, - adapter.as_ref(), - ) - .await - { - Ok(( - response, - claude_api_format, - routed_provider, - outbound_model, - )) => { - log::info!("[{app_type_str}] [Media] Image retry succeeded"); - self.record_success_result( - &attempt_provider_id, - &persistent_provider_id, - app_type_str, - used_half_open_permit, - ) - .await; - - { - let mut current_providers = - self.current_providers.write().await; - current_providers.insert( - app_type_str.to_string(), - ( - persistent_provider_id.clone(), - persistent_provider_name.clone(), - ), - ); - } - - { - let mut status = self.status.write().await; - status.success_requests += 1; - status.last_error = None; - let should_switch = - self.current_provider_id_at_start.as_str() - != persistent_provider_id.as_str(); - if should_switch { - status.failover_count += 1; - let fm = self.failover_manager.clone(); - let ah = self.app_handle.clone(); - let pid = persistent_provider_id.clone(); - let pname = persistent_provider_name.clone(); - let at = app_type_str.to_string(); - - tokio::spawn(async move { - let _ = fm - .try_switch(ah.as_ref(), &at, &pid, &pname) - .await; - }); - } - if status.total_requests > 0 { - status.success_rate = (status.success_requests as f32 - / status.total_requests as f32) - * 100.0; - } - } - - return Ok(ForwardResult { - response, - provider: routed_provider, - claude_api_format, - outbound_model, - connection_guard: None, - }); - } - Err(retry_err) => { - log::warn!( - "[{app_type_str}] [Media] Image retry still failed: {retry_err}" - ); - if let Some(err) = self - .handle_rectifier_retry_failure( - retry_err, - provider, - app_type_str, - used_half_open_permit, - "media 降级", - &mut last_error, - &mut last_provider, - ) - .await - { - return Err(err); - } - continue; - } - } - } - } - - if is_anthropic_provider { - let error_message = extract_error_message(&e); - if should_rectify_thinking_signature( - error_message.as_deref(), - &self.rectifier_config, - ) { - // 已经重试过:直接返回错误(不可重试客户端错误) - if rectifier_retried { - log::warn!("[{app_type_str}] [RECT-005] 整流器已触发过,不再重试"); - // 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题) - self.router - .release_permit_neutral( - &provider.id, - app_type_str, - used_half_open_permit, - ) - .await; - let mut status = self.status.write().await; - status.failed_requests += 1; - status.last_error = Some(e.to_string()); - if status.total_requests > 0 { - status.success_rate = (status.success_requests as f32 - / status.total_requests as f32) - * 100.0; - } - return Err(ForwardError { - error: e, - provider: Some(provider.clone()), - }); - } - - // 首次触发:整流请求体 - let rectified = rectify_anthropic_request(&mut provider_body); - - // 整流未生效:继续尝试 budget 整流路径,避免误判后短路 - if !rectified.applied { - log::warn!( - "[{app_type_str}] [RECT-006] thinking 签名整流器触发但无可整流内容,继续检查 budget;若 budget 也未命中则按客户端错误返回" - ); - signature_rectifier_non_retryable_client_error = true; - } else { - log::info!( - "[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields", - app_type_str, - rectified.removed_thinking_blocks, - rectified.removed_redacted_thinking_blocks, - rectified.removed_signature_fields - ); - - // 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展) - let _ = std::mem::replace(&mut rectifier_retried, true); - - // 使用同一供应商重试(不计入熔断器) - match self - .forward( - app_type, - &method, - provider, - endpoint, - &provider_body, - &headers, - &extensions, - adapter.as_ref(), - ) - .await - { - Ok(( - response, - claude_api_format, - effective_provider, - outbound_model, - )) => { - log::info!("[{app_type_str}] [RECT-002] 整流重试成功"); - self.record_success_result( - &attempt_provider_id, - &persistent_provider_id, - app_type_str, - used_half_open_permit, - ) - .await; - - // 更新当前应用类型使用的 provider - { - let mut current_providers = - self.current_providers.write().await; - current_providers.insert( - app_type_str.to_string(), - ( - persistent_provider_id.clone(), - persistent_provider_name.clone(), - ), - ); - } - - // 更新成功统计 - { - let mut status = self.status.write().await; - status.success_requests += 1; - status.last_error = None; - let should_switch = - self.current_provider_id_at_start.as_str() - != persistent_provider_id.as_str(); - if should_switch { - status.failover_count += 1; - - // 异步触发供应商切换,更新 UI/托盘 - let fm = self.failover_manager.clone(); - let ah = self.app_handle.clone(); - let pid = persistent_provider_id.clone(); - let pname = persistent_provider_name.clone(); - let at = app_type_str.to_string(); - - tokio::spawn(async move { - let _ = fm - .try_switch(ah.as_ref(), &at, &pid, &pname) - .await; - }); - } - if status.total_requests > 0 { - status.success_rate = (status.success_requests - as f32 - / status.total_requests as f32) - * 100.0; - } - } - - return Ok(ForwardResult { - response, - provider: effective_provider, - claude_api_format, - outbound_model, - connection_guard: None, - }); - } - Err(retry_err) => { - log::warn!( - "[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}" - ); - if let Some(err) = self - .handle_rectifier_retry_failure( - retry_err, - provider, - app_type_str, - used_half_open_permit, - "整流", - &mut last_error, - &mut last_provider, - ) - .await - { - return Err(err); - } - continue; - } - } - } - } - } - - // 检测是否需要触发 budget 整流器(仅 Claude/ClaudeAuth 供应商) - if is_anthropic_provider { - let error_message = extract_error_message(&e); - if should_rectify_thinking_budget( - error_message.as_deref(), - &self.rectifier_config, - ) { - // 已经重试过:直接返回错误(不可重试客户端错误) - if budget_rectifier_retried { - log::warn!( - "[{app_type_str}] [RECT-013] budget 整流器已触发过,不再重试" - ); - self.router - .release_permit_neutral( - &provider.id, - app_type_str, - used_half_open_permit, - ) - .await; - let mut status = self.status.write().await; - status.failed_requests += 1; - status.last_error = Some(e.to_string()); - if status.total_requests > 0 { - status.success_rate = (status.success_requests as f32 - / status.total_requests as f32) - * 100.0; - } - return Err(ForwardError { - error: e, - provider: Some(provider.clone()), - }); - } - - let budget_rectified = rectify_thinking_budget(&mut provider_body); - if !budget_rectified.applied { - log::warn!( - "[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容,不做无意义重试" - ); - self.router - .release_permit_neutral( - &provider.id, - app_type_str, - used_half_open_permit, - ) - .await; - let mut status = self.status.write().await; - status.failed_requests += 1; - status.last_error = Some(e.to_string()); - if status.total_requests > 0 { - status.success_rate = (status.success_requests as f32 - / status.total_requests as f32) - * 100.0; - } - return Err(ForwardError { - error: e, - provider: Some(provider.clone()), - }); - } - - log::info!( - "[{}] [RECT-010] thinking budget 整流器触发, before={:?}, after={:?}", - app_type_str, - budget_rectified.before, - budget_rectified.after - ); - - let _ = std::mem::replace(&mut budget_rectifier_retried, true); - - // 使用同一供应商重试(不计入熔断器) - match self - .forward( - app_type, - &method, - provider, - endpoint, - &provider_body, - &headers, - &extensions, - adapter.as_ref(), - ) - .await - { - Ok(( - response, - claude_api_format, - effective_provider, - outbound_model, - )) => { - log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功"); - self.record_success_result( - &attempt_provider_id, - &persistent_provider_id, - app_type_str, - used_half_open_permit, - ) - .await; - - { - let mut current_providers = - self.current_providers.write().await; - current_providers.insert( - app_type_str.to_string(), - ( - persistent_provider_id.clone(), - persistent_provider_name.clone(), - ), - ); - } - - { - let mut status = self.status.write().await; - status.success_requests += 1; - status.last_error = None; - let should_switch = - self.current_provider_id_at_start.as_str() - != persistent_provider_id.as_str(); - if should_switch { - status.failover_count += 1; - let fm = self.failover_manager.clone(); - let ah = self.app_handle.clone(); - let pid = persistent_provider_id.clone(); - let pname = persistent_provider_name.clone(); - let at = app_type_str.to_string(); - tokio::spawn(async move { - let _ = fm - .try_switch(ah.as_ref(), &at, &pid, &pname) - .await; - }); - } - if status.total_requests > 0 { - status.success_rate = (status.success_requests as f32 - / status.total_requests as f32) - * 100.0; - } - } - - return Ok(ForwardResult { - response, - provider: effective_provider, - claude_api_format, - outbound_model, - connection_guard: None, - }); - } - Err(retry_err) => { - log::warn!( - "[{app_type_str}] [RECT-012] budget 整流重试仍失败: {retry_err}" - ); - if let Some(err) = self - .handle_rectifier_retry_failure( - retry_err, - provider, - app_type_str, - used_half_open_permit, - "budget 整流", - &mut last_error, - &mut last_provider, - ) - .await - { - return Err(err); - } - continue; - } - } - } - } - - if signature_rectifier_non_retryable_client_error { - self.router - .release_permit_neutral( - &provider.id, - app_type_str, - used_half_open_permit, - ) - .await; - let mut status = self.status.write().await; - status.failed_requests += 1; - status.last_error = Some(e.to_string()); - if status.total_requests > 0 { - status.success_rate = (status.success_requests as f32 - / status.total_requests as f32) - * 100.0; - } - return Err(ForwardError { - error: e, - provider: Some(provider.clone()), - }); - } - - // 先分类错误,决定是否计入 provider 健康度 - // —— NonRetryable / ClientAbort 是客户端层错误,无论换哪家 provider 都会被拒绝, - // 不应污染熔断器和数据库健康度(与 release_permit_neutral 同语义)。 - let category = self.categorize_proxy_error(&e); - - match category { - ErrorCategory::Retryable => { - // 可重试:真正的 provider 故障 → 记录失败并更新熔断器/DB 健康度 - let _ = self - .router - .record_result_with_health_provider( - &provider.id, - &persistent_provider_id, - app_type_str, - used_half_open_permit, - false, - Some(e.to_string()), - ) - .await; - - { - let mut status = self.status.write().await; - status.last_error = - Some(format!("Provider {} 失败: {}", provider.name, e)); - } - - let (log_code, log_message) = build_retryable_failure_log( - &provider.name, - attempted_providers, - attempt_providers.len(), - &e, - ); - log::warn!("[{app_type_str}] [{log_code}] {log_message}"); - - last_error = Some(e); - last_provider = Some(provider.clone()); - // 继续尝试下一个供应商 - continue; - } - ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => { - // 不可重试:客户端层错误或客户端断连 → 不污染健康度,仅释放 HalfOpen permit - self.router - .release_permit_neutral( - &provider.id, - app_type_str, - used_half_open_permit, - ) - .await; - { - let mut status = self.status.write().await; - status.failed_requests += 1; - status.last_error = Some(e.to_string()); - if status.total_requests > 0 { - status.success_rate = (status.success_requests as f32 - / status.total_requests as f32) - * 100.0; - } - } - return Err(ForwardError { - error: e, - provider: Some(provider.clone()), - }); - } - } - } - } - } - - if attempted_providers == 0 { - // providers 列表非空,但全部被熔断器拒绝(典型:HalfOpen 探测名额被占用) - { - let mut status = self.status.write().await; - status.failed_requests += 1; - status.last_error = Some("所有供应商暂时不可用(熔断器限制)".to_string()); - if status.total_requests > 0 { - status.success_rate = - (status.success_requests as f32 / status.total_requests as f32) * 100.0; - } - } - return Err(ForwardError { - error: ProxyError::NoAvailableProvider, - provider: None, - }); - } - - // 所有供应商都失败了 - { - let mut status = self.status.write().await; - status.failed_requests += 1; - status.last_error = Some("所有供应商都失败".to_string()); - if status.total_requests > 0 { - status.success_rate = - (status.success_requests as f32 / status.total_requests as f32) * 100.0; - } - } - - if let Some((log_code, log_message)) = build_terminal_failure_log( - attempted_providers, - attempt_providers.len(), - last_error.as_ref(), - ) { - log::warn!("[{app_type_str}] [{log_code}] {log_message}"); - } - - Err(ForwardError { - error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded), - provider: last_provider, - }) - } - - /// 转发单个请求(使用适配器) - /// - /// 成功时返回 `(response, claude_api_format, outbound_model)`,其中 - /// `outbound_model` 是最终发往上游的模型名(所有映射/改写之后)。 - #[allow(clippy::too_many_arguments)] - async fn forward( - &self, - app_type: &AppType, - method: &http::Method, - provider: &Provider, - endpoint: &str, - body: &Value, - headers: &axum::http::HeaderMap, - extensions: &Extensions, - adapter: &dyn ProviderAdapter, - ) -> Result<(ProxyResponse, Option, Provider, Option), ProxyError> { - let codex_trace_id = - matches!(app_type, AppType::Codex).then(|| uuid::Uuid::new_v4().to_string()); - let route_started_at = std::time::Instant::now(); - let request_model_for_log = body - .get("model") - .and_then(|value| value.as_str()) - .unwrap_or("unknown") - .to_string(); - let outer_provider_id = provider.id.clone(); - let outer_provider_name = provider.name.clone(); - - // Codex v2 是一个复合 provider:Codex 客户端只看到一个 provider bucket, - // Rust proxy 根据请求模型临时解析真实上游 provider,后续 base_url/auth/转换逻辑 - // 都使用这个 effective provider。 - let provider_is_resolved_codex_route = provider - .settings_config - .get("codexResolvedRouteId") - .is_some(); - let codex_router_configured = matches!(app_type, AppType::Codex) - && !provider_is_resolved_codex_route - && codex_provider_has_routing_config(provider); - let routed_provider = if matches!(app_type, AppType::Codex) { - (!provider_is_resolved_codex_route) - .then(|| super::providers::resolve_codex_model_routed_provider(provider, body)) - .flatten() - } else { - None - }; - let routed_provider = if let Some(route_provider) = routed_provider { - if let Some(target_provider_id) = - super::providers::codex_route_target_provider_id(&route_provider) - { - let Some(target_provider) = self - .router - .get_provider_by_id(target_provider_id, app_type.as_str()) - .map_err(|err| { - ProxyError::ConfigError(format!( - "读取 Codex route 目标供应商 '{target_provider_id}' 失败: {err}" - )) - })? - else { - return Err(ProxyError::ConfigError(format!( - "Codex route 引用了不存在的目标供应商 '{target_provider_id}'" - ))); - }; - Some( - super::providers::materialize_codex_routed_provider_from_target( - &route_provider, - &target_provider, - ), - ) - } else { - Some(route_provider) - } - } else { - None - }; - let codex_route_missed = codex_router_configured && routed_provider.is_none(); - let provider = routed_provider.as_ref().unwrap_or(provider); - - if let Some(trace_id) = codex_trace_id.as_deref() { - let route_id = provider - .settings_config - .get("codexResolvedRouteId") - .and_then(|value| value.as_str()) - .unwrap_or(""); - super::codex_router_log::append_event( - "route_resolved", - &[ - ("trace", trace_id.to_string()), - ("session", self.session_id.clone()), - ("endpoint", endpoint.to_string()), - ("model", request_model_for_log.clone()), - ("outer_provider", outer_provider_id.clone()), - ("outer_name", outer_provider_name.clone()), - ("effective_provider", provider.id.clone()), - ("effective_name", provider.name.clone()), - ("route_id", route_id.to_string()), - ("routing_configured", codex_router_configured.to_string()), - ("route_missed", codex_route_missed.to_string()), - ( - "elapsed_ms", - route_started_at.elapsed().as_millis().to_string(), - ), - ], - ); - } - - if let Some(routed_provider) = routed_provider.as_ref() { - let request_model = body - .get("model") - .and_then(|value| value.as_str()) - .unwrap_or("unknown"); - log::debug!( - "[CodexRouter] model={} routed to provider={} ({})", - request_model, - routed_provider.id, - routed_provider.name - ); - } - // 使用适配器提取 base_url - let mut base_url = adapter.extract_base_url(provider)?; - if codex_route_missed && codex_base_url_points_to_local_proxy(&base_url) { - let request_model = body - .get("model") - .and_then(|value| value.as_str()) - .unwrap_or("unknown"); - if let Some(trace_id) = codex_trace_id.as_deref() { - super::codex_router_log::append_event( - "route_error", - &[ - ("trace", trace_id.to_string()), - ("session", self.session_id.clone()), - ("endpoint", endpoint.to_string()), - ("model", request_model.to_string()), - ("outer_provider", outer_provider_id.clone()), - ("fallback_base_url", base_url.clone()), - ("reason", "route_miss_local_proxy_fallback".to_string()), - ], - ); - } - return Err(ProxyError::InvalidRequest(format!( - "Codex router provider did not match model '{request_model}', and its fallback base_url points to the local proxy. Refusing to forward recursively; add a route/defaultRouteId or switch away from the router provider." - ))); - } - - let is_full_url = provider - .meta - .as_ref() - .and_then(|meta| meta.is_full_url) - .unwrap_or(false); - - // GitHub Copilot API 使用 /chat/completions(无 /v1 前缀) - let is_copilot = provider - .meta - .as_ref() - .and_then(|m| m.provider_type.as_deref()) - == Some("github_copilot") - || base_url.contains("githubcopilot.com"); - - // 应用模型映射(独立于格式转换) - // 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) - .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); - mapped_body - }; - - // 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口) - let mut mapped_body = normalize_thinking_type(mapped_body); - - if is_copilot { - mapped_body = - super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body); - self.apply_copilot_live_model_resolution(provider, &mut mapped_body) - .await; - } else { - mapped_body = - super::model_mapper::strip_one_m_suffix_for_upstream_from_body(mapped_body); - } - - // --- Copilot 优化器:分类 + 请求体优化(在格式转换之前执行) --- - // 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move - // - // 执行顺序(与 copilot-api 对齐): - // 1. 先在原始 body 上分类(保留 tool_result 语义,避免误判为 user) - // 2. 再清洗孤立 tool_result(防止上游 API 报错) - // 3. 再合并 tool_result + text(减少 premium 计费) - let copilot_optimization = if is_copilot && self.copilot_optimizer_config.enabled { - // 1. 在原始 body 上分类 — 必须在清洗/合并之前执行 - // 孤立 tool_result 仍保持 tool_result 类型,分类能正确识别为 agent - let has_anthropic_beta = headers.contains_key("anthropic-beta"); - let classification = super::copilot_optimizer::classify_request( - &mapped_body, - has_anthropic_beta, - self.copilot_optimizer_config.compact_detection, - self.copilot_optimizer_config.subagent_detection, - ); - - log::debug!( - "[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}, is_subagent={}", - classification.initiator, - classification.is_warmup, - classification.is_compact, - classification.is_subagent - ); - - // 2. 孤立 tool_result 清理 — 分类完成后再清洗 - // 防止上游 API 因不匹配的 tool_result 报错导致重试/重复计费 - mapped_body = super::copilot_optimizer::sanitize_orphan_tool_results(mapped_body); - - // 3. Tool result 合并 — 将 [tool_result, text] 变为 [tool_result(含text)] - if self.copilot_optimizer_config.tool_result_merging { - mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body); - } - - // 3.5. 主动剥离 thinking block — Copilot 走 OpenAI 兼容端点不识别该块 - // 避免上游拒绝后由 rectifier 反应式重试(首次请求已消耗 quota) - if self.copilot_optimizer_config.strip_thinking { - mapped_body = super::copilot_optimizer::strip_thinking_blocks(mapped_body); - } - - // 4. Warmup 小模型降级 - if self.copilot_optimizer_config.warmup_downgrade && classification.is_warmup { - log::info!( - "[Copilot] Warmup 请求降级到模型: {}", - self.copilot_optimizer_config.warmup_model - ); - mapped_body["model"] = - serde_json::json!(&self.copilot_optimizer_config.warmup_model); - } - - // 预计算确定性 Request ID(在 body 被 move 之前) - // Session 提取优先级(与 session.rs extract_from_metadata 对齐): - // 1. metadata.user_id 中的 _session_ 后缀 - // 2. metadata.session_id(直接字段) - // 3. raw metadata.user_id(整串 fallback) - // 4. x-session-id header - let metadata = body.get("metadata"); - let session_id = metadata - .and_then(|m| m.get("user_id")) - .and_then(|v| v.as_str()) - .and_then(super::session::parse_session_from_user_id) - .or_else(|| { - metadata - .and_then(|m| m.get("session_id")) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - }) - .or_else(|| { - metadata - .and_then(|m| m.get("user_id")) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - }) - .or_else(|| { - headers - .get("x-session-id") - .and_then(|v| v.to_str().ok()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - }) - .unwrap_or_default(); - let det_request_id = if self.copilot_optimizer_config.deterministic_request_id { - Some(super::copilot_optimizer::deterministic_request_id( - &mapped_body, - &session_id, - )) - } else { - None - }; - - // 从 session ID 派生稳定的 interaction ID(同一主对话共享) - let interaction_id = - super::copilot_optimizer::deterministic_interaction_id(&session_id); - - Some((classification, det_request_id, interaction_id)) - } else { - None - }; - - // GitHub Copilot 动态 endpoint 路由 - // 从 CopilotAuthManager 获取缓存的 API endpoint(支持企业版等非默认 endpoint) - if is_copilot && !is_full_url { - if let Some(app_handle) = &self.app_handle { - let copilot_state = app_handle.state::(); - let copilot_auth = copilot_state.0.read().await; - - // 从 provider.meta 获取关联的 GitHub 账号 ID - let account_id = provider - .meta - .as_ref() - .and_then(|m| m.managed_account_id_for("github_copilot")); - - let dynamic_endpoint = match &account_id { - Some(id) => copilot_auth.get_api_endpoint(id).await, - None => copilot_auth.get_default_api_endpoint().await, - }; - - // 只在动态 endpoint 与当前 base_url 不同时替换 - if dynamic_endpoint != base_url { - log::debug!( - "[Copilot] 使用动态 API endpoint: {} (原: {})", - dynamic_endpoint, - base_url - ); - base_url = dynamic_endpoint; - } - } - } - let resolved_claude_api_format = if adapter.name() == "Claude" { - Some( - self.resolve_claude_api_format(provider, &mapped_body, is_copilot) - .await, - ) - } else { - None - }; - if adapter.name() == "Claude" { - if let Some(api_format) = resolved_claude_api_format.as_deref() { - super::providers::normalize_anthropic_messages_for_provider( - &mut mapped_body, - provider, - api_format, - ); - self.apply_media_prevention(&mut mapped_body, provider); - } - } - let needs_transform = match resolved_claude_api_format.as_deref() { - Some(api_format) => super::providers::claude_api_format_needs_transform(api_format), - None => adapter.needs_transform(provider), - }; - let codex_responses_to_chat = matches!(app_type, AppType::Codex) - && super::providers::should_convert_codex_responses_to_chat(provider, endpoint); - let codex_responses_to_messages = matches!(app_type, AppType::Codex) - && super::providers::should_convert_codex_responses_to_messages(provider, endpoint); - let (effective_endpoint, passthrough_query) = if codex_responses_to_chat { - rewrite_codex_responses_endpoint_to_chat(endpoint) - } else if codex_responses_to_messages { - rewrite_codex_responses_endpoint_to_messages(endpoint) - } else if needs_transform && adapter.name() == "Claude" { - let api_format = resolved_claude_api_format - .as_deref() - .unwrap_or_else(|| super::providers::get_claude_api_format(provider)); - rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot, &mapped_body) - } else { - ( - endpoint.to_string(), - split_endpoint_and_query(endpoint) - .1 - .map(ToString::to_string), - ) - }; - - let codex_chat_base_is_full_endpoint = codex_responses_to_chat - && base_url - .trim_end_matches('/') - .to_ascii_lowercase() - .ends_with("/chat/completions"); - - let url = if matches!(resolved_claude_api_format.as_deref(), Some("gemini_native")) { - super::gemini_url::resolve_gemini_native_url( - &base_url, - &effective_endpoint, - is_full_url, - ) - } else if is_full_url || codex_chat_base_is_full_endpoint { - append_query_to_full_url(&base_url, passthrough_query.as_deref()) - } else { - adapter.build_url(&base_url, &effective_endpoint) - }; - - // 记录映射后的出站模型名(此时 mapped_body 已完成接管映射 / [1m] 剥离 / - // Copilot 归一化)。格式转换后若 body 仍带 model 字段会在下方刷新覆盖; - // gemini_native 等模型在 URL 中的格式则保留此处的转换前真值。 - let mut outbound_model = mapped_body - .get("model") - .and_then(|m| m.as_str()) - .filter(|m| !m.is_empty()) - .map(str::to_string); - - // 转换请求体(如果需要) - let request_prepare_started_at = std::time::Instant::now(); - let mut request_body = if codex_responses_to_chat || codex_responses_to_messages { - let mut mapped_body = mapped_body; - let restored = self - .codex_chat_history - .enrich_request(&mut mapped_body) - .await; - if restored > 0 { - log::debug!( - "[Codex] Restored or enriched {restored} cached function call item(s) for Chat upstream" - ); - } - super::providers::apply_codex_chat_upstream_model(provider, &mut mapped_body); - let reasoning_config = - super::providers::resolve_codex_chat_reasoning_config(provider, &mapped_body); - let text_only_override = super::providers::codex_provider_text_only_input(provider); - let cache_config = super::providers::resolve_codex_cache_config(provider, &mapped_body); - super::providers::transform_codex_chat::responses_to_chat_completions_with_reasoning_text_only_and_cache( - mapped_body, - reasoning_config.as_ref(), - text_only_override, - Some(&cache_config), - )? - } else if needs_transform { - if adapter.name() == "Claude" { - let api_format = resolved_claude_api_format - .as_deref() - .unwrap_or_else(|| super::providers::get_claude_api_format(provider)); - super::providers::transform_claude_request_for_api_format( - mapped_body, - provider, - api_format, - self.session_client_provided - .then_some(self.session_id.as_str()), - Some(self.gemini_shadow.as_ref()), - )? - } else { - adapter.transform_request(mapped_body, provider)? - } - } else { - let mut mapped_body = mapped_body; - if matches!(app_type, AppType::Codex) { - super::providers::apply_codex_request_upstream_model(provider, &mut mapped_body); - } - mapped_body - }; - - if matches!(app_type, AppType::Codex) { - self.apply_media_prevention(&mut request_body, provider); - } - - // 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游 - // 默认使用空白名单,过滤所有 _ 前缀字段 - let request_body = if should_normalize_codex_oauth_responses_passthrough_body( - app_type, - provider, - &url, - needs_transform, - codex_responses_to_chat, - codex_responses_to_messages, - ) { - super::providers::openai_compat::normalize_codex_oauth_responses_request(request_body) - } else if should_normalize_codex_responses_passthrough_control_messages( - app_type, - provider, - endpoint, - needs_transform, - codex_responses_to_chat, - codex_responses_to_messages, - ) { - super::providers::openai_compat::normalize_codex_responses_passthrough_request( - request_body, - ) - } else { - request_body - }; - let mut filtered_body = prepare_upstream_request_body(request_body); - if !is_copilot { - if let Some(overrides) = provider - .meta - .as_ref() - .and_then(|meta| meta.local_proxy_request_overrides.as_ref()) - { - if apply_local_proxy_body_overrides(&mut filtered_body, overrides) { - filtered_body = prepare_upstream_request_body(filtered_body); - } - } - } - // 出站 body 定稿后刷新真值(覆盖 Codex chat 上游模型覆写、转换层模型改写) - if let Some(m) = filtered_body - .get("model") - .and_then(|m| m.as_str()) - .filter(|m| !m.is_empty()) - { - outbound_model = Some(m.to_string()); - } - log_prompt_cache_trace( - app_type, - provider, - &effective_endpoint, - resolved_claude_api_format.as_deref(), - &filtered_body, - self.session_client_provided, - ); - let request_is_streaming = - is_streaming_request(&effective_endpoint, &filtered_body, headers); - let force_identity_encoding = needs_transform - || codex_responses_to_chat - || codex_responses_to_messages - || request_is_streaming; - - let codex_chat_request_shape = - codex_responses_to_chat.then(|| summarize_codex_chat_request_shape(&filtered_body)); - if let Some(trace_id) = codex_trace_id.as_deref() { - let mut fields = vec![ - ("trace", trace_id.to_string()), - ("session", self.session_id.clone()), - ("endpoint", endpoint.to_string()), - ("effective_endpoint", effective_endpoint.clone()), - ("model", request_model_for_log.clone()), - ("provider", provider.id.clone()), - ("upstream_url", url.clone()), - ("responses_to_chat", codex_responses_to_chat.to_string()), - ( - "responses_to_messages", - codex_responses_to_messages.to_string(), - ), - ("streaming", request_is_streaming.to_string()), - ( - "elapsed_ms", - request_prepare_started_at.elapsed().as_millis().to_string(), - ), - ]; - if let Some(shape) = codex_chat_request_shape.as_ref() { - fields.push(("request_shape", shape.clone())); - } - super::codex_router_log::append_event("request_prepared", &fields); - } - - // Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充) - let mut codex_oauth_account_id: Option = None; - let mut should_send_codex_oauth_session_headers = false; - - // 获取认证头(提前准备,用于内联替换) - let auth_started_at = std::time::Instant::now(); - let mut auth_strategy_for_log = "none".to_string(); - let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) { - // GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token - if auth.strategy == AuthStrategy::GitHubCopilot { - if let Some(app_handle) = &self.app_handle { - let copilot_state = app_handle.state::(); - let copilot_auth: tokio::sync::RwLockReadGuard<'_, CopilotAuthManager> = - copilot_state.0.read().await; - - // 从 provider.meta 获取关联的 GitHub 账号 ID(多账号支持) - let account_id = provider - .meta - .as_ref() - .and_then(|m| m.managed_account_id_for("github_copilot")); - - // 根据账号 ID 获取对应 token(向后兼容:无账号 ID 时使用第一个账号) - let token_result = match &account_id { - Some(id) => { - log::debug!("[Copilot] 使用指定账号 {id} 获取 token"); - copilot_auth.get_valid_token_for_account(id).await - } - None => { - log::debug!("[Copilot] 使用默认账号获取 token"); - copilot_auth.get_valid_token().await - } - }; - - match token_result { - Ok(token) => { - auth = AuthInfo::new(token, AuthStrategy::GitHubCopilot); - log::debug!( - "[Copilot] 成功获取 Copilot token (account={})", - account_id.as_deref().unwrap_or("default") - ); - } - Err(e) => { - log::error!( - "[Copilot] 获取 Copilot token 失败 (account={}): {e}", - account_id.as_deref().unwrap_or("default") - ); - return Err(ProxyError::AuthError(format!( - "GitHub Copilot 认证失败: {e}" - ))); - } - } - } else { - log::error!("[Copilot] AppHandle 不可用"); - return Err(ProxyError::AuthError( - "GitHub Copilot 认证不可用(无 AppHandle)".to_string(), - )); - } - } - - // Codex OAuth 特殊处理:从 CodexOAuthManager 获取真实 access_token - if auth.strategy == AuthStrategy::CodexOAuth { - if let Some(app_handle) = &self.app_handle { - let codex_state = app_handle.state::(); - let codex_auth: tokio::sync::RwLockReadGuard<'_, CodexOAuthManager> = - codex_state.0.read().await; - - // 从 provider.meta 获取关联的 ChatGPT 账号 ID - let account_id = provider - .meta - .as_ref() - .and_then(|m| m.managed_account_id_for("codex_oauth")); - - let token_result = match &account_id { - Some(id) => { - log::debug!("[CodexOAuth] 使用指定账号 {id} 获取 token"); - codex_auth.get_valid_token_for_account(id).await - } - None => { - log::debug!("[CodexOAuth] 使用默认账号获取 token"); - codex_auth.get_valid_token().await - } - }; - - match token_result { - Ok(token) => { - auth = AuthInfo::new(token, AuthStrategy::CodexOAuth); - should_send_codex_oauth_session_headers = true; - // 解析使用的 account_id(用于注入 ChatGPT-Account-Id header) - codex_oauth_account_id = match account_id { - Some(id) => Some(id), - None => codex_auth.default_account_id().await, - }; - log::debug!( - "[CodexOAuth] 成功获取 access_token (account={})", - codex_oauth_account_id.as_deref().unwrap_or("default") - ); - } - Err(e) => { - log::error!("[CodexOAuth] 获取 access_token 失败: {e}"); - return Err(ProxyError::AuthError(format!( - "Codex OAuth 认证失败: {e}" - ))); - } - } - } else { - log::error!("[CodexOAuth] AppHandle 不可用"); - return Err(ProxyError::AuthError( - "Codex OAuth 认证不可用(无 AppHandle)".to_string(), - )); - } - } - - auth_strategy_for_log = format!("{:?}", auth.strategy); - adapter.get_auth_headers(&auth)? - } else { - Vec::new() - }; - - // 注入 Codex OAuth 的 ChatGPT-Account-Id header(如果有 account_id) - if let Some(ref account_id) = codex_oauth_account_id { - if let Ok(hv) = http::HeaderValue::from_str(account_id) { - auth_headers.push((http::HeaderName::from_static("chatgpt-account-id"), hv)); - } - } - - let codex_oauth_session_headers = - if should_send_codex_oauth_session_headers && self.session_client_provided { - build_codex_oauth_session_headers(&self.session_id) - } else { - Vec::new() - }; - - if let Some(trace_id) = codex_trace_id.as_deref() { - super::codex_router_log::append_event( - "auth_prepared", - &[ - ("trace", trace_id.to_string()), - ("session", self.session_id.clone()), - ("model", request_model_for_log.clone()), - ("provider", provider.id.clone()), - ("auth_strategy", auth_strategy_for_log.clone()), - ("auth_header_count", auth_headers.len().to_string()), - ( - "oauth_session_header_count", - codex_oauth_session_headers.len().to_string(), - ), - ( - "elapsed_ms", - auth_started_at.elapsed().as_millis().to_string(), - ), - ], - ); - } - - // 自定义 User-Agent:与 stream_check / model_fetch 共用 parse_custom_user_agent, - // 运行时静默忽略非法值(前端在输入处给非阻断提示,不在保存时阻断)。 - // Copilot 指纹 UA 不可覆盖。 - let custom_user_agent = if is_copilot { - None - } else { - provider - .meta - .as_ref() - .and_then(|meta| meta.custom_user_agent_header().ok().flatten()) - }; - - // --- Copilot 优化器:动态 header 注入 --- - if let Some((ref classification, ref det_request_id, ref interaction_id)) = - copilot_optimization - { - for (name, value) in auth_headers.iter_mut() { - match name.as_str() { - "x-initiator" if self.copilot_optimizer_config.request_classification => { - *value = http::HeaderValue::from_static(classification.initiator); - } - "x-interaction-type" if classification.is_subagent => { - // 子代理请求:conversation-subagent 不计 premium interaction - *value = http::HeaderValue::from_static("conversation-subagent"); - } - "x-request-id" | "x-agent-task-id" => { - if let Some(ref det_id) = det_request_id { - if let Ok(hv) = http::HeaderValue::from_str(det_id) { - *value = hv; - } - } - } - _ => {} - } - } - - // x-interaction-id:仅在有 session 时注入(不在 get_auth_headers 中) - if let Some(ref iid) = interaction_id { - if let Ok(hv) = http::HeaderValue::from_str(iid) { - auth_headers.push((http::HeaderName::from_static("x-interaction-id"), hv)); - } - } - - if classification.is_subagent { - log::info!( - "[Copilot] 子代理请求: x-initiator=agent, x-interaction-type=conversation-subagent" - ); - } - } - - // Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重) - let copilot_fingerprint_headers: &[&str] = if is_copilot { - &[ - "user-agent", - "editor-version", - "editor-plugin-version", - "copilot-integration-id", - "x-github-api-version", - "openai-intent", - // 新增 headers - "x-initiator", - "x-interaction-type", - "x-interaction-id", - "x-vscode-user-agent-library-version", - "x-request-id", - "x-agent-task-id", - ] - } else { - &[] - }; - - // 预计算上游 host 值(用于在原位替换 host header) - let upstream_host = url - .parse::() - .ok() - .and_then(|u| u.authority().map(|a| a.to_string())); - - let should_send_anthropic_headers = adapter.name() == "Claude" - && matches!(resolved_claude_api_format.as_deref(), Some("anthropic")); - - // 预计算 anthropic-beta 值(仅 Claude) - let anthropic_beta_value = if should_send_anthropic_headers { - const CLAUDE_CODE_BETA: &str = "claude-code-20250219"; - Some(if let Some(beta) = headers.get("anthropic-beta") { - if let Ok(beta_str) = beta.to_str() { - if beta_str.contains(CLAUDE_CODE_BETA) { - beta_str.to_string() - } else { - format!("{CLAUDE_CODE_BETA},{beta_str}") - } - } else { - CLAUDE_CODE_BETA.to_string() - } - } else { - CLAUDE_CODE_BETA.to_string() - }) - } else { - None - }; - - // ============================================================ - // 构建有序 HeaderMap — 内联替换,保持客户端原始顺序 - // ============================================================ - let mut ordered_headers = http::HeaderMap::new(); - let mut saw_auth = false; - let mut saw_accept_encoding = false; - let mut saw_user_agent = false; - let mut saw_anthropic_beta = false; - let mut saw_anthropic_version = false; - - for (key, value) in headers { - let key_str = key.as_str(); - - // --- host — 原位替换为上游 host(保持客户端原始位置) --- - if key_str.eq_ignore_ascii_case("host") { - if let Some(ref host_val) = upstream_host { - if let Ok(hv) = http::HeaderValue::from_str(host_val) { - ordered_headers.append(key.clone(), hv); - } - } - continue; - } - - // --- 连接 / 追踪 / CDN 类 — 无条件跳过 --- - if matches!( - key_str, - "content-length" - | "transfer-encoding" - | "x-forwarded-host" - | "x-forwarded-port" - | "x-forwarded-proto" - | "forwarded" - | "cf-connecting-ip" - | "cf-ipcountry" - | "cf-ray" - | "cf-visitor" - | "true-client-ip" - | "fastly-client-ip" - | "x-azure-clientip" - | "x-azure-fdid" - | "x-azure-ref" - | "akamai-origin-hop" - | "x-akamai-config-log-detail" - | "x-request-id" - | "x-correlation-id" - | "x-trace-id" - | "x-amzn-trace-id" - | "x-b3-traceid" - | "x-b3-spanid" - | "x-b3-parentspanid" - | "x-b3-sampled" - | "traceparent" - | "tracestate" - ) { - continue; - } - - // --- 认证类 — 用 adapter 提供的认证头替换(在原始位置) --- - if key_str.eq_ignore_ascii_case("authorization") - || key_str.eq_ignore_ascii_case("x-api-key") - || key_str.eq_ignore_ascii_case("x-goog-api-key") - { - if !saw_auth { - saw_auth = true; - for (ah_name, ah_value) in &auth_headers { - ordered_headers.append(ah_name.clone(), ah_value.clone()); - } - } - continue; - } - - // --- accept-encoding — transform / SSE 路径强制 identity,其余保留原值 --- - if key_str.eq_ignore_ascii_case("accept-encoding") { - if !saw_accept_encoding { - saw_accept_encoding = true; - if force_identity_encoding { - ordered_headers.append( - http::header::ACCEPT_ENCODING, - http::HeaderValue::from_static("identity"), - ); - } else { - ordered_headers.append(key.clone(), value.clone()); - } - } - continue; - } - - // --- user-agent: provider-level override for local proxy routing --- - if !is_copilot && key_str.eq_ignore_ascii_case("user-agent") { - if !saw_user_agent { - saw_user_agent = true; - if let Some(ref ua) = custom_user_agent { - ordered_headers.append(http::header::USER_AGENT, ua.clone()); - } else { - ordered_headers.append(key.clone(), value.clone()); - } - } - continue; - } - - // --- anthropic-beta — 用重建值替换(确保含 claude-code 标记) --- - if key_str.eq_ignore_ascii_case("anthropic-beta") { - if !saw_anthropic_beta { - saw_anthropic_beta = true; - if let Some(ref beta_val) = anthropic_beta_value { - if let Ok(hv) = http::HeaderValue::from_str(beta_val) { - ordered_headers.append("anthropic-beta", hv); - } - } - } - continue; - } - - // --- anthropic-version — 透传客户端值 --- - if key_str.eq_ignore_ascii_case("anthropic-version") { - if should_send_anthropic_headers { - saw_anthropic_version = true; - ordered_headers.append(key.clone(), value.clone()); - } - continue; - } - - // --- Copilot 指纹头 — 跳过(由 auth_headers 提供) --- - if copilot_fingerprint_headers - .iter() - .any(|h| key_str.eq_ignore_ascii_case(h)) - { - continue; - } - - // --- 默认:透传 --- - ordered_headers.append(key.clone(), value.clone()); - } - - // 如果原始请求中没有认证头,在末尾追加 - if !saw_auth && !auth_headers.is_empty() { - for (ah_name, ah_value) in &auth_headers { - ordered_headers.append(ah_name.clone(), ah_value.clone()); - } - } - - // transform / SSE 路径在缺失时补 identity;普通透传不主动补 accept-encoding - if !saw_accept_encoding && force_identity_encoding { - ordered_headers.append( - http::header::ACCEPT_ENCODING, - http::HeaderValue::from_static("identity"), - ); - } - - if !saw_user_agent { - if let Some(ref ua) = custom_user_agent { - ordered_headers.append(http::header::USER_AGENT, ua.clone()); - } - } - - // 如果原始请求中没有 anthropic-beta 且有值需要添加,追加 - if !saw_anthropic_beta { - if let Some(ref beta_val) = anthropic_beta_value { - if let Ok(hv) = http::HeaderValue::from_str(beta_val) { - ordered_headers.append("anthropic-beta", hv); - } - } - } - - // anthropic-version:仅在缺失时补充默认值 - if should_send_anthropic_headers && !saw_anthropic_version { - ordered_headers.append( - "anthropic-version", - http::HeaderValue::from_static("2023-06-01"), - ); - } - - // Codex OAuth 反代尽量对齐官方 Codex CLI 的会话路由信号。 - // 只发送客户端提供的 session_id;生成的 UUID 每次不同,反而会破坏前缀缓存。 - for (name, value) in codex_oauth_session_headers { - if !ordered_headers.contains_key(&name) { - ordered_headers.insert(name, value); - } - } - - // 序列化请求体。GET/HEAD 是 idempotent/safe 方法,按 HTTP 语义不应携带 body; - // 强行附带 JSON body 会让某些上游(如 Google Gemini 的 models.list)拒绝请求。 - let body_bytes = if matches!(method, &http::Method::GET | &http::Method::HEAD) { - Vec::new() - } else { - serde_json::to_vec(&filtered_body).map_err(|e| { - ProxyError::Internal(format!("Failed to serialize request body: {e}")) - })? - }; - let request_bytes_len = body_bytes.len(); - - // 确保 content-type 存在 - if !ordered_headers.contains_key(http::header::CONTENT_TYPE) { - ordered_headers.insert( - http::header::CONTENT_TYPE, - http::HeaderValue::from_static("application/json"), - ); - } - - apply_local_proxy_header_overrides( - &mut ordered_headers, - provider - .meta - .as_ref() - .and_then(|meta| meta.local_proxy_request_overrides.as_ref()), - is_copilot, - ); - - reject_proxy_placeholder_for_managed_account_upstream(&url, &ordered_headers)?; - - // 输出请求信息日志 - let tag = adapter.name(); - let request_model = filtered_body - .get("model") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let responses_lite_fallback_key = - codex_responses_lite_fallback_key(&provider.id, &url, request_model); - if matches!(app_type, AppType::Codex) - && ordered_headers.contains_key(http::HeaderName::from_static( - "x-openai-internal-codex-responses-lite", - )) - && self - .codex_responses_lite_fallback_active(&responses_lite_fallback_key) - .await - { - ordered_headers.remove(http::HeaderName::from_static( - "x-openai-internal-codex-responses-lite", - )); - log::info!( - "[{tag}] 命中 Codex Responses-Lite fallback 缓存,按 provider/url/model 直接去头发送 (model={request_model})" - ); - if let Some(trace_id) = codex_trace_id.as_deref() { - super::codex_router_log::append_event( - "responses_lite_fallback_cache_hit", - &[ - ("trace", trace_id.to_string()), - ("session", self.session_id.clone()), - ("model", request_model.to_string()), - ("provider", provider.id.clone()), - ], - ); - } - } - log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})"); - if log::log_enabled!(log::Level::Debug) { - log::debug!( - "[{tag}] >>> 请求体摘要: bytes={}, body_hash={}", - request_bytes_len, - short_value_hash(Some(&filtered_body)) - ); - } - - // 确定超时 - let timeout = if self.non_streaming_timeout.is_zero() { - std::time::Duration::from_secs(600) // 默认 600 秒 - } else { - self.non_streaming_timeout - }; - - // 获取全局代理 URL - let upstream_proxy_url: Option = super::http_client::get_current_proxy_url(); - - // SOCKS5 代理不支持 CONNECT 隧道,需要用 reqwest - let is_socks_proxy = upstream_proxy_url - .as_deref() - .map(|u| u.starts_with("socks5")) - .unwrap_or(false); - - let preserve_exact_header_case = should_preserve_exact_header_case( - adapter.name(), - provider, - resolved_claude_api_format.as_deref(), - is_copilot, - ); - let transport_for_log = if is_socks_proxy || !preserve_exact_header_case { - "reqwest" - } else { - "hyper" - }; - let upstream_started_at = std::time::Instant::now(); - if let Some(trace_id) = codex_trace_id.as_deref() { - super::codex_router_log::append_event( - "upstream_send", - &[ - ("trace", trace_id.to_string()), - ("session", self.session_id.clone()), - ("model", request_model_for_log.clone()), - ("provider", provider.id.clone()), - ("transport", transport_for_log.to_string()), - ("request_bytes", request_bytes_len.to_string()), - ("header_count", ordered_headers.len().to_string()), - ("streaming", request_is_streaming.to_string()), - ("timeout_ms", timeout.as_millis().to_string()), - ( - "uses_upstream_proxy", - upstream_proxy_url.is_some().to_string(), - ), - ], - ); - } - - // 发送请求。默认保留 Codex Responses-Lite 协商头;只有上游明确返回 - // Lite 不支持错误时,才在错误响应体读取后剥头重发一次。 - let send_upstream_request = |headers: http::HeaderMap, body_bytes: Vec| { - let method = method.clone(); - let url = url.clone(); - let extensions = extensions.clone(); - let upstream_proxy_url = upstream_proxy_url.clone(); - async move { - send_forwarder_upstream_request( - method, - url, - headers, - extensions, - body_bytes, - timeout, - request_is_streaming, - self.non_streaming_timeout, - self.streaming_first_byte_timeout, - is_socks_proxy, - preserve_exact_header_case, - upstream_proxy_url.as_deref(), - ) - .await - } - }; - - let mut response = send_upstream_request(ordered_headers.clone(), body_bytes.clone()) - .await - .inspect_err(|err| { - if let Some(trace_id) = codex_trace_id.as_deref() { - let transport = if is_socks_proxy || !preserve_exact_header_case { - "reqwest" - } else { - "hyper" - }; - super::codex_router_log::append_event( - "upstream_send_error", - &[ - ("trace", trace_id.to_string()), - ("session", self.session_id.clone()), - ("model", request_model_for_log.clone()), - ("provider", provider.id.clone()), - ("transport", transport.to_string()), - ( - "elapsed_ms", - upstream_started_at.elapsed().as_millis().to_string(), - ), - ("error", err.to_string()), - ], - ); - } - })?; - - // 检查响应状态 - let mut status = response.status(); - let upstream_elapsed_ms = upstream_started_at.elapsed().as_millis().to_string(); - if let Some(trace_id) = codex_trace_id.as_deref() { - super::codex_router_log::append_event( - "upstream_status", - &[ - ("trace", trace_id.to_string()), - ("session", self.session_id.clone()), - ("model", request_model_for_log.clone()), - ("provider", provider.id.clone()), - ("status", status.as_u16().to_string()), - ("streaming", request_is_streaming.to_string()), - ("elapsed_ms", upstream_elapsed_ms.clone()), - ], - ); - } - - if !status.is_success() { - let status_code = status.as_u16(); - let body_text = read_decoded_error_body(response).await?; - if let Some(trace_id) = codex_trace_id.as_deref() { - append_upstream_error_event( - trace_id, - &self.session_id, - &request_model_for_log, - &provider.id, - status_code, - body_text.as_deref(), - codex_chat_request_shape.as_deref(), - ); - } - - if should_retry_without_codex_responses_lite_header( - app_type, - &ordered_headers, - status_code, - body_text.as_deref(), - ) { - self.mark_codex_responses_lite_fallback(responses_lite_fallback_key.clone()) - .await; - let mut retry_headers = ordered_headers.clone(); - retry_headers.remove(http::HeaderName::from_static( - "x-openai-internal-codex-responses-lite", - )); - log::warn!( - "[{tag}] 上游拒绝 Codex Responses-Lite,剥离内部协商头后重试一次 (model={request_model})" - ); - if let Some(trace_id) = codex_trace_id.as_deref() { - super::codex_router_log::append_event( - "upstream_retry_without_responses_lite", - &[ - ("trace", trace_id.to_string()), - ("session", self.session_id.clone()), - ("model", request_model_for_log.clone()), - ("provider", provider.id.clone()), - ("status", status_code.to_string()), - ( - "body_summary", - body_text - .as_deref() - .map(summarize_upstream_body) - .unwrap_or_else(|| "".to_string()), - ), - ], - ); - } - response = send_upstream_request(retry_headers, body_bytes.clone()) - .await - .inspect_err(|err| { - if let Some(trace_id) = codex_trace_id.as_deref() { - let transport = if is_socks_proxy || !preserve_exact_header_case { - "reqwest" - } else { - "hyper" - }; - super::codex_router_log::append_event( - "upstream_send_error", - &[ - ("trace", trace_id.to_string()), - ("session", self.session_id.clone()), - ("model", request_model_for_log.clone()), - ("provider", provider.id.clone()), - ("transport", transport.to_string()), - ( - "elapsed_ms", - upstream_started_at.elapsed().as_millis().to_string(), - ), - ("error", err.to_string()), - ], - ); - } - })?; - status = response.status(); - if let Some(trace_id) = codex_trace_id.as_deref() { - super::codex_router_log::append_event( - "upstream_status", - &[ - ("trace", trace_id.to_string()), - ("session", self.session_id.clone()), - ("model", request_model_for_log.clone()), - ("provider", provider.id.clone()), - ("status", status.as_u16().to_string()), - ("streaming", request_is_streaming.to_string()), - ( - "elapsed_ms", - upstream_started_at.elapsed().as_millis().to_string(), - ), - ("retry", "without_responses_lite".to_string()), - ], - ); - } - } else { - return Err(ProxyError::UpstreamError { - status: status_code, - body: body_text, - }); - } - } - - if status.is_success() { - let response_prepare_started_at = std::time::Instant::now(); - let response = self - .prepare_success_response_for_failover(response, request_is_streaming) - .await?; - if let Some(trace_id) = codex_trace_id.as_deref() { - super::codex_router_log::append_event( - "response_ready", - &[ - ("trace", trace_id.to_string()), - ("session", self.session_id.clone()), - ("model", request_model_for_log.clone()), - ("provider", provider.id.clone()), - ("status", status.as_u16().to_string()), - ("streaming", request_is_streaming.to_string()), - ( - "elapsed_ms", - response_prepare_started_at - .elapsed() - .as_millis() - .to_string(), - ), - ], - ); - } - Ok(( - response, - resolved_claude_api_format, - provider.clone(), - outbound_model, - )) - } else { - let status_code = status.as_u16(); - let body_text = read_decoded_error_body(response).await?; - if let Some(trace_id) = codex_trace_id.as_deref() { - append_upstream_error_event( - trace_id, - &self.session_id, - &request_model_for_log, - &provider.id, - status_code, - body_text.as_deref(), - codex_chat_request_shape.as_deref(), - ); - } - - Err(ProxyError::UpstreamError { - status: status_code, - body: body_text, - }) - } - } - - /// 故障转移开启时,成功不能只看上游响应头。 - /// - /// - 非流式:先把完整 body 读到内存,读超时/连接中断会回到 retry loop 尝试下一家。 - /// - 流式:至少等首个 chunk 到达,避免上游返回 200 后一直不吐 SSE 时被误记成功。 - async fn prepare_success_response_for_failover( - &self, - response: ProxyResponse, - request_is_streaming: bool, - ) -> Result { - if request_is_streaming { - return self.prime_streaming_response(response).await; - } - - if self.non_streaming_timeout.is_zero() { - return Ok(response); - } - - let status = response.status(); - let headers = response.headers().clone(); - let body_timeout = self.non_streaming_timeout; - let body = tokio::time::timeout(body_timeout, response.bytes()) - .await - .map_err(|_| { - ProxyError::Timeout(format!( - "响应体读取超时: {}s(上游发完响应头后 body 未到达)", - body_timeout.as_secs() - )) - })??; - - Ok(ProxyResponse::buffered(status, headers, body)) - } - - async fn prime_streaming_response( - &self, - response: ProxyResponse, - ) -> Result { - if self.streaming_first_byte_timeout.is_zero() { - return Ok(response); - } - - let status = response.status(); - let headers = response.headers().clone(); - let timeout = self.streaming_first_byte_timeout; - let mut stream = Box::pin(response.bytes_stream()); - - let first = tokio::time::timeout(timeout, stream.next()) - .await - .map_err(|_| { - ProxyError::Timeout(format!( - "流式响应首包超时: {}s(上游已返回响应头但未返回数据)", - timeout.as_secs() - )) - })?; - - let Some(first) = first else { - return Err(ProxyError::ForwardFailed( - "流式响应在首包到达前结束".to_string(), - )); - }; - - let first = - first.map_err(|e| ProxyError::ForwardFailed(format!("读取流式响应首包失败: {e}")))?; - - if let Some(message) = retryable_error_from_primed_sse_chunk(&first) { - return Err(ProxyError::UpstreamError { - status: 503, - body: Some(message), - }); - } - - let replay = futures::stream::once(async move { Ok(first) }).chain(stream); - Ok(ProxyResponse::streamed(status, headers, replay)) - } - - async fn resolve_claude_api_format( - &self, - provider: &Provider, - body: &Value, - is_copilot: bool, - ) -> String { - if !is_copilot { - return super::providers::get_claude_api_format(provider).to_string(); - } - - let model = body.get("model").and_then(|value| value.as_str()); - if let Some(model_id) = model { - if self - .is_copilot_openai_vendor_model(provider, model_id) - .await - { - return "openai_responses".to_string(); - } - } - - "openai_chat".to_string() - } - - /// 用 Copilot live `/models` 列表确认 model ID 真实可用,找不到时按 family 降级。 - /// 命中缓存后是同步的;首次请求或 5 min 缓存过期后会触发一次 HTTP。 - async fn apply_copilot_live_model_resolution( - &self, - provider: &Provider, - body: &mut serde_json::Value, - ) { - let Some(model_id) = body.get("model").and_then(|v| v.as_str()) else { - return; - }; - let model_id = model_id.to_string(); - - let Some(app_handle) = &self.app_handle else { - return; - }; - let copilot_state = app_handle.state::(); - let copilot_auth = copilot_state.0.read().await; - let account_id = provider - .meta - .as_ref() - .and_then(|m| m.managed_account_id_for("github_copilot")); - - let models_result = match account_id.as_deref() { - Some(id) => copilot_auth.fetch_models_for_account(id).await, - None => copilot_auth.fetch_models().await, - }; - - let models = match models_result { - Ok(m) => m, - Err(err) => { - log::debug!("[Copilot] live model list unavailable, skip resolution: {err}"); - return; - } - }; - - if let Some(resolved) = - super::providers::copilot_model_map::resolve_against_models(&model_id, &models) - { - log::info!("[Copilot] live-model resolve: {model_id} → {resolved}"); - body["model"] = serde_json::Value::String(resolved); - } - } - - async fn is_copilot_openai_vendor_model(&self, provider: &Provider, model_id: &str) -> bool { - let Some(app_handle) = &self.app_handle else { - log::debug!("[Copilot] AppHandle unavailable, fallback to chat/completions"); - return false; - }; - - let copilot_state = app_handle.state::(); - let copilot_auth = copilot_state.0.read().await; - let account_id = provider - .meta - .as_ref() - .and_then(|m| m.managed_account_id_for("github_copilot")); - - let vendor_result = match account_id.as_deref() { - Some(id) => { - copilot_auth - .get_model_vendor_for_account(id, model_id) - .await - } - None => copilot_auth.get_model_vendor(model_id).await, - }; - - match vendor_result { - Ok(Some(vendor)) => vendor.eq_ignore_ascii_case("openai"), - Ok(None) => { - log::debug!( - "[Copilot] Model vendor unavailable for {model_id}, fallback to chat/completions" - ); - false - } - Err(err) => { - log::warn!( - "[Copilot] Failed to resolve model vendor for {model_id}, fallback to chat/completions: {err}" - ); - false - } - } - } - - fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory { - match error { - // 网络和上游错误:都应该尝试下一个供应商 - ProxyError::Timeout(_) => ErrorCategory::Retryable, - ProxyError::ForwardFailed(_) => ErrorCategory::Retryable, - ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable, - // 上游 HTTP 错误:按状态码分桶。 - // - // 客户端请求自身有问题的状态码无论换哪个 provider 都会被拒绝, - // 继续轮询只会放大错误率、污染熔断器健康度、浪费配额: - // 400 Bad Request / 422 Unprocessable Entity ← 请求体格式或语义错误 - // 405 Method Not Allowed / 406 Not Acceptable ← 方法或 Accept 错误 - // 413 Payload Too Large / 414 URI Too Long ← 客户端构造超限 - // 415 Unsupported Media Type ← Content-Type 错误 - // 501 Not Implemented ← 上游协议确实不支持 - // - // 其他 4xx(401/403/404/408/409/429/451 等)和全部 5xx 都保留 - // Retryable —— 换一家 provider 可能持有不同的 key、配额、地域或模型映射。 - ProxyError::UpstreamError { status, .. } => match *status { - 400 | 405 | 406 | 413 | 414 | 415 | 422 | 501 => ErrorCategory::NonRetryable, - _ => ErrorCategory::Retryable, - }, - // Provider 级配置/转换问题:换一个 Provider 可能就能成功 - ProxyError::ConfigError(_) => ErrorCategory::Retryable, - ProxyError::TransformError(_) => ErrorCategory::Retryable, - ProxyError::AuthError(_) => ErrorCategory::Retryable, - ProxyError::StreamIdleTimeout(_) => ErrorCategory::Retryable, - // 无可用供应商:所有供应商都试过了,无法重试 - ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable, - // 其他错误(数据库/内部错误等):不是换供应商能解决的问题 - _ => ErrorCategory::NonRetryable, - } - } -} - -/// 从 ProxyError 中提取错误消息 -fn extract_error_message(error: &ProxyError) -> Option { - match error { - ProxyError::UpstreamError { body, .. } => body.clone(), - _ => Some(error.to_string()), - } -} - -/// 检测 Provider 是否为 Bedrock(通过 CLAUDE_CODE_USE_BEDROCK 环境变量判断) -fn is_bedrock_provider(provider: &Provider) -> bool { - provider - .settings_config - .get("env") - .and_then(|e| e.get("CLAUDE_CODE_USE_BEDROCK")) - .and_then(|v| v.as_str()) - .map(|v| v == "1") - .unwrap_or(false) -} - -fn build_retryable_failure_log( - provider_name: &str, - attempted_providers: usize, - total_providers: usize, - error: &ProxyError, -) -> (&'static str, String) { - let error_summary = summarize_proxy_error(error); - - if total_providers <= 1 { - ( - log_fwd::SINGLE_PROVIDER_FAILED, - format!("Provider {provider_name} 请求失败: {error_summary}"), - ) - } else { - ( - log_fwd::PROVIDER_FAILED_RETRY, - format!( - "Provider {provider_name} 失败,继续尝试下一个 ({attempted_providers}/{total_providers}): {error_summary}" - ), - ) - } -} - -fn build_terminal_failure_log( - attempted_providers: usize, - total_providers: usize, - last_error: Option<&ProxyError>, -) -> Option<(&'static str, String)> { - if total_providers <= 1 { - return None; - } - - let error_summary = last_error - .map(summarize_proxy_error) - .unwrap_or_else(|| "未知错误".to_string()); - - Some(( - log_fwd::ALL_PROVIDERS_FAILED, - format!( - "已尝试 {attempted_providers}/{total_providers} 个 Provider,均失败。最后错误: {error_summary}" - ), - )) -} - -fn summarize_proxy_error(error: &ProxyError) -> String { - match error { - ProxyError::UpstreamError { status, body } => { - let body_summary = body - .as_deref() - .map(summarize_upstream_body) - .filter(|summary| !summary.is_empty()); - - match body_summary { - Some(summary) => format!("上游 HTTP {status}: {summary}"), - None => format!("上游 HTTP {status}"), - } - } - ProxyError::Timeout(message) => { - format!("请求超时: {}", summarize_text_for_log(message, 180)) - } - ProxyError::ForwardFailed(message) => { - format!("请求转发失败: {}", summarize_text_for_log(message, 180)) - } - ProxyError::TransformError(message) => { - format!("响应转换失败: {}", summarize_text_for_log(message, 180)) - } - ProxyError::ConfigError(message) => { - format!("配置错误: {}", summarize_text_for_log(message, 180)) - } - ProxyError::AuthError(message) => { - format!("认证失败: {}", summarize_text_for_log(message, 180)) - } - _ => summarize_text_for_log(&error.to_string(), 180), - } -} - -/// 从已经预读到的首个 SSE 分块里识别“上游还没真正开始生成就失败”的错误。 -/// -/// 这类错误常见于 ChatGPT/Codex OAuth 在高负载时返回 HTTP 200 + `event: error` -/// 或 `event: response.failed`。如果此时直接把响应头交给 Codex,后续已经无法在同一个 -/// HTTP 请求里切换到下一条路由;在首包阶段把它还原为 503,才能复用现有 failover/retry -/// 机制。普通 `response.created` / delta 事件必须原样放行。 -fn retryable_error_from_primed_sse_chunk(first: &Bytes) -> Option { - let text = std::str::from_utf8(first).ok()?; - for block in text.split("\n\n") { - let mut event_name: Option<&str> = None; - let mut data_lines = Vec::new(); - - for line in block.lines() { - if let Some(value) = line.strip_prefix("event:") { - event_name = Some(value.trim()); - } else if let Some(value) = line.strip_prefix("data:") { - data_lines.push(value.trim()); - } - } - - if data_lines.is_empty() { - continue; - } - - let data = data_lines.join("\n"); - let parsed = serde_json::from_str::(&data).ok(); - let event_is_error = matches!( - event_name, - Some("error" | "response.failed" | "response.error") - ); - let payload_is_error = parsed.as_ref().is_some_and(|value| { - value.get("error").is_some() - || value - .get("type") - .and_then(Value::as_str) - .is_some_and(|kind| matches!(kind, "error" | "response.failed")) - || value - .pointer("/response/status") - .and_then(Value::as_str) - .is_some_and(|status| status == "failed") - }); - - if event_is_error || payload_is_error { - return Some(extract_sse_error_message(parsed.as_ref()).unwrap_or(data)); - } - } - - None -} - -/// 提取 SSE 错误体里最适合写入日志/返回给重试分类器的消息。 -fn extract_sse_error_message(value: Option<&Value>) -> Option { - let value = value?; - for pointer in [ - "/error/message", - "/message", - "/response/error/message", - "/response/incomplete_details/reason", - ] { - if let Some(message) = value - .pointer(pointer) - .and_then(Value::as_str) - .map(str::trim) - .filter(|message| !message.is_empty()) - { - return Some(message.to_string()); - } - } - - Some(value.to_string()) -} - -fn summarize_upstream_body(body: &str) -> String { - if let Ok(json_body) = serde_json::from_str::(body) { - if let Some(message) = extract_json_error_message(&json_body) { - return summarize_text_for_log(&message, 180); - } - - if let Ok(compact_json) = serde_json::to_string(&json_body) { - return summarize_text_for_log(&compact_json, 180); - } - } - - summarize_text_for_log(body, 180) -} - -fn extract_json_error_message(body: &Value) -> Option { - let candidates = [ - body.pointer("/error/message"), - body.pointer("/message"), - body.pointer("/detail"), - body.pointer("/error"), - ]; - - candidates - .into_iter() - .flatten() - .find_map(|value| value.as_str().map(ToString::to_string)) -} - -fn split_endpoint_and_query(endpoint: &str) -> (&str, Option<&str>) { - endpoint - .split_once('?') - .map_or((endpoint, None), |(path, query)| (path, Some(query))) -} - -fn strip_beta_query(query: Option<&str>) -> Option { - let filtered = query.map(|query| { - query - .split('&') - .filter(|pair| !pair.is_empty() && !pair.starts_with("beta=")) - .collect::>() - .join("&") - }); - - match filtered.as_deref() { - Some("") | None => None, - Some(_) => filtered, - } -} - -fn is_claude_messages_path(path: &str) -> bool { - matches!(path, "/v1/messages" | "/claude/v1/messages") -} - -fn rewrite_codex_responses_endpoint_to_chat(endpoint: &str) -> (String, Option) { - let (_path, query) = split_endpoint_and_query(endpoint); - let passthrough_query = query.map(ToString::to_string); - let target_path = "/chat/completions"; - let rewritten = match passthrough_query.as_deref() { - Some(query) if !query.is_empty() => format!("{target_path}?{query}"), - _ => target_path.to_string(), - }; - - (rewritten, passthrough_query) -} - -fn rewrite_codex_responses_endpoint_to_messages(endpoint: &str) -> (String, Option) { - let (_path, query) = split_endpoint_and_query(endpoint); - let passthrough_query = query.map(ToString::to_string); - let target_path = "/v1/messages"; - let rewritten = match passthrough_query.as_deref() { - Some(query) if !query.is_empty() => format!("{target_path}?{query}"), - _ => target_path.to_string(), - }; - - (rewritten, passthrough_query) -} - -fn rewrite_claude_transform_endpoint( - endpoint: &str, - api_format: &str, - is_copilot: bool, - body: &Value, -) -> (String, Option) { - let (path, query) = split_endpoint_and_query(endpoint); - let passthrough_query = if is_claude_messages_path(path) { - strip_beta_query(query) - } else { - query.map(ToString::to_string) - }; - - if !is_claude_messages_path(path) { - return (endpoint.to_string(), passthrough_query); - } - - if api_format == "gemini_native" { - let model = - super::providers::transform_gemini::extract_gemini_model(body).unwrap_or("unknown"); - // Accept both bare ids (`gemini-2.5-pro`) and the resource-name - // form (`models/gemini-2.5-pro`) that Gemini SDKs emit. See - // `normalize_gemini_model_id` for rationale. - let model = super::gemini_url::normalize_gemini_model_id(model); - let is_stream = body - .get("stream") - .and_then(|value| value.as_bool()) - .unwrap_or(false); - let target_path = if is_stream { - format!("/v1beta/models/{model}:streamGenerateContent") - } else { - format!("/v1beta/models/{model}:generateContent") - }; - - let rewritten_query = merge_query_params( - passthrough_query.as_deref(), - if is_stream { Some("alt=sse") } else { None }, - ); - - let rewritten = match rewritten_query.as_deref() { - Some(query) if !query.is_empty() => format!("{target_path}?{query}"), - _ => target_path, - }; - - return (rewritten, rewritten_query); - } - - let target_path = if is_copilot && api_format == "openai_responses" { - "/v1/responses" - } else if is_copilot { - "/chat/completions" - } else if api_format == "openai_responses" { - "/v1/responses" - } else { - "/v1/chat/completions" - }; - - let rewritten = match passthrough_query.as_deref() { - Some(query) if !query.is_empty() => format!("{target_path}?{query}"), - _ => target_path.to_string(), - }; - - (rewritten, passthrough_query) -} - -fn merge_query_params(base_query: Option<&str>, extra_param: Option<&str>) -> Option { - let mut params: Vec = base_query - .into_iter() - .flat_map(|query| query.split('&')) - .filter(|pair| !pair.is_empty()) - .filter(|pair| !pair.starts_with("alt=")) - .map(ToString::to_string) - .collect(); - - if let Some(extra_param) = extra_param { - params.push(extra_param.to_string()); - } - - if params.is_empty() { - None - } else { - Some(params.join("&")) - } -} - -fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String { - match query { - Some(query) if !query.is_empty() => { - if base_url.contains('?') { - format!("{base_url}&{query}") - } else { - format!("{base_url}?{query}") - } - } - _ => base_url.to_string(), - } -} - -fn build_codex_oauth_session_headers( - session_id: &str, -) -> Vec<(http::HeaderName, http::HeaderValue)> { - let session_id = session_id.trim(); - if session_id.is_empty() { - return Vec::new(); - } - - let mut headers = Vec::new(); - if let Ok(value) = http::HeaderValue::from_str(session_id) { - headers.push((http::HeaderName::from_static("session-id"), value.clone())); - headers.push((http::HeaderName::from_static("thread-id"), value.clone())); - headers.push((http::HeaderName::from_static("x-client-request-id"), value)); - } - - let window_id = format!("{session_id}:0"); - if let Ok(value) = http::HeaderValue::from_str(&window_id) { - headers.push((http::HeaderName::from_static("x-codex-window-id"), value)); - } - - headers -} - -fn reject_proxy_placeholder_for_managed_account_upstream( - url: &str, - headers: &http::HeaderMap, -) -> Result<(), ProxyError> { - if !is_managed_account_upstream_url(url) || !headers_contain_proxy_placeholder(headers) { - return Ok(()); - } - - Err(ProxyError::AuthError( - "Managed account proxy auth was not resolved; PROXY_MANAGED must not be sent upstream" - .to_string(), - )) -} - -fn is_managed_account_upstream_url(url: &str) -> bool { - let Ok(uri) = url.parse::() else { - return false; - }; - - let Some(host) = uri.host().map(str::to_ascii_lowercase) else { - return false; - }; - - host == "githubcopilot.com" - || host.ends_with(".githubcopilot.com") - || (host == "chatgpt.com" && uri.path().starts_with("/backend-api/codex")) -} - -/// 判断某个 Codex 客户端私有头是否应该在转发到上游前移除。 -/// -/// 这个策略只处理 CCSwitchMulti 作为 Codex 本地代理时的上游边界: -/// - 非 Codex app 流量不处理,避免误删其它客户端自定义 header; -/// - 托管 ChatGPT Codex OAuth 是官方后端协议路径,保留内部协商头; -/// - 第三方 OpenAI-compatible / MultiRouter 目标不承诺支持官方私有头,默认剥离。 -fn should_retry_without_codex_responses_lite_header( - app_type: &AppType, - headers: &http::HeaderMap, - status: u16, - body: Option<&str>, -) -> bool { - matches!(app_type, AppType::Codex) - && matches!(status, 400 | 404 | 422 | 501) - && headers.contains_key(http::HeaderName::from_static( - "x-openai-internal-codex-responses-lite", - )) - && body - .map(|body| { - body.contains( - "This model is not supported when using X-OpenAI-Internal-Codex-Responses-Lite", - ) - }) - .unwrap_or(false) -} - -/// 生成 Codex Responses-Lite fallback 的能力缓存 key。 -/// -/// 参数: -/// - `provider_id`: 已解析后的 effective provider id,避免不同上游互相污染。 -/// - `url`: 实际请求 URL,只保留 scheme/host/port/path,忽略 query 中可能出现的敏感参数。 -/// - `model`: 实际请求模型;Lite 支持通常是模型维度能力,不能只按 provider 缓存。 -/// 返回: -/// - 稳定字符串 key,用于短期负缓存。 -fn codex_responses_lite_fallback_key(provider_id: &str, url: &str, model: &str) -> String { - let upstream_scope = url - .parse::() - .ok() - .and_then(|uri| { - let scheme = uri.scheme_str().unwrap_or("http").to_ascii_lowercase(); - let host = uri.host()?.to_ascii_lowercase(); - let port = uri - .port_u16() - .map(|port| format!(":{port}")) - .unwrap_or_default(); - Some(format!("{scheme}://{host}{port}{}", uri.path())) - }) - .unwrap_or_else(|| url.trim().to_ascii_lowercase()); - format!( - "{}|{}|{}", - provider_id.trim(), - upstream_scope, - model.trim().to_ascii_lowercase() - ) -} - -/// 判断 fallback 负缓存条目在指定时间点是否有效。 -/// -/// 副作用: -/// - 过期条目会被删除,避免缓存随着模型/上游组合不断增长。 -fn codex_responses_lite_fallback_active_at( - fallbacks: &mut HashMap, - key: &str, - now: Instant, -) -> bool { - match fallbacks.get(key).copied() { - Some(expires_at) if expires_at > now => true, - Some(_) => { - fallbacks.remove(key); - false - } - None => false, - } -} - -/// 发送一次上游请求,不做业务级重试。 -/// -/// 调用方负责决定是否根据错误体重放请求;这里只封装 reqwest/hyper 两条传输路径, -/// 避免 Responses-Lite fallback 和常规发送逻辑出现分叉。 -#[allow(clippy::too_many_arguments)] -async fn send_forwarder_upstream_request( - method: http::Method, - url: String, - headers: http::HeaderMap, - extensions: Extensions, - body_bytes: Vec, - timeout: std::time::Duration, - request_is_streaming: bool, - non_streaming_timeout: std::time::Duration, - streaming_first_byte_timeout: std::time::Duration, - is_socks_proxy: bool, - preserve_exact_header_case: bool, - upstream_proxy_url: Option<&str>, -) -> Result { - if is_socks_proxy || !preserve_exact_header_case { - log::debug!( - "[Forwarder] Using pooled reqwest client (preserve_exact_header_case={preserve_exact_header_case}, socks_proxy={is_socks_proxy})" - ); - let client = super::http_client::get(); - let mut request = client.request(method.clone(), &url); - if request_is_streaming { - request = request.timeout(std::time::Duration::from_secs(24 * 60 * 60)); - } else if !non_streaming_timeout.is_zero() { - request = request.timeout(non_streaming_timeout); - } - for (key, value) in &headers { - request = request.header(key, value); - } - let send = request.body(body_bytes).send(); - let send_result = if request_is_streaming { - let header_timeout = if streaming_first_byte_timeout.is_zero() { - timeout - } else { - streaming_first_byte_timeout - }; - match tokio::time::timeout(header_timeout, send).await { - Ok(result) => result, - Err(_) => { - return Err(ProxyError::Timeout(format!( - "流式响应首包超时: {}s(上游未返回响应头)", - header_timeout.as_secs() - ))); - } - } - } else { - send.await - }; - return send_result - .map(ProxyResponse::Reqwest) - .map_err(map_reqwest_send_error); - } - - let uri: http::Uri = url - .parse() - .map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?; - super::hyper_client::send_request( - uri, - method, - headers, - extensions, - body_bytes, - timeout, - upstream_proxy_url, - ) - .await -} - -/// 读取并解压上游错误响应体,保留可读错误摘要给日志、fallback 判断和客户端。 -async fn read_decoded_error_body(response: ProxyResponse) -> Result, ProxyError> { - let encoding = get_content_encoding(response.headers()); - let raw = response.bytes().await?; - let decoded = match encoding { - Some(encoding) => match decompress_body(&encoding, &raw) { - Ok(Some(decompressed)) => decompressed, - _ => raw.to_vec(), - }, - None => raw.to_vec(), - }; - Ok(String::from_utf8(decoded).ok()) -} - -/// 记录上游错误响应。body 只进入摘要,避免把完整 prompt 或大响应写入日志。 -fn append_upstream_error_event( - trace_id: &str, - session_id: &str, - request_model: &str, - provider_id: &str, - status: u16, - body_text: Option<&str>, - request_shape: Option<&str>, -) { - let mut fields = vec![ - ("trace", trace_id.to_string()), - ("session", session_id.to_string()), - ("model", request_model.to_string()), - ("provider", provider_id.to_string()), - ("status", status.to_string()), - ( - "body_summary", - body_text - .map(summarize_upstream_body) - .unwrap_or_else(|| "".to_string()), - ), - ]; - if let Some(shape) = request_shape { - fields.push(("request_shape", shape.to_string())); - } - super::codex_router_log::append_event("upstream_error", &fields); -} - -/// 识别会触发上游 Responses-Lite 分支的 Codex 内部请求头。 -#[allow(dead_code)] -fn is_codex_responses_lite_header(name: &http::HeaderName) -> bool { - name.as_str() - .eq_ignore_ascii_case("x-openai-internal-codex-responses-lite") -} - -fn headers_contain_proxy_placeholder(headers: &http::HeaderMap) -> bool { - headers.values().any(|value| { - value - .to_str() - .map(|value| value.contains(PROXY_AUTH_PLACEHOLDER)) - .unwrap_or(false) - }) -} - -fn should_preserve_exact_header_case( - adapter_name: &str, - provider: &Provider, - resolved_claude_api_format: Option<&str>, - is_copilot: bool, -) -> bool { - if matches!(adapter_name, "Codex" | "Gemini") { - return false; - } - - if is_copilot || provider.is_codex_oauth() { - return false; - } - - matches!(resolved_claude_api_format, None | Some("anthropic")) -} - -/// 判断本次请求是否是 ChatGPT Codex 官方后端的 Responses 透传路径。 -/// -/// 参数: -/// - `app_type`: 当前客户端应用类型,只有 Codex Desktop/CLI 请求需要该兼容层。 -/// - `provider`: 已经由 MultiRouter 解析后的 effective provider。 -/// - `url`: forwarder 最终要访问的上游 URL。 -/// - `needs_transform`: 是否已经走了 Claude/Anthropic 转换管线。 -/// - `codex_responses_to_chat`: 是否已经被改写到 Chat Completions 上游。 -/// - `codex_responses_to_messages`: 是否已经被改写到 Messages 上游。 -/// 返回: -/// - `true` 表示需要在透传前补齐 ChatGPT Codex backend 的必填字段。 -/// 副作用: -/// - 无。该函数只读入参,用来把修复范围限制在 official managed Codex OAuth。 -fn should_normalize_codex_oauth_responses_passthrough_body( - app_type: &AppType, - provider: &Provider, - url: &str, - needs_transform: bool, - codex_responses_to_chat: bool, - codex_responses_to_messages: bool, -) -> bool { - matches!(app_type, AppType::Codex) - && provider.is_codex_oauth() - && !needs_transform - && !codex_responses_to_chat - && !codex_responses_to_messages - && is_chatgpt_codex_responses_upstream_url(url) -} - -/// 判断是否需要规整第三方 Responses 透传请求中的 Codex 控制消息。 -/// -/// 参数: -/// - `app_type`: 当前客户端应用类型,只有 Codex 的 Responses 历史会携带这类角色。 -/// - `provider`: 已经由 MultiRouter 解析后的 effective provider。 -/// - `endpoint`: 本地代理收到的 endpoint。 -/// - `needs_transform`: 是否已进入其它格式转换管线。 -/// - `codex_responses_to_chat`: 是否已转成 Chat Completions。 -/// - `codex_responses_to_messages`: 是否已转成 Messages。 -/// 返回: -/// - `true` 表示该请求会原生透传到第三方 Responses API,需要把 developer/system -/// input item 提升到 instructions。 -/// 副作用: -/// - 无。 -fn should_normalize_codex_responses_passthrough_control_messages( - app_type: &AppType, - provider: &Provider, - endpoint: &str, - needs_transform: bool, - codex_responses_to_chat: bool, - codex_responses_to_messages: bool, -) -> bool { - matches!(app_type, AppType::Codex) - && !provider.is_codex_oauth() - && !needs_transform - && !codex_responses_to_chat - && !codex_responses_to_messages - && super::providers::is_codex_responses_endpoint(endpoint) -} - -/// 判断 URL 是否指向 ChatGPT 的 Codex Responses backend。 -/// -/// 参数: -/// - `url`: 已拼接完成的上游 URL。 -/// 返回: -/// - `true` 表示 host/path 是 `chatgpt.com/backend-api/codex/responses` 系列。 -/// 副作用: -/// - 无。解析失败时保守返回 `false`,避免影响普通 OpenAI/兼容厂商。 -fn is_chatgpt_codex_responses_upstream_url(url: &str) -> bool { - let Ok(uri) = url.parse::() else { - return false; - }; - - let Some(host) = uri.host().map(str::to_ascii_lowercase) else { - return false; - }; - if host != "chatgpt.com" { - return false; - } - - matches!( - uri.path().trim_end_matches('/'), - "/backend-api/codex/responses" | "/backend-api/codex/responses/compact" - ) -} - -fn is_streaming_request(endpoint: &str, body: &Value, headers: &axum::http::HeaderMap) -> bool { - if body - .get("stream") - .and_then(|value| value.as_bool()) - .unwrap_or(false) - { - return true; - } - - if endpoint.contains("streamGenerateContent") || endpoint.contains("alt=sse") { - return true; - } - - headers - .get(axum::http::header::ACCEPT) - .and_then(|value| value.to_str().ok()) - .map(|accept| accept.contains("text/event-stream")) - .unwrap_or(false) -} - -#[cfg(test)] -fn should_force_identity_encoding( - endpoint: &str, - body: &Value, - headers: &axum::http::HeaderMap, -) -> bool { - is_streaming_request(endpoint, body, headers) -} - -fn map_reqwest_send_error(error: reqwest::Error) -> ProxyError { - if error.is_timeout() { - ProxyError::Timeout(format!("请求超时: {error}")) - } else if error.is_connect() { - ProxyError::ForwardFailed(format!("连接失败: {error}")) - } else { - ProxyError::ForwardFailed(error.to_string()) - } -} - -fn summarize_text_for_log(text: &str, max_chars: usize) -> String { - let normalized = text.split_whitespace().collect::>().join(" "); - let trimmed = normalized.trim(); - - if trimmed.chars().count() <= max_chars { - return trimmed.to_string(); - } - - let truncated: String = trimmed.chars().take(max_chars).collect(); - let truncated = truncated.trim_end(); - format!("{truncated}...") -} - -fn apply_local_proxy_body_overrides( - body: &mut Value, - overrides: &LocalProxyRequestOverrides, -) -> bool { - let Some(override_body) = overrides.body.as_ref() else { - return false; - }; - - if !override_body.is_object() { - log::warn!("[LocalProxyOverrides] Ignoring body override because it is not an object"); - return false; - } - - merge_json_override(body, override_body) -} - -fn merge_json_override(target: &mut Value, patch: &Value) -> bool { - merge_json_override_inner(target, patch, true) -} - -fn merge_json_override_inner(target: &mut Value, patch: &Value, is_top_level: bool) -> bool { - match (target, patch) { - (Value::Object(target_map), Value::Object(patch_map)) => { - let mut changed = false; - for (key, patch_value) in patch_map { - if is_top_level && key == "stream" { - log::warn!( - "[LocalProxyOverrides] Ignoring body override for protected field: stream" - ); - continue; - } - match target_map.get_mut(key) { - Some(target_value) => { - changed |= merge_json_override_inner(target_value, patch_value, false); - } - None => { - target_map.insert(key.clone(), patch_value.clone()); - changed = true; - } - } - } - changed - } - (target_value, patch_value) => { - if target_value == patch_value { - false - } else { - *target_value = patch_value.clone(); - true - } - } - } -} - -fn apply_local_proxy_header_overrides( - headers: &mut http::HeaderMap, - overrides: Option<&LocalProxyRequestOverrides>, - is_copilot: bool, -) { - if is_copilot { - return; - } - - let Some(header_overrides) = overrides.map(|overrides| &overrides.headers) else { - return; - }; - - for (raw_name, raw_value) in header_overrides { - let header_name = raw_name.trim().to_ascii_lowercase(); - if header_name.is_empty() { - log::warn!("[LocalProxyOverrides] Ignoring header override with empty name"); - continue; - } - - let Ok(name) = http::HeaderName::from_bytes(header_name.as_bytes()) else { - log::warn!("[LocalProxyOverrides] Ignoring invalid header override name: {raw_name}"); - continue; - }; - - if is_protected_local_proxy_override_header(&name) { - log::debug!( - "[LocalProxyOverrides] Ignoring protected header override: {}", - name.as_str() - ); - continue; - } - - let Ok(value) = http::HeaderValue::from_str(raw_value) else { - log::warn!( - "[LocalProxyOverrides] Ignoring invalid header override value for {}", - name.as_str() - ); - continue; - }; - - headers.insert(name, value); - } -} - -fn is_protected_local_proxy_override_header(name: &http::HeaderName) -> bool { - matches!( - name.as_str(), - "host" - | "content-length" - | "transfer-encoding" - | "connection" - | "proxy-authorization" - | "proxy-authenticate" - | "te" - | "trailer" - | "upgrade" - | "accept-encoding" - | "content-type" - | "authorization" - | "x-api-key" - | "x-goog-api-key" - | "chatgpt-account-id" - | "session_id" - | "x-client-request-id" - | "x-codex-window-id" - | "x-forwarded-host" - | "x-forwarded-port" - | "x-forwarded-proto" - | "forwarded" - | "cf-connecting-ip" - | "cf-ipcountry" - | "cf-ray" - | "cf-visitor" - | "true-client-ip" - | "fastly-client-ip" - | "x-azure-clientip" - | "x-azure-fdid" - | "x-azure-ref" - | "akamai-origin-hop" - | "x-akamai-config-log-detail" - | "x-request-id" - | "x-correlation-id" - | "x-trace-id" - | "x-amzn-trace-id" - | "x-b3-traceid" - | "x-b3-spanid" - | "x-b3-parentspanid" - | "x-b3-sampled" - | "traceparent" - | "tracestate" - ) -} - -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 { - parts.push(format!("body={}", value_for_log(body))); - } - - parts.push(format!( - "messages={}", - body.get("messages") - .and_then(Value::as_array) - .map(|values| values.len().to_string()) - .unwrap_or_else(|| "absent".to_string()) - )); - - if let Some(tools) = body.get("tools").and_then(Value::as_array) { - let mut types = tools - .iter() - .filter_map(|tool| tool.get("type").and_then(Value::as_str)) - .collect::>(); - types.sort_unstable(); - types.dedup(); - parts.push(format!("tools={}", tools.len())); - parts.push(format!("tool_types=[{}]", types.join(","))); - } else { - parts.push("tools=absent".to_string()); - } - - for key in [ - "tool_choice", - "parallel_tool_calls", - "metadata", - "service_tier", - "stream_options", - "response_format", - "reasoning_effort", - "enable_thinking", - "reasoning", - ] { - parts.push(format!( - "{key}={}", - body.get(key) - .map(value_for_shape_log) - .unwrap_or_else(|| "absent".to_string()) - )); - } - - parts.push(format!( - "thinking={}", - body.get("thinking") - .map(value_for_shape_log) - .unwrap_or_else(|| "absent".to_string()) - )); - - parts.join(";") -} - -/// 把 JSON 值压缩成不含正文内容的形态描述。 -fn value_for_shape_log(value: &Value) -> String { - match value { - Value::Object(object) => { - let mut keys = object.keys().map(String::as_str).collect::>(); - keys.sort_unstable(); - format!("object(keys=[{}])", keys.join(",")) - } - Value::Array(values) => format!("array(len={})", values.len()), - Value::Bool(_) => "bool".to_string(), - Value::Number(_) => "number".to_string(), - Value::String(_) => "string".to_string(), - Value::Null => "null".to_string(), - } -} - -/// 构建本次转发要尝试的 provider 列表。 -/// -/// Codex MultiRouter 必须保留父 provider 进入 `forward()`,再由 `forward()` 在请求上下文内解析 -/// 具体 route。提前展开成 `parent::route::*` 会让状态页、健康记录、日志 outer_provider 和后续 -/// 转换器判定丢掉父 router 身份,表现为“监听正常但没有命中当前 MultiRouter”。 -fn build_forward_attempt_providers_preserving_codex_router_context( - app_type: &AppType, - providers: &[Provider], - body: &Value, -) -> Vec { - let _ = (app_type, body); - providers.to_vec() -} - -fn log_prompt_cache_trace( - app_type: &AppType, - provider: &Provider, - endpoint: &str, - api_format: Option<&str>, - body: &Value, - session_client_provided: bool, -) { - if !log::log_enabled!(log::Level::Debug) { - return; - } - - let prompt_cache_key = body - .get("prompt_cache_key") - .and_then(|value| value.as_str()) - .map(|key| format!("present(len={})", key.len())) - .unwrap_or_else(|| "absent".to_string()); - let store = body - .get("store") - .map(value_for_log) - .unwrap_or_else(|| "absent".to_string()); - let stream = body - .get("stream") - .map(value_for_log) - .unwrap_or_else(|| "absent".to_string()); - - log::debug!( - "[CacheTrace] app={}, provider={}, endpoint={}, api_format={}, session_client_provided={}, prompt_cache_key={}, store={}, stream={}, instructions_hash={}, tools_hash={}, input_hash={}, include_hash={}, body_hash={}", - app_type.as_str(), - provider.id, - endpoint, - api_format.unwrap_or("native"), - session_client_provided, - prompt_cache_key, - store, - stream, - short_value_hash(body.get("instructions")), - short_value_hash(body.get("tools")), - short_value_hash(body.get("input")), - short_value_hash(body.get("include")), - short_value_hash(Some(body)), - ); -} - -fn value_for_log(value: &Value) -> String { - match value { - Value::Bool(value) => value.to_string(), - Value::Number(value) => value.to_string(), - Value::String(value) => value.clone(), - Value::Null => "null".to_string(), - Value::Array(values) => format!("array(len={})", values.len()), - Value::Object(values) => format!("object(len={})", values.len()), - } -} - -/// 判断 Codex provider 是否声明了本地模型路由。 -/// -/// 这个标记用于区分普通 Codex provider 与“外层 bucket router”。只有 router -/// provider 发生 route miss 时,才需要额外防止回退到自身本地代理地址。 -fn codex_provider_has_routing_config(provider: &Provider) -> bool { - provider.settings_config.get("codexRouting").is_some() - || provider.settings_config.get("codexModelRoutes").is_some() - || provider.settings_config.get("modelRoutes").is_some() -} - -/// 判断 fallback base_url 是否指向本机代理入口。 -/// -/// 这里不依赖端口固定值,因为历史配置里出现过 15721 Rust proxy 和 15722 -/// Node sidecar;只要 router provider 在 route miss 后回到本机地址,就有递归或 -/// 未运行服务导致超时的风险。 -fn codex_base_url_points_to_local_proxy(base_url: &str) -> bool { - let normalized = base_url.trim().to_ascii_lowercase(); - normalized.contains("://127.0.0.1") - || normalized.contains("://localhost") - || normalized.contains("://[::1]") -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::database::Database; - use crate::provider::LocalProxyRequestOverrides; - use axum::http::header::{HeaderValue, ACCEPT}; - use axum::http::HeaderMap; - use bytes::Bytes; - use http::StatusCode; - use serde_json::json; - - fn test_provider_with_type(provider_type: Option<&str>) -> Provider { - Provider { - id: "provider-1".to_string(), - name: "Provider 1".to_string(), - settings_config: json!({}), - website_url: None, - category: None, - created_at: None, - sort_index: None, - notes: None, - meta: provider_type.map(|value| crate::provider::ProviderMeta { - provider_type: Some(value.to_string()), - ..Default::default() - }), - icon: None, - icon_color: None, - in_failover_queue: false, - } - } - - fn test_forwarder( - non_streaming_timeout: Duration, - streaming_first_byte_timeout: Duration, - ) -> RequestForwarder { - let db = Arc::new(Database::memory().expect("memory db")); - - RequestForwarder { - router: Arc::new(ProviderRouter::new(db.clone())), - status: Arc::new(RwLock::new(ProxyStatus::default())), - current_providers: Arc::new(RwLock::new(HashMap::new())), - gemini_shadow: Arc::new(GeminiShadowStore::new()), - codex_chat_history: Arc::new(CodexChatHistoryStore::default()), - failover_manager: Arc::new(FailoverSwitchManager::new(db)), - app_handle: None, - current_provider_id_at_start: String::new(), - session_id: String::new(), - session_client_provided: false, - rectifier_config: RectifierConfig::default(), - optimizer_config: OptimizerConfig::default(), - copilot_optimizer_config: CopilotOptimizerConfig::default(), - codex_responses_lite_fallbacks: Arc::new(RwLock::new(HashMap::new())), - non_streaming_timeout, - streaming_first_byte_timeout, - max_attempts: 1, - } - } - - // 验证只有上游明确返回 Responses-Lite 不支持时,才触发剥头重试。 - #[test] - fn codex_responses_lite_error_triggers_retry_without_header() { - let header = http::HeaderName::from_static("x-openai-internal-codex-responses-lite"); - let mut headers = http::HeaderMap::new(); - headers.insert(header, http::HeaderValue::from_static("true")); - - assert!(should_retry_without_codex_responses_lite_header( - &AppType::Codex, - &headers, - 400, - Some("This model is not supported when using X-OpenAI-Internal-Codex-Responses-Lite.") - )); - } - - // 验证普通 400 不触发剥头重试,避免隐藏真实请求错误。 - #[test] - fn ordinary_upstream_error_does_not_trigger_responses_lite_retry() { - let header = http::HeaderName::from_static("x-openai-internal-codex-responses-lite"); - let mut headers = http::HeaderMap::new(); - headers.insert(header, http::HeaderValue::from_static("true")); - - assert!(!should_retry_without_codex_responses_lite_header( - &AppType::Codex, - &headers, - 400, - Some("invalid_request_error: missing required field") - )); - } - - // 验证非 Codex app 流量不应用 Codex Responses-Lite fallback。 - #[test] - fn non_codex_app_does_not_trigger_responses_lite_retry() { - let header = http::HeaderName::from_static("x-openai-internal-codex-responses-lite"); - let mut headers = http::HeaderMap::new(); - headers.insert(header, http::HeaderValue::from_static("true")); - - assert!(!should_retry_without_codex_responses_lite_header( - &AppType::Claude, - &headers, - 400, - Some("This model is not supported when using X-OpenAI-Internal-Codex-Responses-Lite.") - )); - } - - // 验证 header 名识别只匹配已知 Codex Responses-Lite 私有头。 - #[test] - fn codex_responses_lite_header_name_is_detected_precisely() { - let lite_header = http::HeaderName::from_static("x-openai-internal-codex-responses-lite"); - let custom_header = http::HeaderName::from_static("x-custom-feature"); - - assert!(is_codex_responses_lite_header(&lite_header)); - assert!(!is_codex_responses_lite_header(&custom_header)); - } - - // 验证 fallback key 按 provider、上游 path 和模型隔离,避免一个模型失败后误伤其它模型。 - #[test] - fn codex_responses_lite_fallback_key_scopes_provider_url_and_model() { - let key_a = codex_responses_lite_fallback_key( - "provider-a", - "https://api.example.com/v1/responses?token=secret", - "gpt-5.5", - ); - let key_b = codex_responses_lite_fallback_key( - "provider-a", - "https://api.example.com/v1/responses?token=other", - "gpt-5.5", - ); - let key_other_model = codex_responses_lite_fallback_key( - "provider-a", - "https://api.example.com/v1/responses", - "gpt-5.4", - ); - let key_other_provider = codex_responses_lite_fallback_key( - "provider-b", - "https://api.example.com/v1/responses", - "gpt-5.5", - ); - - assert_eq!(key_a, key_b); - assert_ne!(key_a, key_other_model); - assert_ne!(key_a, key_other_provider); - assert!(!key_a.contains("secret")); - } - - // 验证短期负缓存命中期间有效,过期后会自动删除并允许下一次重新带头探测。 - #[test] - fn codex_responses_lite_fallback_cache_expires() { - let now = Instant::now(); - let key = "provider|https://api.example.com/v1/responses|gpt-5.5".to_string(); - let mut fallbacks = HashMap::new(); - fallbacks.insert(key.clone(), now + CODEX_RESPONSES_LITE_FALLBACK_TTL); - - assert!(codex_responses_lite_fallback_active_at( - &mut fallbacks, - &key, - now + Duration::from_secs(60) - )); - assert!(!codex_responses_lite_fallback_active_at( - &mut fallbacks, - &key, - now + CODEX_RESPONSES_LITE_FALLBACK_TTL + Duration::from_secs(1) - )); - assert!(!fallbacks.contains_key(&key)); - } - - #[test] - fn codex_chat_request_shape_omits_prompt_text_and_records_field_shapes() { - let body = json!({ - "model": "glm-5.2", - "messages": [ - {"role": "user", "content": "secret prompt should not appear"} - ], - "thinking": {"type": "enabled"}, - "reasoning_effort": "max", - "stream_options": {"include_usage": true}, - "tools": [{ - "type": "function", - "function": { - "name": "read_secret", - "parameters": {"type": "object"} - } - }] - }); - - let summary = summarize_codex_chat_request_shape(&body); - - assert!(summary.contains("model")); - assert!(summary.contains("messages=1")); - assert!(summary.contains("tools=1")); - assert!(summary.contains("tool_types=[function]")); - assert!(summary.contains("thinking=object(keys=[type])")); - assert!(summary.contains("reasoning_effort=string")); - assert!(summary.contains("stream_options=object(keys=[include_usage])")); - assert!(!summary.contains("secret prompt")); - assert!(!summary.contains("read_secret")); - } - - #[test] - fn single_provider_retryable_log_uses_single_provider_code() { - let error = ProxyError::UpstreamError { - status: 429, - body: Some(r#"{"error":{"message":"rate limit exceeded"}}"#.to_string()), - }; - - let (code, message) = build_retryable_failure_log("PackyCode-response", 1, 1, &error); - - assert_eq!(code, log_fwd::SINGLE_PROVIDER_FAILED); - assert!(message.contains("Provider PackyCode-response 请求失败")); - assert!(message.contains("上游 HTTP 429")); - assert!(message.contains("rate limit exceeded")); - assert!(!message.contains("切换下一个")); - } - - #[test] - fn multi_provider_retryable_log_keeps_failover_wording() { - let error = ProxyError::Timeout("upstream timed out after 30s".to_string()); - - let (code, message) = build_retryable_failure_log("primary", 1, 3, &error); - - assert_eq!(code, log_fwd::PROVIDER_FAILED_RETRY); - assert!(message.contains("继续尝试下一个 (1/3)")); - assert!(message.contains("请求超时")); - } - - #[test] - fn single_provider_has_no_terminal_all_failed_log() { - assert!(build_terminal_failure_log(1, 1, None).is_none()); - } - - #[test] - fn multi_provider_terminal_log_contains_last_error_summary() { - let error = ProxyError::ForwardFailed("connection reset by peer".to_string()); - - let (code, message) = - build_terminal_failure_log(2, 2, Some(&error)).expect("expected terminal log"); - - assert_eq!(code, log_fwd::ALL_PROVIDERS_FAILED); - assert!(message.contains("已尝试 2/2 个 Provider,均失败")); - assert!(message.contains("connection reset by peer")); - } - - #[test] - fn summarize_upstream_body_prefers_json_message() { - let body = json!({ - "error": { - "message": "invalid_request_error: unsupported field" - }, - "request_id": "req_123" - }); - - let summary = summarize_upstream_body(&body.to_string()); - - assert_eq!(summary, "invalid_request_error: unsupported field"); - } - - #[test] - fn summarize_text_for_log_collapses_whitespace_and_truncates() { - let summary = summarize_text_for_log("line1\n\n line2 line3", 12); - - assert_eq!(summary, "line1 line2..."); - } - - #[test] - fn codex_local_router_fallback_detection_covers_known_loopback_urls() { - assert!(codex_base_url_points_to_local_proxy( - "http://127.0.0.1:15721/v1" - )); - assert!(codex_base_url_points_to_local_proxy( - "http://localhost:15722/v1" - )); - assert!(!codex_base_url_points_to_local_proxy( - "https://api.openai.com/v1" - )); - } - - #[test] - fn codex_routing_config_detection_reads_new_and_legacy_fields() { - let mut provider = test_provider_with_type(None); - assert!(!codex_provider_has_routing_config(&provider)); - - provider.settings_config = json!({ "codexRouting": { "routes": [] } }); - assert!(codex_provider_has_routing_config(&provider)); - - provider.settings_config = json!({ "modelRoutes": [] }); - assert!(codex_provider_has_routing_config(&provider)); - } - - #[test] - fn codex_multirouter_attempts_keep_parent_provider_context() { - let mut provider = test_provider_with_type(None); - provider.id = "codex-openai-router".to_string(); - provider.name = "OpenAI Multi-Model Router".to_string(); - provider.settings_config = json!({ - "codexRouting": { - "enabled": true, - "routes": [ - { - "id": "qwen-local", - "name": "Qwen Local vLLM", - "enabled": true, - "models": ["qwen3.6"], - "base_url": "https://example.test/v1", - "wireApi": "chat" - } - ] - } - }); - - let attempts = build_forward_attempt_providers_preserving_codex_router_context( - &AppType::Codex, - &[provider.clone()], - &json!({ "model": "qwen3.6" }), - ); - - assert_eq!(attempts.len(), 1); - assert_eq!(attempts[0].id, "codex-openai-router"); - assert!(attempts[0] - .settings_config - .get("codexResolvedRouteId") - .is_none()); - } - - #[test] - fn canonical_json_sorts_object_keys_for_cache_trace_hashes() { - let left = json!({ - "tools": [ - { - "parameters": { - "properties": { - "b": {"type": "string"}, - "a": {"type": "number"} - }, - "type": "object" - }, - "name": "lookup" - } - ] - }); - let right = json!({ - "tools": [ - { - "name": "lookup", - "parameters": { - "type": "object", - "properties": { - "a": {"type": "number"}, - "b": {"type": "string"} - } - } - } - ] - }); - - assert_eq!( - crate::proxy::json_canonical::canonical_json_string(&left), - crate::proxy::json_canonical::canonical_json_string(&right) - ); - assert_eq!( - short_value_hash(Some(&left)), - short_value_hash(Some(&right)) - ); - } - - #[test] - fn prepare_upstream_request_body_filters_private_fields_and_canonicalizes_order() { - let body = json!({ - "z": 1, - "_internal": "drop", - "tools": [ - { - "name": "lookup", - "parameters": { - "type": "object", - "properties": { - "_id": { - "_private_note": "drop", - "type": "string" - }, - "b": {"type": "number"}, - "a": {"type": "string"} - } - } - } - ], - "a": 2 - }); - - let prepared = prepare_upstream_request_body(body); - - assert!(prepared.get("_internal").is_none()); - assert!(prepared["tools"][0]["parameters"]["properties"] - .get("_id") - .is_some()); - assert!(prepared["tools"][0]["parameters"]["properties"]["_id"] - .get("_private_note") - .is_none()); - assert_eq!( - serde_json::to_string(&prepared).unwrap(), - r#"{"a":2,"tools":[{"name":"lookup","parameters":{"properties":{"_id":{"type":"string"},"a":{"type":"string"},"b":{"type":"number"}},"type":"object"}}],"z":1}"# - ); - } - - #[test] - fn codex_oauth_responses_passthrough_normalizer_is_scoped() { - let codex_oauth = test_provider_with_type(Some("codex_oauth")); - let regular = test_provider_with_type(None); - let official_url = "https://chatgpt.com/backend-api/codex/responses"; - - assert!(should_normalize_codex_oauth_responses_passthrough_body( - &AppType::Codex, - &codex_oauth, - official_url, - false, - false, - false - )); - assert!(!should_normalize_codex_oauth_responses_passthrough_body( - &AppType::Codex, - ®ular, - official_url, - false, - false, - false - )); - assert!(!should_normalize_codex_oauth_responses_passthrough_body( - &AppType::Codex, - &codex_oauth, - "https://api.openai.com/v1/responses", - false, - false, - false - )); - assert!(!should_normalize_codex_oauth_responses_passthrough_body( - &AppType::Claude, - &codex_oauth, - official_url, - false, - false, - false - )); - assert!(!should_normalize_codex_oauth_responses_passthrough_body( - &AppType::Codex, - &codex_oauth, - official_url, - true, - false, - false - )); - assert!(!should_normalize_codex_oauth_responses_passthrough_body( - &AppType::Codex, - &codex_oauth, - official_url, - false, - true, - false - )); - } - - #[test] - fn codex_responses_passthrough_control_message_normalizer_is_scoped() { - let codex_oauth = test_provider_with_type(Some("codex_oauth")); - let regular = test_provider_with_type(None); - - assert!( - should_normalize_codex_responses_passthrough_control_messages( - &AppType::Codex, - ®ular, - "/v1/responses", - false, - false, - false - ) - ); - assert!( - should_normalize_codex_responses_passthrough_control_messages( - &AppType::Codex, - ®ular, - "/responses/compact?conversation=1", - false, - false, - false - ) - ); - assert!( - !should_normalize_codex_responses_passthrough_control_messages( - &AppType::Codex, - &codex_oauth, - "/v1/responses", - false, - false, - false - ) - ); - assert!( - !should_normalize_codex_responses_passthrough_control_messages( - &AppType::Claude, - ®ular, - "/v1/responses", - false, - false, - false - ) - ); - assert!( - !should_normalize_codex_responses_passthrough_control_messages( - &AppType::Codex, - ®ular, - "/v1/chat/completions", - false, - false, - false - ) - ); - assert!( - !should_normalize_codex_responses_passthrough_control_messages( - &AppType::Codex, - ®ular, - "/v1/responses", - true, - false, - false - ) - ); - assert!( - !should_normalize_codex_responses_passthrough_control_messages( - &AppType::Codex, - ®ular, - "/v1/responses", - false, - true, - false - ) - ); - } - - #[test] - fn local_proxy_body_overrides_deep_merge_final_body_without_stream() { - let mut body = json!({ - "model": "before", - "stream": false, - "metadata": { - "keep": true, - "temperature": 1 - }, - "messages": [{ "role": "user", "content": "hello" }] - }); - let overrides = LocalProxyRequestOverrides { - headers: HashMap::new(), - body: Some(json!({ - "model": "after", - "stream": true, - "metadata": { - "temperature": 0.2, - "top_p": 0.9 - }, - "messages": [] - })), - }; - - assert!(apply_local_proxy_body_overrides(&mut body, &overrides)); - - assert_eq!(body["model"], "after"); - assert_eq!(body["stream"], false); - assert_eq!(body["metadata"]["keep"], true); - assert_eq!(body["metadata"]["temperature"], 0.2); - assert_eq!(body["metadata"]["top_p"], 0.9); - assert_eq!(body["messages"], json!([])); - } - - #[test] - fn local_proxy_header_overrides_replace_allowed_headers_only() { - let mut headers = http::HeaderMap::new(); - headers.insert( - http::header::USER_AGENT, - http::HeaderValue::from_static("original"), - ); - headers.insert( - http::header::AUTHORIZATION, - http::HeaderValue::from_static("Bearer good"), - ); - headers.insert( - http::header::CONTENT_TYPE, - http::HeaderValue::from_static("application/json"), - ); - - let overrides = LocalProxyRequestOverrides { - headers: HashMap::from([ - ("User-Agent".to_string(), "custom".to_string()), - ("X-Test".to_string(), "ok".to_string()), - ("Authorization".to_string(), "Bearer bad".to_string()), - ("Content-Type".to_string(), "text/plain".to_string()), - ("X-Bad".to_string(), "bad\nvalue".to_string()), - ]), - body: None, - }; - - apply_local_proxy_header_overrides(&mut headers, Some(&overrides), false); - - assert_eq!( - headers - .get(http::header::USER_AGENT) - .and_then(|value| value.to_str().ok()), - Some("custom") - ); - assert_eq!( - headers - .get(http::header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()), - Some("Bearer good") - ); - assert_eq!( - headers - .get(http::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()), - Some("application/json") - ); - assert_eq!( - headers.get("x-test").and_then(|value| value.to_str().ok()), - Some("ok") - ); - assert!(headers.get("x-bad").is_none()); - } - - #[test] - fn local_proxy_header_overrides_are_skipped_for_copilot() { - let mut headers = http::HeaderMap::new(); - headers.insert( - http::header::USER_AGENT, - http::HeaderValue::from_static("copilot"), - ); - let overrides = LocalProxyRequestOverrides { - headers: HashMap::from([("User-Agent".to_string(), "custom".to_string())]), - body: None, - }; - - apply_local_proxy_header_overrides(&mut headers, Some(&overrides), true); - - assert_eq!( - headers - .get(http::header::USER_AGENT) - .and_then(|value| value.to_str().ok()), - Some("copilot") - ); - } - - #[tokio::test] - async fn non_streaming_success_is_buffered_before_marking_provider_successful() { - let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1)); - let response = ProxyResponse::streamed( - StatusCode::OK, - HeaderMap::new(), - futures::stream::once(async { - tokio::time::sleep(Duration::from_millis(10)).await; - Ok::(Bytes::from_static(b"{\"ok\":true}")) - }), - ); - - let prepared = forwarder - .prepare_success_response_for_failover(response, false) - .await - .expect("response should be buffered"); - - assert_eq!( - prepared.bytes().await.unwrap(), - Bytes::from_static(b"{\"ok\":true}") - ); - } - - #[tokio::test] - async fn non_streaming_body_read_error_is_retryable_before_success_record() { - let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1)); - let response = ProxyResponse::streamed( - StatusCode::OK, - HeaderMap::new(), - futures::stream::once(async { - Err::(std::io::Error::other("body boom")) - }), - ); - - let err = match forwarder - .prepare_success_response_for_failover(response, false) - .await - { - Ok(_) => panic!("body read errors should fail the attempt"), - Err(err) => err, - }; - - assert!(matches!(err, ProxyError::ForwardFailed(_))); - } - - #[tokio::test] - async fn streaming_success_primes_first_chunk_and_replays_it() { - let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1)); - let response = ProxyResponse::streamed( - StatusCode::OK, - HeaderMap::new(), - futures::stream::iter(vec![ - Ok::(Bytes::from_static(b"first")), - Ok::(Bytes::from_static(b"second")), - ]), - ); - - let prepared = forwarder - .prepare_success_response_for_failover(response, true) - .await - .expect("stream should be primed"); - - assert_eq!( - prepared.bytes().await.unwrap(), - Bytes::from_static(b"firstsecond") - ); - } - - #[tokio::test] - async fn streaming_first_chunk_error_is_retryable_before_success_record() { - let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1)); - let response = ProxyResponse::streamed( - StatusCode::OK, - HeaderMap::new(), - futures::stream::once(async { - Err::(std::io::Error::other("first chunk boom")) - }), - ); - - let err = match forwarder - .prepare_success_response_for_failover(response, true) - .await - { - Ok(_) => panic!("first chunk errors should fail the attempt"), - Err(err) => err, - }; - - assert!(matches!(err, ProxyError::ForwardFailed(_))); - } - - #[tokio::test] - async fn streaming_first_sse_error_event_is_retryable_before_response_is_returned() { - let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1)); - let response = ProxyResponse::streamed( - StatusCode::OK, - HeaderMap::new(), - futures::stream::once(async { - Ok::(Bytes::from_static( - b"event: error\ndata: {\"error\":{\"message\":\"We're currently experiencing high demand\",\"type\":\"server_error\"}}\n\n", - )) - }), - ); - - let err = match forwarder - .prepare_success_response_for_failover(response, true) - .await - { - Ok(_) => panic!("first SSE error event should fail the attempt before streaming"), - Err(err) => err, - }; - - assert!(matches!( - err, - ProxyError::UpstreamError { - status: 503, - body: Some(message), - } if message.contains("high demand") - )); - } - - #[tokio::test] - async fn streaming_first_normal_sse_event_is_replayed_to_client() { - let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1)); - let response = ProxyResponse::streamed( - StatusCode::OK, - HeaderMap::new(), - futures::stream::iter(vec![ - Ok::(Bytes::from_static( - b"event: response.created\ndata: {\"type\":\"response.created\"}\n\n", - )), - Ok::(Bytes::from_static( - b"event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n", - )), - ]), - ); - - let prepared = forwarder - .prepare_success_response_for_failover(response, true) - .await - .expect("normal first SSE event should be replayed"); - - let body = prepared.bytes().await.unwrap(); - assert!(String::from_utf8_lossy(&body).contains("response.created")); - assert!(String::from_utf8_lossy(&body).contains("response.completed")); - } - - #[test] - fn codex_oauth_session_headers_match_codex_cache_identity() { - let headers = build_codex_oauth_session_headers("session-123"); - let mut map = HeaderMap::new(); - for (name, value) in headers { - map.insert(name, value); - } - - assert_eq!( - map.get("session-id"), - Some(&HeaderValue::from_static("session-123")) - ); - assert_eq!( - map.get("thread-id"), - Some(&HeaderValue::from_static("session-123")) - ); - assert_eq!( - map.get("x-client-request-id"), - Some(&HeaderValue::from_static("session-123")) - ); - assert_eq!( - map.get("x-codex-window-id"), - Some(&HeaderValue::from_static("session-123:0")) - ); - } - - #[test] - fn managed_account_upstream_rejects_proxy_managed_placeholder_header() { - let mut headers = HeaderMap::new(); - headers.insert( - "authorization", - HeaderValue::from_static("Bearer PROXY_MANAGED"), - ); - - let err = reject_proxy_placeholder_for_managed_account_upstream( - "https://api.githubcopilot.com/chat/completions", - &headers, - ) - .expect_err("placeholder should be rejected before upstream"); - - assert!(matches!( - err, - ProxyError::AuthError(message) if message.contains("PROXY_MANAGED") - )); - } - - #[test] - fn codex_oauth_upstream_rejects_proxy_managed_placeholder_header() { - let mut headers = HeaderMap::new(); - headers.insert( - "authorization", - HeaderValue::from_static("Bearer PROXY_MANAGED"), - ); - - let err = reject_proxy_placeholder_for_managed_account_upstream( - "https://chatgpt.com/backend-api/codex/responses", - &headers, - ) - .expect_err("placeholder should be rejected before upstream"); - - assert!(matches!( - err, - ProxyError::AuthError(message) if message.contains("PROXY_MANAGED") - )); - } - - #[test] - fn non_managed_upstream_allows_proxy_managed_placeholder_guard() { - let mut headers = HeaderMap::new(); - headers.insert( - "authorization", - HeaderValue::from_static("Bearer PROXY_MANAGED"), - ); - - reject_proxy_placeholder_for_managed_account_upstream( - "https://api.example.com/v1/messages", - &headers, - ) - .expect("guard is scoped to managed-account upstreams"); - } - - #[test] - fn exact_header_case_preserved_for_native_claude_only() { - let provider = test_provider_with_type(None); - - assert!(should_preserve_exact_header_case( - "Claude", - &provider, - Some("anthropic"), - false - )); - assert!(!should_preserve_exact_header_case( - "Claude", - &provider, - Some("openai_responses"), - false - )); - assert!(!should_preserve_exact_header_case( - "Codex", &provider, None, false - )); - assert!(!should_preserve_exact_header_case( - "Gemini", &provider, None, false - )); - } - - #[test] - fn exact_header_case_skipped_for_codex_oauth_and_copilot() { - let codex_oauth = test_provider_with_type(Some("codex_oauth")); - let copilot = test_provider_with_type(Some("github_copilot")); - - assert!(!should_preserve_exact_header_case( - "Claude", - &codex_oauth, - Some("openai_responses"), - false - )); - assert!(!should_preserve_exact_header_case( - "Claude", - &copilot, - Some("openai_chat"), - true - )); - } - - #[test] - fn rewrite_claude_transform_endpoint_strips_beta_for_chat_completions() { - let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( - "/v1/messages?beta=true&foo=bar", - "openai_chat", - false, - &json!({ "model": "gpt-5.4" }), - ); - - assert_eq!(endpoint, "/v1/chat/completions?foo=bar"); - assert_eq!(passthrough_query.as_deref(), Some("foo=bar")); - } - - #[test] - fn rewrite_claude_transform_endpoint_strips_beta_for_responses() { - let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( - "/claude/v1/messages?beta=true&x-id=1", - "openai_responses", - false, - &json!({ "model": "gpt-5.4" }), - ); - - assert_eq!(endpoint, "/v1/responses?x-id=1"); - assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); - } - - #[test] - fn rewrite_codex_responses_endpoint_to_chat_preserves_query() { - let (endpoint, passthrough_query) = - rewrite_codex_responses_endpoint_to_chat("/v1/responses?foo=bar"); - - assert_eq!(endpoint, "/chat/completions?foo=bar"); - assert_eq!(passthrough_query.as_deref(), Some("foo=bar")); - } - - #[test] - fn rewrite_codex_responses_compact_endpoint_to_chat_preserves_query() { - let (endpoint, passthrough_query) = - rewrite_codex_responses_endpoint_to_chat("/v1/responses/compact?foo=bar"); - - assert_eq!(endpoint, "/chat/completions?foo=bar"); - assert_eq!(passthrough_query.as_deref(), Some("foo=bar")); - } - - #[test] - fn rewrite_claude_transform_endpoint_uses_copilot_path() { - let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( - "/v1/messages?beta=true&x-id=1", - "anthropic", - true, - &json!({ "model": "claude-sonnet-4-6" }), - ); - - assert_eq!(endpoint, "/chat/completions?x-id=1"); - assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); - } - - #[test] - fn rewrite_claude_transform_endpoint_uses_copilot_responses_path() { - let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( - "/v1/messages?beta=true&x-id=1", - "openai_responses", - true, - &json!({ "model": "gpt-5.4" }), - ); - - assert_eq!(endpoint, "/v1/responses?x-id=1"); - assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); - } - - #[test] - fn rewrite_claude_transform_endpoint_maps_gemini_generate_content() { - let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( - "/v1/messages?beta=true&x-id=1", - "gemini_native", - false, - &json!({ "model": "gemini-2.5-pro" }), - ); - - assert_eq!( - endpoint, - "/v1beta/models/gemini-2.5-pro:generateContent?x-id=1" - ); - assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); - } - - /// Regression: body.model arriving as the resource-name form - /// `models/gemini-2.5-pro` must not produce a doubled - /// `/v1beta/models/models/...` path. - #[test] - fn rewrite_claude_transform_endpoint_strips_gemini_model_resource_prefix() { - let (endpoint, _) = rewrite_claude_transform_endpoint( - "/v1/messages", - "gemini_native", - false, - &json!({ "model": "models/gemini-2.5-pro" }), - ); - - assert_eq!(endpoint, "/v1beta/models/gemini-2.5-pro:generateContent"); - } - - #[test] - fn rewrite_claude_transform_endpoint_maps_gemini_streaming() { - let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( - "/v1/messages?beta=true", - "gemini_native", - false, - &json!({ "model": "gemini-2.5-flash", "stream": true }), - ); - - assert_eq!( - endpoint, - "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" - ); - assert_eq!(passthrough_query.as_deref(), Some("alt=sse")); - } - - #[test] - fn append_query_to_full_url_preserves_existing_query_string() { - let url = append_query_to_full_url("https://relay.example/api?foo=bar", Some("x-id=1")); - - assert_eq!(url, "https://relay.example/api?foo=bar&x-id=1"); - } - - #[test] - fn build_gemini_native_url_uses_origin_when_base_ends_with_v1beta() { - let url = crate::proxy::gemini_url::build_gemini_native_url( - "https://generativelanguage.googleapis.com/v1beta", - "/v1beta/models/gemini-2.5-pro:generateContent", - ); - - assert_eq!( - url, - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent" - ); - } - - #[test] - fn build_gemini_native_url_uses_origin_when_base_already_contains_models_prefix() { - let url = crate::proxy::gemini_url::build_gemini_native_url( - "https://generativelanguage.googleapis.com/v1beta/models", - "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", - ); - - assert_eq!( - url, - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" - ); - } - - #[test] - fn resolve_gemini_native_url_keeps_opaque_full_url_as_is() { - let url = crate::proxy::gemini_url::resolve_gemini_native_url( - "https://relay.example/custom/generate-content", - "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", - true, - ); - - assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse"); - } - - #[test] - fn force_identity_for_stream_flag_requests() { - let headers = HeaderMap::new(); - - assert!(should_force_identity_encoding( - "/v1/responses", - &json!({ "stream": true }), - &headers - )); - } - - #[test] - fn force_identity_for_gemini_stream_endpoints() { - let headers = HeaderMap::new(); - - assert!(should_force_identity_encoding( - "/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse", - &json!({ "model": "gemini-2.5-pro" }), - &headers - )); - } - - #[test] - fn streaming_request_detects_gemini_sse_without_body_stream_flag() { - let headers = HeaderMap::new(); - - assert!(is_streaming_request( - "/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse", - &json!({ "model": "gemini-2.5-pro" }), - &headers - )); - } - - #[test] - fn force_identity_for_sse_accept_header() { - let mut headers = HeaderMap::new(); - headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream")); - - assert!(should_force_identity_encoding( - "/v1/responses", - &json!({ "model": "gpt-5" }), - &headers - )); - } - - #[test] - fn non_streaming_requests_allow_automatic_compression() { - let headers = HeaderMap::new(); - - assert!(!should_force_identity_encoding( - "/v1/responses", - &json!({ "model": "gpt-5" }), - &headers - )); - } - - // ==================== Copilot 动态 endpoint 路由相关测试 ==================== - - /// 验证 is_copilot 检测逻辑:通过 provider_type 判断 - #[test] - fn copilot_detection_via_provider_type() { - use crate::provider::{Provider, ProviderMeta}; - - let provider = Provider { - id: "test".to_string(), - name: "Test Copilot".to_string(), - settings_config: serde_json::json!({}), - website_url: None, - category: None, - created_at: None, - sort_index: None, - notes: None, - meta: Some(ProviderMeta { - provider_type: Some("github_copilot".to_string()), - ..Default::default() - }), - icon: None, - icon_color: None, - in_failover_queue: false, - }; - - let is_copilot = provider - .meta - .as_ref() - .and_then(|m| m.provider_type.as_deref()) - == Some("github_copilot"); - - assert!(is_copilot, "应该通过 provider_type 检测为 Copilot"); - } - - /// 验证 is_copilot 检测逻辑:通过 base_url 判断 - #[test] - fn copilot_detection_via_base_url() { - let base_url = "https://api.githubcopilot.com"; - let is_copilot = base_url.contains("githubcopilot.com"); - assert!(is_copilot, "应该通过 base_url 检测为 Copilot"); - - let non_copilot_url = "https://api.anthropic.com"; - let is_not_copilot = non_copilot_url.contains("githubcopilot.com"); - assert!(!is_not_copilot, "非 Copilot URL 不应被检测为 Copilot"); - } - - /// 验证企业版 endpoint(不包含 githubcopilot.com)场景下 is_copilot 仍然正确 - #[test] - fn copilot_detection_for_enterprise_endpoint() { - use crate::provider::{Provider, ProviderMeta}; - - // 企业版场景:provider_type 是 github_copilot,但 base_url 可能是企业内部域名 - let provider = Provider { - id: "enterprise".to_string(), - name: "Enterprise Copilot".to_string(), - settings_config: serde_json::json!({}), - website_url: None, - category: None, - created_at: None, - sort_index: None, - notes: None, - meta: Some(ProviderMeta { - provider_type: Some("github_copilot".to_string()), - ..Default::default() - }), - icon: None, - icon_color: None, - in_failover_queue: false, - }; - - let enterprise_base_url = "https://copilot-api.corp.example.com"; - - // is_copilot 应该通过 provider_type 检测成功,即使 base_url 不包含 githubcopilot.com - let is_copilot = provider - .meta - .as_ref() - .and_then(|m| m.provider_type.as_deref()) - == Some("github_copilot") - || enterprise_base_url.contains("githubcopilot.com"); - - assert!( - is_copilot, - "企业版 Copilot 应该通过 provider_type 被正确检测" - ); - } - - /// 验证动态 endpoint 替换条件 - #[test] - fn dynamic_endpoint_replacement_conditions() { - // 条件:is_copilot && !is_full_url - let test_cases = [ - (true, false, true, "Copilot + 非 full_url 应该替换"), - (true, true, false, "Copilot + full_url 不应替换"), - (false, false, false, "非 Copilot 不应替换"), - (false, true, false, "非 Copilot + full_url 不应替换"), - ]; - - for (is_copilot, is_full_url, should_replace, desc) in test_cases { - let will_replace = is_copilot && !is_full_url; - assert_eq!(will_replace, should_replace, "{desc}"); - } - } - - // ===== P3: forwarder 层 media 开关回归测试 ===== - // 验证 gate 在 forwarder 这一层的"接线",而非 media_sanitizer 纯函数本身。 - - fn forwarder_with_rectifier(config: RectifierConfig) -> RequestForwarder { - let mut fwd = test_forwarder(Duration::from_secs(1), Duration::from_secs(1)); - fwd.rectifier_config = config; - fwd - } - - fn provider_with_settings(settings_config: Value) -> Provider { - let mut p = test_provider_with_type(Some("anthropic")); - p.settings_config = settings_config; - p - } - - fn body_with_image(model: &str) -> Value { - json!({ - "model": model, - "messages": [{ - "role": "user", - "content": [ - { "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } } - ] - }] - }) - } - - fn body_with_codex_input_image(model: &str) -> Value { - json!({ - "model": model, - "input": [{ - "role": "user", - "content": [ - { "type": "input_image", "image_url": "data:image/png;base64,abc" } - ] - }] - }) - } - - fn image_unsupported_error() -> ProxyError { - ProxyError::UpstreamError { - status: 400, - body: Some( - r#"{"error":{"message":"This model does not support image input"}}"#.to_string(), - ), - } - } - - fn minimax_sensitive_image_error() -> ProxyError { - ProxyError::UpstreamError { - status: 400, - body: Some( - r#"{"base_resp":{"status_code":1026,"status_msg":"input new_sensitive, messages[61]'s content[0] image is sensitive, please check your input"}}"# - .to_string(), - ), - } - } - #[test] - fn prevention_replaces_when_all_switches_on_and_model_in_heuristic_list() { - let fwd = forwarder_with_rectifier(RectifierConfig::default()); - let provider = provider_with_settings(json!({})); - let mut body = body_with_image("deepseek-v4-pro"); - - let replaced = fwd.apply_media_prevention(&mut body, &provider); - - assert_eq!(replaced, 1, "默认全开 + 名单内模型应预替换"); - assert_eq!(body["messages"][0]["content"][0]["type"], "text"); - } - - #[test] - fn prevention_skipped_when_media_fallback_off() { - // 关闭 request_media_fallback:即使名单命中也不预替换。 - let fwd = forwarder_with_rectifier(RectifierConfig { - request_media_fallback: false, - ..RectifierConfig::default() - }); - let provider = provider_with_settings(json!({})); - let mut body = body_with_image("deepseek-v4-pro"); - - let replaced = fwd.apply_media_prevention(&mut body, &provider); - - assert_eq!(replaced, 0); - assert_eq!(body["messages"][0]["content"][0]["type"], "image"); - } - - #[test] - fn prevention_skipped_when_master_switch_off() { - let fwd = forwarder_with_rectifier(RectifierConfig { - enabled: false, - ..RectifierConfig::default() - }); - let provider = provider_with_settings(json!({})); - let mut body = body_with_image("deepseek-v4-pro"); - - assert_eq!(fwd.apply_media_prevention(&mut body, &provider), 0); - assert_eq!(body["messages"][0]["content"][0]["type"], "image"); - } - - #[test] - fn prevention_heuristic_off_skips_list_but_keeps_explicit_text_only() { - // 关闭 request_media_heuristic:名单预测失效,但显式声明 text-only 仍预替换。 - let fwd = forwarder_with_rectifier(RectifierConfig { - request_media_heuristic: false, - ..RectifierConfig::default() - }); - - // (a) 名单内模型、无显式声明 → 不再预替换 - let bare_provider = provider_with_settings(json!({})); - let mut list_body = body_with_image("deepseek-v4-pro"); - assert_eq!( - fwd.apply_media_prevention(&mut list_body, &bare_provider), - 0, - "heuristic 关闭后名单模型不应被预替换" - ); - assert_eq!(list_body["messages"][0]["content"][0]["type"], "image"); - - // (b) 显式声明 text-only → 仍预替换(声明驱动,不受 heuristic 开关影响) - let declared_provider = provider_with_settings(json!({ - "models": [ { "id": "some-text-model", "input": ["text"] } ] - })); - let mut declared_body = body_with_image("some-text-model"); - assert_eq!( - fwd.apply_media_prevention(&mut declared_body, &declared_provider), - 1, - "显式 text-only 即使关闭 heuristic 也应预替换" - ); - assert_eq!(declared_body["messages"][0]["content"][0]["type"], "text"); - } - - #[test] - fn reactive_triggers_when_all_switches_on() { - let fwd = forwarder_with_rectifier(RectifierConfig::default()); - let body = body_with_image("any-model"); - assert!(fwd.media_retry_should_trigger("Claude", false, &body, &image_unsupported_error())); - } - - #[test] - fn reactive_triggers_for_codex_image_url_deserialize_errors() { - let fwd = forwarder_with_rectifier(RectifierConfig::default()); - let body = body_with_codex_input_image("deepseek-v4-flash"); - let error = ProxyError::UpstreamError { - status: 400, - body: Some( - r#"{"error":{"message":"Failed to deserialize the JSON body into the target type: messages[11]: unknown variant image_url, expected text"}}"# - .to_string(), - ), - }; - - assert!(fwd.media_retry_should_trigger("Codex", false, &body, &error)); - } - - #[test] - fn reactive_triggers_for_codex_sensitive_image_errors() { - let fwd = forwarder_with_rectifier(RectifierConfig::default()); - let body = body_with_codex_input_image("MiniMax-M3"); - - assert!(fwd.media_retry_should_trigger( - "Codex", - false, - &body, - &minimax_sensitive_image_error() - )); - } - - #[test] - fn reactive_sensitive_image_error_still_requires_image_body() { - let fwd = forwarder_with_rectifier(RectifierConfig::default()); - let body = json!({ - "model": "MiniMax-M3", - "input": [{ - "role": "user", - "content": [{ "type": "input_text", "text": "hello" }] - }] - }); - - assert!(!fwd.media_retry_should_trigger( - "Codex", - false, - &body, - &minimax_sensitive_image_error() - )); - } - - #[test] - fn reactive_skipped_when_media_fallback_off() { - // 关闭 request_media_fallback:上游报图片错误也不触发兜底重试。 - let fwd = forwarder_with_rectifier(RectifierConfig { - request_media_fallback: false, - ..RectifierConfig::default() - }); - let body = body_with_image("any-model"); - assert!(!fwd.media_retry_should_trigger( - "Claude", - false, - &body, - &image_unsupported_error() - )); - } - - #[test] - fn reactive_skipped_when_master_switch_off() { - let fwd = forwarder_with_rectifier(RectifierConfig { - enabled: false, - ..RectifierConfig::default() - }); - let body = body_with_image("any-model"); - assert!(!fwd.media_retry_should_trigger( - "Claude", - false, - &body, - &image_unsupported_error() - )); - } - - #[test] - fn reactive_unaffected_by_heuristic_switch() { - // 关闭 request_media_heuristic 不影响反应式兜底——它是上游实测错误后的恢复,不是预测。 - let fwd = forwarder_with_rectifier(RectifierConfig { - request_media_heuristic: false, - ..RectifierConfig::default() - }); - let body = body_with_image("any-model"); - assert!(fwd.media_retry_should_trigger("Claude", false, &body, &image_unsupported_error())); - } -} From 2cf967f3c8bb85592d5d193a9c765767ed0858cf Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 10 Jul 2026 09:17:24 +0800 Subject: [PATCH 05/13] chore: trim readonly MCP tool descriptions --- src-tauri/src/bin/ccswitch_readonly_mcp.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/bin/ccswitch_readonly_mcp.rs b/src-tauri/src/bin/ccswitch_readonly_mcp.rs index 06284e59cc..d8133dc3ca 100644 --- a/src-tauri/src/bin/ccswitch_readonly_mcp.rs +++ b/src-tauri/src/bin/ccswitch_readonly_mcp.rs @@ -164,12 +164,10 @@ impl ServerHandler for ReadonlyProjectServer { Ok(ListResourceTemplatesResult { resource_templates: vec![ ResourceTemplate::new("ccswitch://project/tree/{path}", "project-subtree") - .with_description( - "Read a shallow read-only subtree under CCSWITCH_READONLY_ROOT", - ) + .with_description("Read a shallow project subtree") .with_mime_type("text/plain"), ResourceTemplate::new("ccswitch://project/file/{path}", "project-file") - .with_description("Read a UTF-8 text file under CCSWITCH_READONLY_ROOT") + .with_description("Read a UTF-8 project file") .with_mime_type("text/plain"), ], next_cursor: None, From 5b3e54f6d16ff0990785fb0a38a61718f2559267 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 10 Jul 2026 09:18:57 +0800 Subject: [PATCH 06/13] chore: remove unused Claude chat mode metadata --- src-tauri/src/provider.rs | 3 --- src/types.ts | 2 -- 2 files changed, 5 deletions(-) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 6b7bcdc314..af314f2e40 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -517,9 +517,6 @@ pub struct ProviderMeta { /// - "openai_responses": OpenAI Responses API 格式,需要转换 #[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")] pub api_format: Option, - /// Codex 上游为 Claude 时,将 claude-sonnet-5 清洗为普通聊天请求 - #[serde(rename = "claudeChatMode", skip_serializing_if = "Option::is_none")] - pub claude_chat_mode: Option, /// 通用认证绑定(provider_config / managed_account) /// /// 新代码应只写入该字段;githubAccountId 仅保留兼容读取。 diff --git a/src/types.ts b/src/types.ts index b06d32c6ce..3e3ddd7660 100644 --- a/src/types.ts +++ b/src/types.ts @@ -219,8 +219,6 @@ export interface ProviderMeta { | "openai_responses" | "openai_messages" | "gemini_native"; - // Codex Claude provider: treat claude-sonnet-5 as normal chat instead of coding agent - claudeChatMode?: boolean; // Codex MultiRouter 向导里的协议选择来源;manual 表示用户已显式锁定,不再被探测推荐覆盖。 apiFormatSource?: "manual" | "probe" | "inferred" | string; // 通用认证绑定 From afc85b15bee0b4a2e3ca1bf76d533d8940bec61a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 02:01:06 +0000 Subject: [PATCH 07/13] chore: remove stale profile scaffolding --- src-tauri/src/codex_config.rs | 9 ++---- src-tauri/src/proxy/forwarder.rs | 53 -------------------------------- 2 files changed, 2 insertions(+), 60 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index bf8a5b803f..4112d86337 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -769,9 +769,7 @@ 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)); - // Newer Codex Desktop builds distinguish Chat-visible models from - // code-mode-only models via `tool_mode`. A JSON null matches official - // chat-capable entries and avoids marking routed models as code-only. + // `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"])); @@ -1365,10 +1363,7 @@ fn codex_provider_models_toml_array(specs: &[CodexCatalogModelSpec]) -> Item { /// 将模型目录同步到活动 provider 的 `models` 字段。 /// -/// Codex Desktop 的 app-server 会把 custom/reserved local provider 标为“自定义”, -/// 但候选菜单仍需要 provider 内部能枚举模型;只写顶层 `model_catalog_json` -/// 对部分 Desktop 版本不够。这里以 CCSwitchMulti 的 `modelCatalog` 为显式信号, -/// 不再因为 `lmstudio` 这类 Codex 保留 provider id 跳过投影。 +/// `modelCatalog` 中的模型始终投影到活动 provider,包括 Codex 保留的 provider id。 fn set_active_codex_provider_models(doc: &mut DocumentMut, specs: &[CodexCatalogModelSpec]) { if specs.is_empty() { return; diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 5a84211863..fa1926ab03 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -524,59 +524,6 @@ fn ask_tool_name(item: &Value) -> String { .to_string() } -#[allow(dead_code)] -#[derive(Debug, Clone)] -struct AskToolCall { - name: String, - arguments: String, -} - -#[allow(dead_code)] -fn ask_tool_arguments(item: &Value) -> String { - item.get("arguments") - .or_else(|| item.get("input")) - .map(ask_value_to_text) - .unwrap_or_default() -} - -#[allow(dead_code)] -fn ask_tool_output(item: &Value) -> String { - item.get("output") - .map(ask_value_to_text) - .unwrap_or_else(|| ask_value_to_text(item)) -} - -#[allow(dead_code)] -fn ask_value_to_text(value: &Value) -> String { - match value { - Value::String(value) => value.clone(), - other => canonical_json_string(other), - } -} - -#[allow(dead_code)] -fn ask_execution_evidence_item(item: &Value, calls: &HashMap) -> Value { - let call_id = item.get("call_id").and_then(Value::as_str).unwrap_or(""); - let output = ask_tool_output(item); - let (status_attr, tool, arguments) = if let Some(call) = calls.get(call_id) { - ("", call.name.as_str(), call.arguments.as_str()) - } else { - (" status=\"unpaired\"", "unknown", "") - }; - let evidence = format!( - "\nsource: prior Codex execution\ntool: {tool}\narguments:\n{arguments}\n\nresult:\n{output}\n" - ); - - serde_json::json!({ - "type": "message", - "role": "user", - "content": [{ - "type": "input_text", - "text": evidence - }] - }) -} - pub struct ForwardResult { pub response: ProxyResponse, pub provider: Provider, From 70686a13810231ccd140d2d08f4684f9d620491c Mon Sep 17 00:00:00 2001 From: z13321812367-sys <1478191279@qq.com> Date: Fri, 10 Jul 2026 16:18:03 +0800 Subject: [PATCH 08/13] Add portable readonly MCP for Codex Ask --- src-tauri/src/bin/ccswitch_readonly_mcp.rs | 345 +-------------------- src-tauri/src/codex_config.rs | 256 ++++++++++++++- src-tauri/src/lib.rs | 2 + src-tauri/src/main.rs | 8 + src-tauri/src/proxy/forwarder.rs | 158 ++++++++-- src-tauri/src/readonly_mcp.rs | 295 ++++++++++++++++++ src-tauri/src/services/proxy.rs | 3 + 7 files changed, 698 insertions(+), 369 deletions(-) create mode 100644 src-tauri/src/readonly_mcp.rs diff --git a/src-tauri/src/bin/ccswitch_readonly_mcp.rs b/src-tauri/src/bin/ccswitch_readonly_mcp.rs index d8133dc3ca..acb0a3e219 100644 --- a/src-tauri/src/bin/ccswitch_readonly_mcp.rs +++ b/src-tauri/src/bin/ccswitch_readonly_mcp.rs @@ -1,344 +1,3 @@ -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 = env::var("CCSWITCH_READONLY_ROOT") - .map_err(|_| anyhow::anyhow!("CCSWITCH_READONLY_ROOT is not set"))?; - let root = fs::canonicalize(root)?; - if !root.is_dir() { - anyhow::bail!("CCSWITCH_READONLY_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_text(&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}")))?; - if bytes.contains(&0) { - return Err(invalid_params("binary file rejected")); - } - - String::from_utf8(bytes).map_err(|_| invalid_params("file is not valid UTF-8 text")) - } -} - -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 a UTF-8 project file") - .with_mime_type("text/plain"), - ], - next_cursor: None, - meta: None, - }) - } - - async fn read_resource( - &self, - request: ReadResourceRequestParams, - _context: RequestContext, - ) -> Result { - let (uri, text) = if request.uri == TREE_URI || request.uri.starts_with(TREE_URI_PREFIX) { - let uri = request.uri; - let text = self.tree_text(&uri)?; - (uri, text) - } else if request.uri.starts_with(FILE_URI_PREFIX) { - let text = self.read_file_text(&request.uri)?; - (request.uri, text) - } else { - return Err(ErrorData::resource_not_found("resource not found", None)); - }; - - Ok(ReadResourceResult::new(vec![ - ResourceContents::TextResourceContents { - uri, - mime_type: Some("text/plain".to_string()), - text, - meta: None, - }, - ])) - } -} - -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) -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - ReadonlyProjectServer::from_env()? - .serve(stdio()) - .await? - .waiting() - .await?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[test] - fn resolves_path_inside_root() { - let dir = tempdir().unwrap(); - let file = dir.path().join("a.txt"); - fs::write(&file, "ok").unwrap(); - - let root = fs::canonicalize(dir.path()).unwrap(); - let resolved = resolve_inside_root(&root, "a.txt").unwrap(); - - assert_eq!(resolved, fs::canonicalize(file).unwrap()); - } - - #[test] - fn rejects_parent_escape() { - let dir = tempdir().unwrap(); - let root = fs::canonicalize(dir.path()).unwrap(); - - assert!(resolve_inside_root(&root, "../secret.txt").is_err()); - } - - #[test] - fn reads_tree_and_ignores_heavy_dirs() { - let dir = tempdir().unwrap(); - fs::create_dir(dir.path().join("src")).unwrap(); - fs::write(dir.path().join("src/lib.rs"), "pub fn x() {}").unwrap(); - fs::create_dir(dir.path().join("node_modules")).unwrap(); - fs::write(dir.path().join("node_modules/skip.js"), "skip").unwrap(); - - let server = ReadonlyProjectServer { - root: fs::canonicalize(dir.path()).unwrap(), - }; - let tree = server.tree_text(TREE_URI).unwrap(); - - assert!(tree.contains("src/")); - assert!(tree.contains("src\\lib.rs") || tree.contains("src/lib.rs")); - assert!(!tree.contains("node_modules")); - } - - #[test] - fn root_tree_is_shallow_and_subtree_can_drill_down() { - let dir = tempdir().unwrap(); - fs::create_dir_all(dir.path().join("src-tauri/src/proxy")).unwrap(); - fs::write( - dir.path().join("src-tauri/src/proxy/forwarder.rs"), - "fn main() {}", - ) - .unwrap(); - - let server = ReadonlyProjectServer { - root: fs::canonicalize(dir.path()).unwrap(), - }; - - let root_tree = server.tree_text(TREE_URI).unwrap(); - assert!(root_tree.contains("src-tauri/")); - assert!(!root_tree.contains("forwarder.rs")); - - let subtree = server - .tree_text("ccswitch://project/tree/src-tauri") - .unwrap(); - assert!(subtree.contains("src-tauri\\src/") || subtree.contains("src-tauri/src/")); - assert!( - subtree.contains("src-tauri\\src\\proxy/") || subtree.contains("src-tauri/src/proxy/") - ); - } - - #[test] - fn rejects_binary_file() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("bin.dat"), [0, 1, 2]).unwrap(); - let server = ReadonlyProjectServer { - root: fs::canonicalize(dir.path()).unwrap(), - }; - - assert!(server - .read_file_text("ccswitch://project/file/bin.dat") - .is_err()); - } +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 4112d86337..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()) @@ -927,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") @@ -1843,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( @@ -3876,6 +3996,51 @@ experimental_bearer_token = "PROXY_MANAGED" 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" @@ -5790,6 +5955,95 @@ 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 配置的 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 645a4b4896..8bab75b3b6 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, 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 fa1926ab03..030ac0239a 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -52,6 +52,14 @@ const CLAUDE_ASK_ALLOWED_TOOLS: &[&str] = &[ ]; 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; @@ -182,7 +190,15 @@ fn apply_claude_ask_profile_for_provider( if let Some(input) = obj.get_mut("input") { let original_input = std::mem::take(input); - *input = filter_responses_input_for_claude_ask(original_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 @@ -284,19 +300,25 @@ fn tool_choice_tool_name(tool_choice: &Value) -> Option<&str> { } } -fn filter_responses_input_for_claude_ask(input: Value) -> Value { +fn filter_responses_input_for_claude_ask(input: Value) -> (Value, AskInputFilterStats) { match input { - Value::Array(items) => Value::Array(filter_response_items_for_claude_ask(items)), + Value::Array(items) => { + let (items, stats) = filter_response_items_for_claude_ask(items); + (Value::Array(items), stats) + } Value::Object(object) => { - Value::Array(filter_response_items_for_claude_ask(vec![Value::Object( - object, - )])) + let (items, stats) = filter_response_items_for_claude_ask(vec![Value::Object(object)]); + (Value::Array(items), stats) } - other => other, + other => (other, AskInputFilterStats::default()), } } -fn filter_response_items_for_claude_ask(items: Vec) -> Vec { +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 { @@ -337,13 +359,20 @@ fn filter_response_items_for_claude_ask(items: Vec) -> Vec { 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) { - sanitize_ask_environment_context_item(&mut item); - filtered.push(item); + if clean_ask_contextual_content_items(&mut item, &mut stats) { + filtered.push(item); + } } } - filtered + stats.kept_items = filtered.len(); + (filtered, stats) } fn sanitize_ask_mcp_tool_output(tool_name: &str, item: &mut Value) { @@ -424,7 +453,8 @@ fn should_keep_ask_conversation_item(item: &Value) -> bool { } match item.get("role").and_then(Value::as_str) { - Some("user") | Some("assistant") | Some("latest_reminder") => true, + Some("user") | Some("latest_reminder") => true, + Some("assistant") => false, Some("system") | Some("developer") | Some("tool") => false, Some(_) => false, None => matches!( @@ -434,10 +464,13 @@ fn should_keep_ask_conversation_item(item: &Value) -> bool { } } -fn sanitize_ask_environment_context_item(item: &mut Value) { +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; } } @@ -446,22 +479,48 @@ fn sanitize_ask_environment_context_item(item: &mut Value) { 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) => { - for content_item in content_items { + content_items.retain_mut(|content_item| { let Some(text) = content_item.get("text").and_then(Value::as_str) else { - continue; - }; - let Some(sanitized) = sanitize_environment_context_fragment(text) else { - continue; + return true; }; - *content_item.get_mut("text").expect("text exists") = Value::String(sanitized); + 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 { @@ -4960,16 +5019,14 @@ mod tests { "role": "user", "content": [{ "type": "input_text", - "text": "\n E:\\文档备份\\New project 333\\downloads\\ccswitchmulti-src\n powershell\n 2026-07-07\n Asia/Shanghai\n enabled\n \n enabled\n" + "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#"E:\文档备份\New project 333\downloads\ccswitchmulti-src"#) - ); + assert!(text.contains(r#"C:\project"#)); assert!(!text.contains("powershell")); assert!(!text.contains("current_date")); assert!(!text.contains("timezone")); @@ -5045,7 +5102,7 @@ mod tests { } #[test] - fn claude_ask_profile_leaves_agents_fragment_unchanged() { + 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", @@ -5060,7 +5117,58 @@ mod tests { }); assert!(apply_claude_ask_profile_for_provider(&mut body, true)); - assert_eq!(body["input"][0]["content"][0]["text"], agents); + 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] diff --git a/src-tauri/src/readonly_mcp.rs b/src-tauri/src/readonly_mcp.rs new file mode 100644 index 0000000000..badf8577be --- /dev/null +++ b/src-tauri/src/readonly_mcp.rs @@ -0,0 +1,295 @@ +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_text(&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}")))?; + if bytes.contains(&0) { + return Err(invalid_params("binary file rejected")); + } + + String::from_utf8(bytes).map_err(|_| invalid_params("file is not valid UTF-8 text")) + } +} + +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 a UTF-8 project file") + .with_mime_type("text/plain"), + ], + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + let (uri, text) = if request.uri == TREE_URI || request.uri.starts_with(TREE_URI_PREFIX) { + let uri = request.uri; + let text = self.tree_text(&uri)?; + (uri, text) + } else if request.uri.starts_with(FILE_URI_PREFIX) { + let text = self.read_file_text(&request.uri)?; + (request.uri, text) + } else { + return Err(ErrorData::resource_not_found("resource not found", None)); + }; + + Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { + uri, + mime_type: Some("text/plain".to_string()), + text, + meta: None, + }, + ])) + } +} + +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") + ); + } +} diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 3787490d63..d2ddb443cd 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -2885,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(()); From 894d4a0c2c53d542baa5a543f8bf67c73f6c570a Mon Sep 17 00:00:00 2001 From: z13321812367-sys <1478191279@qq.com> Date: Fri, 10 Jul 2026 16:36:44 +0800 Subject: [PATCH 09/13] Restore Codex live config on user exit --- src-tauri/src/lib.rs | 7 ++- src-tauri/src/services/proxy.rs | 92 +++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8bab75b3b6..ffc31ae48e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1742,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; } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index d2ddb443cd..f64f056f3d 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -4023,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() { From b0574adb40798aab7c33d30b7124b34ca1279639 Mon Sep 17 00:00:00 2001 From: z13321812367-sys <1478191279@qq.com> Date: Sat, 11 Jul 2026 04:57:02 +0800 Subject: [PATCH 10/13] Prevent startup from restoring Codex takeover --- src-tauri/src/lib.rs | 72 +++++++++++++++++++++++--------------------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ffc31ae48e..3cc294d524 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1786,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 => { @@ -1806,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}"); } } @@ -2133,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() { @@ -2159,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" + ); + } } From 16b50b26b240154f62233f9085077d5272d44471 Mon Sep 17 00:00:00 2001 From: z13321812367-sys <1478191279@qq.com> Date: Sat, 11 Jul 2026 05:30:47 +0800 Subject: [PATCH 11/13] Include GPT-5.6 variants in Codex model catalog --- .../codex/CodexRouterWorkspacePage.test.ts | 97 +++++++++++++++++++ .../codex/CodexRouterWorkspacePage.tsx | 67 ++++++++----- 2 files changed, 142 insertions(+), 22 deletions(-) diff --git a/src/components/codex/CodexRouterWorkspacePage.test.ts b/src/components/codex/CodexRouterWorkspacePage.test.ts index 12caf78e76..a5351f201f 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: 256000, + }, + { + model: "gpt-5.6-terra", + displayName: "GPT-5.6 Terra", + contextWindow: 256000, + }, + { + model: "gpt-5.6-luna", + displayName: "GPT-5.6 Luna", + contextWindow: 256000, + }, { 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: 256000, + }, + { + model: "gpt-5.6-terra", + displayName: "GPT-5.6 Terra", + contextWindow: 256000, + }, + { + model: "gpt-5.6-luna", + displayName: "GPT-5.6 Luna", + contextWindow: 256000, + }, { 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: 256000, + }, + { + model: "gpt-5.6-terra", + displayName: "GPT-5.6 Terra", + contextWindow: 256000, + }, + { + model: "gpt-5.6-luna", + displayName: "GPT-5.6 Luna", + contextWindow: 256000, + }, ]); 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..dea95588ff 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: 256000, + }, + { + model: "gpt-5.6-terra", + displayName: "GPT-5.6 Terra", + contextWindow: 256000, + }, + { + model: "gpt-5.6-luna", + displayName: "GPT-5.6 Luna", + contextWindow: 256000, + }, +]; 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, From 9f029549f077912504a9293e0d72c3e8347ccb31 Mon Sep 17 00:00:00 2001 From: z13321812367-sys <1478191279@qq.com> Date: Sat, 11 Jul 2026 05:33:33 +0800 Subject: [PATCH 12/13] Use current GPT-5.6 context windows --- .../codex/CodexRouterWorkspacePage.test.ts | 18 +++++++++--------- .../codex/CodexRouterWorkspacePage.tsx | 6 +++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/codex/CodexRouterWorkspacePage.test.ts b/src/components/codex/CodexRouterWorkspacePage.test.ts index a5351f201f..9d6ac46694 100644 --- a/src/components/codex/CodexRouterWorkspacePage.test.ts +++ b/src/components/codex/CodexRouterWorkspacePage.test.ts @@ -1134,17 +1134,17 @@ describe("Codex MultiRouter workspace route persistence helpers", () => { { model: "gpt-5.6-sol", displayName: "GPT-5.6 Sol", - contextWindow: 256000, + contextWindow: 372000, }, { model: "gpt-5.6-terra", displayName: "GPT-5.6 Terra", - contextWindow: 256000, + contextWindow: 372000, }, { model: "gpt-5.6-luna", displayName: "GPT-5.6 Luna", - contextWindow: 256000, + contextWindow: 372000, }, { model: "qwen3.6", displayName: "Qwen 3.6", contextWindow: 262144 }, ]); @@ -1288,17 +1288,17 @@ describe("Codex MultiRouter workspace route persistence helpers", () => { { model: "gpt-5.6-sol", displayName: "GPT-5.6 Sol", - contextWindow: 256000, + contextWindow: 372000, }, { model: "gpt-5.6-terra", displayName: "GPT-5.6 Terra", - contextWindow: 256000, + contextWindow: 372000, }, { model: "gpt-5.6-luna", displayName: "GPT-5.6 Luna", - contextWindow: 256000, + contextWindow: 372000, }, { model: "gpt-5.5-relay-gpt", @@ -1803,17 +1803,17 @@ describe("Codex MultiRouter workspace route persistence helpers", () => { { model: "gpt-5.6-sol", displayName: "GPT-5.6 Sol", - contextWindow: 256000, + contextWindow: 372000, }, { model: "gpt-5.6-terra", displayName: "GPT-5.6 Terra", - contextWindow: 256000, + contextWindow: 372000, }, { model: "gpt-5.6-luna", displayName: "GPT-5.6 Luna", - contextWindow: 256000, + contextWindow: 372000, }, ]); expect(plan.settingsConfig.modelCatalog.spawnAgentModels).toEqual([ diff --git a/src/components/codex/CodexRouterWorkspacePage.tsx b/src/components/codex/CodexRouterWorkspacePage.tsx index dea95588ff..c9e0ae7f06 100644 --- a/src/components/codex/CodexRouterWorkspacePage.tsx +++ b/src/components/codex/CodexRouterWorkspacePage.tsx @@ -405,17 +405,17 @@ const OPENAI_CODEX_NEW_OFFICIAL_MODELS: CodexCatalogModelDraft[] = [ { model: "gpt-5.6-sol", displayName: "GPT-5.6 Sol", - contextWindow: 256000, + contextWindow: 372000, }, { model: "gpt-5.6-terra", displayName: "GPT-5.6 Terra", - contextWindow: 256000, + contextWindow: 372000, }, { model: "gpt-5.6-luna", displayName: "GPT-5.6 Luna", - contextWindow: 256000, + contextWindow: 372000, }, ]; const OPENAI_CODEX_FALLBACK_CATALOG_MODELS: CodexCatalogModelDraft[] = [ From da9018d0066bfea8698bd5f2547d85497fd9661e Mon Sep 17 00:00:00 2001 From: z13321812367-sys <1478191279@qq.com> Date: Fri, 17 Jul 2026 20:18:18 +0800 Subject: [PATCH 13/13] revert: restore text_only model filter, remove strip_codex_hosted_tools Root cause: codex_config.catalog change set all non-OAuth models to input_modalities: ["text", "image"], causing Codex to inject image_gen.imagegen hosted tool - APINebula GPT rejects mixed tools. Fix: revert codex_config.rs and handlers.rs to HEAD. Keep readonly_mcp.rs (binary file reading, not related). --- src-tauri/src/readonly_mcp.rs | 146 +++++++++++++++++++++++++++++----- 1 file changed, 128 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/readonly_mcp.rs b/src-tauri/src/readonly_mcp.rs index badf8577be..6657d8913f 100644 --- a/src-tauri/src/readonly_mcp.rs +++ b/src-tauri/src/readonly_mcp.rs @@ -1,3 +1,4 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; use rmcp::{ model::{ ErrorData, ListResourceTemplatesResult, ListResourcesResult, PaginatedRequestParams, @@ -116,7 +117,7 @@ impl ReadonlyProjectServer { Ok(out) } - fn read_file_text(&self, uri: &str) -> Result { + fn read_file_contents(&self, uri: &str) -> Result { let rel = uri .strip_prefix(FILE_URI_PREFIX) .ok_or_else(|| invalid_params("unsupported resource uri"))?; @@ -131,11 +132,29 @@ impl ReadonlyProjectServer { } 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 Err(invalid_params("binary file rejected")); + 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, + }), } - - String::from_utf8(bytes).map_err(|_| invalid_params("file is not valid UTF-8 text")) } } @@ -169,8 +188,8 @@ impl ServerHandler for ReadonlyProjectServer { .with_description("Read a shallow project subtree") .with_mime_type("text/plain"), ResourceTemplate::new("ccswitch://project/file/{path}", "project-file") - .with_description("Read a UTF-8 project file") - .with_mime_type("text/plain"), + .with_description("Read any project file") + .with_mime_type("application/octet-stream"), ], next_cursor: None, meta: None, @@ -182,25 +201,45 @@ impl ServerHandler for ReadonlyProjectServer { request: ReadResourceRequestParams, _context: RequestContext, ) -> Result { - let (uri, text) = if request.uri == TREE_URI || request.uri.starts_with(TREE_URI_PREFIX) { + let contents = if request.uri == TREE_URI || request.uri.starts_with(TREE_URI_PREFIX) { let uri = request.uri; let text = self.tree_text(&uri)?; - (uri, text) - } else if request.uri.starts_with(FILE_URI_PREFIX) { - let text = self.read_file_text(&request.uri)?; - (request.uri, text) - } else { - return Err(ErrorData::resource_not_found("resource not found", None)); - }; - - Ok(ReadResourceResult::new(vec![ 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", } } @@ -292,4 +331,75 @@ mod tests { 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, + } + ); + } }