From 775ed30567bfa1d00d4315e3aaeb6776f962bf90 Mon Sep 17 00:00:00 2001 From: BigStrongSun <54140710+BigStrongSun@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:03:24 +0800 Subject: [PATCH 01/15] fix(codex): sanitize generated V2 agent nicknames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: model identifiers such as qwen3.8 were copied into nickname_candidates unchanged. Codex only permits ASCII letters, digits, spaces, hyphens, and underscores, so it rejected the entire generated role and later reported unknown agent_type. Normalize unsupported punctuation to spaces, collapse whitespace, provide a safe fallback, and cover the qwen3.8 role TOML with a regression test. Verified all 85 codex_subagent_profiles tests and cargo formatting. 本次提交由BigStrongsSun完成 --- src-tauri/src/codex_subagent_profiles.rs | 39 ++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/codex_subagent_profiles.rs b/src-tauri/src/codex_subagent_profiles.rs index bf01aa3b..a47e50be 100644 --- a/src-tauri/src/codex_subagent_profiles.rs +++ b/src-tauri/src/codex_subagent_profiles.rs @@ -1083,11 +1083,29 @@ fn default_role_name(p: &ParsedCodexSubagentProfile) -> String { } fn default_nickname(p: &ParsedCodexSubagentProfile) -> String { let source = p.key.split(['-', '_']).next().unwrap_or(&p.key); - let mut chars = source.chars(); - chars + let sanitized = source + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, ' ' | '-' | '_') { + character + } else { + ' ' + } + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + let mut chars = sanitized.chars(); + let nickname = chars .next() .map(|c| c.to_uppercase().collect::() + chars.as_str()) - .unwrap_or_default() + .unwrap_or_default(); + if nickname.is_empty() { + "CCSwitch Worker".to_string() + } else { + nickname + } } fn profile_collision_identity(entry: &ParsedProfileEntry) -> String { @@ -1917,6 +1935,21 @@ mod tests { ); } + #[test] + fn generated_default_nickname_sanitizes_model_punctuation_for_codex_role_files() { + let mut compile_request = request(Some(config( + SelectionPolicy::Balanced, + vec![valid(profile("qwen3.8", "qwen3.8"))], + ))); + compile_request.catalog_models = vec![catalog("qwen3.8", true)]; + let output = compile_subagent_v2_profiles(&compile_request).expect("compile Qwen profile"); + let role = output.generated_roles.first().expect("generated Qwen role"); + assert_eq!(role.nickname_candidates, vec!["Qwen3 8"]); + let toml = render_generated_role_toml(role, "# managed").expect("render Qwen role"); + assert!(toml.contains("nickname_candidates = [\"Qwen3 8\"]")); + assert!(!toml.contains("nickname_candidates = [\"Qwen3.8\"]")); + } + #[test] fn codex_subagent_v2_text_only_capability_round_trips_and_guards_generated_copy() { let mut raw = canonical_raw_profile(json!(["repository_exploration"])); From ab99d5f0915f1fe0c7d6283660512fa44b96f231 Mon Sep 17 00:00:00 2001 From: BigStrongSun <54140710+BigStrongSun@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:03:57 +0800 Subject: [PATCH 02/15] chore(release): prepare CCSwitchMulti 3.19.1-29 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the package, Rust crate, lockfile, and Tauri application versions together so the Qwen V2 nickname fix can be deployed and distinguished from 3.19.1-28. Add Chinese release notes covering root cause, behavior change, and regression validation. 本次提交由BigStrongsSun完成 --- docs/release-notes/v3.19.1-29-zh.md | 15 +++++++++++++++ package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 docs/release-notes/v3.19.1-29-zh.md diff --git a/docs/release-notes/v3.19.1-29-zh.md b/docs/release-notes/v3.19.1-29-zh.md new file mode 100644 index 00000000..40f45589 --- /dev/null +++ b/docs/release-notes/v3.19.1-29-zh.md @@ -0,0 +1,15 @@ +# CCSwitchMulti v3.19.1-29 + +本版本修复 Codex Sub-Agent V2 为包含标点的第三方模型生成角色文件时,角色可能被 Codex 整体忽略的问题。 + +## 修复内容 + +- 生成 `nickname_candidates` 时遵循 Codex 的字符约束:仅保留 ASCII 字母、数字、空格、连字符和下划线。 +- 对模型标识中的点号等不支持字符做安全归一化。例如 `qwen3.8` 现在生成昵称 `Qwen3 8`,不会再触发 malformed agent role definition。 +- 当归一化后没有可用字符时,使用安全的默认昵称,保证生成的角色文件仍可被 Codex 加载。 +- 新增 `qwen3.8` 回归测试,覆盖角色生成和 TOML 序列化结果。 + +## 验证 + +- `codex_subagent_profiles::tests`:85 项通过,0 项失败。 +- Rust 格式检查和 Git whitespace 检查通过。 diff --git a/package.json b/package.json index a1ba13ff..126fe64f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-switch-multi", - "version": "3.19.1-28", + "version": "3.19.1-29", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3077ab1c..e2defa9f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -761,7 +761,7 @@ dependencies = [ [[package]] name = "cc-switch" -version = "3.19.1-28" +version = "3.19.1-29" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 28dd50c2..542185d0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cc-switch" -version = "3.19.1-28" +version = "3.19.1-29" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" authors = ["Jason Young"] license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a1491c75..a4ed8dd3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CCSwitchMulti", - "version": "3.19.1-28", + "version": "3.19.1-29", "identifier": "com.ccswitchmulti.desktop", "build": { "frontendDist": "../dist", From 678687552405ac29d29342be72e25297c5f94774 Mon Sep 17 00:00:00 2001 From: BigStrongSun <54140710+BigStrongSun@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:48:58 +0800 Subject: [PATCH 03/15] fix(codex): harden V2 nickname generation for all model IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Do not special-case qwen3.8. Centralize the exact Codex nickname grammar, derive automatic nicknames from the full model identifier, preserve legal hyphens and underscores, normalize every unsupported character, and retain a safe fallback for identifiers without usable ASCII. Reuse the same predicate for explicit nickname validation. Add table-driven coverage for dotted, slashed, colon-delimited, Unicode, and symbol-only identifiers plus an end-to-end invariant that every generated role nickname satisfies Codex parsing rules. Verified 87 profile tests with zero failures. 本次提交由BigStrongsSun完成 --- src-tauri/src/codex_subagent_profiles.rs | 80 +++++++++++++++++++++--- 1 file changed, 70 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/codex_subagent_profiles.rs b/src-tauri/src/codex_subagent_profiles.rs index a47e50be..57ab2151 100644 --- a/src-tauri/src/codex_subagent_profiles.rs +++ b/src-tauri/src/codex_subagent_profiles.rs @@ -1081,8 +1081,15 @@ fn generated_instructions_for_provider( fn default_role_name(p: &ParsedCodexSubagentProfile) -> String { p.key.clone() } -fn default_nickname(p: &ParsedCodexSubagentProfile) -> String { - let source = p.key.split(['-', '_']).next().unwrap_or(&p.key); + +fn is_valid_codex_nickname(nickname: &str) -> bool { + !nickname.is_empty() + && nickname.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, ' ' | '-' | '_') + }) +} + +fn sanitize_codex_nickname(source: &str) -> String { let sanitized = source .chars() .map(|character| { @@ -1099,15 +1106,19 @@ fn default_nickname(p: &ParsedCodexSubagentProfile) -> String { let mut chars = sanitized.chars(); let nickname = chars .next() - .map(|c| c.to_uppercase().collect::() + chars.as_str()) + .map(|character| character.to_uppercase().collect::() + chars.as_str()) .unwrap_or_default(); - if nickname.is_empty() { - "CCSwitch Worker".to_string() - } else { + if is_valid_codex_nickname(&nickname) { nickname + } else { + "CCSwitch Worker".to_string() } } +fn default_nickname(p: &ParsedCodexSubagentProfile) -> String { + sanitize_codex_nickname(&p.key) +} + fn profile_collision_identity(entry: &ParsedProfileEntry) -> String { match entry { ParsedProfileEntry::Valid(profile) => normalize_profile_key(&profile.model), @@ -1166,10 +1177,7 @@ fn validate_and_trim_intrinsic_overrides( }, )); } - if !nickname - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_')) - { + if !is_valid_codex_nickname(nickname) { return Err(validation_error( "invalid_nickname", Some(&profile.key), @@ -1950,6 +1958,58 @@ mod tests { assert!(!toml.contains("nickname_candidates = [\"Qwen3.8\"]")); } + #[test] + fn generated_default_nickname_is_codex_valid_for_diverse_model_identifiers() { + let cases = [ + ("gpt-4.1", "Gpt-4 1"), + ("claude-3.7-sonnet", "Claude-3 7-sonnet"), + ("qwen2.5-coder", "Qwen2 5-coder"), + ("moonshot_v1.8", "Moonshot_v1 8"), + ("vendor/model:1.0", "Vendor model 1 0"), + ("模型.版本", "CCSwitch Worker"), + ("...", "CCSwitch Worker"), + ]; + + for (model, expected) in cases { + let nickname = sanitize_codex_nickname(model); + assert_eq!(nickname, expected, "model={model}"); + assert!(is_valid_codex_nickname(&nickname), "model={model}"); + } + } + + #[test] + fn every_automatically_generated_nickname_satisfies_codex_role_grammar() { + let models = [ + "qwen3.8", + "gpt-4.1", + "deepseek-v4.flash", + "vendor/model:1.0", + "...model", + ]; + + for model in models { + let mut compile_request = request(Some(config( + SelectionPolicy::Balanced, + vec![valid(profile(model, model))], + ))); + compile_request.catalog_models = vec![catalog(model, true)]; + let output = compile_subagent_v2_profiles(&compile_request) + .unwrap_or_else(|error| panic!("compile model={model}: {error:?}")); + let role = output + .generated_roles + .first() + .unwrap_or_else(|| panic!("missing generated role for model={model}")); + assert_eq!(role.nickname_candidates.len(), 1, "model={model}"); + assert!( + is_valid_codex_nickname(&role.nickname_candidates[0]), + "model={model}, nickname={}", + role.nickname_candidates[0] + ); + render_generated_role_toml(role, "# managed") + .unwrap_or_else(|error| panic!("render model={model}: {error:?}")); + } + } + #[test] fn codex_subagent_v2_text_only_capability_round_trips_and_guards_generated_copy() { let mut raw = canonical_raw_profile(json!(["repository_exploration"])); From 4f014d626eb374036dac7f6ea5fc983f6473abee Mon Sep 17 00:00:00 2001 From: BigStrongSun <54140710+BigStrongSun@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:49:23 +0800 Subject: [PATCH 04/15] chore(release): prepare CCSwitchMulti 3.19.1-30 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump all application version sources together and document the generalized Sub-Agent V2 nickname normalization, shared grammar validation, model identifier coverage, and 87-test acceptance result. 本次提交由BigStrongsSun完成 --- docs/release-notes/v3.19.1-30-zh.md | 16 ++++++++++++++++ package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 docs/release-notes/v3.19.1-30-zh.md diff --git a/docs/release-notes/v3.19.1-30-zh.md b/docs/release-notes/v3.19.1-30-zh.md new file mode 100644 index 00000000..20b2b1e4 --- /dev/null +++ b/docs/release-notes/v3.19.1-30-zh.md @@ -0,0 +1,16 @@ +# CCSwitchMulti v3.19.1-30 + +本版本将 Sub-Agent V2 自动昵称修复扩展到所有模型标识,不再针对单个 Qwen 模型做特殊处理。 + +## 修复内容 + +- 自动昵称从完整模型标识生成,合法的连字符与下划线会保留。 +- 点号、斜杠、冒号及其他不符合 Codex 昵称语法的字符统一安全归一化。 +- 没有可用 ASCII 字符的标识使用安全默认昵称;无法生成合法 roleName 的模型仍会在更早阶段明确拒绝。 +- 自动昵称和用户显式昵称共用同一条 Codex 字符规则,避免校验逻辑漂移。 +- 增加多模型表驱动测试和最终生成角色不变量测试,覆盖 `qwen3.8`、`gpt-4.1`、`qwen2.5-coder`、斜杠/冒号模型标识及 Unicode 边界。 + +## 验证 + +- `codex_subagent_profiles::tests`:87 项通过,0 项失败。 +- Rust 格式检查与 Git whitespace 检查通过。 diff --git a/package.json b/package.json index 126fe64f..4315b1c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-switch-multi", - "version": "3.19.1-29", + "version": "3.19.1-30", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e2defa9f..5a88acb0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -761,7 +761,7 @@ dependencies = [ [[package]] name = "cc-switch" -version = "3.19.1-29" +version = "3.19.1-30" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 542185d0..4f5b5e82 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cc-switch" -version = "3.19.1-29" +version = "3.19.1-30" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" authors = ["Jason Young"] license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a4ed8dd3..be5500d2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CCSwitchMulti", - "version": "3.19.1-29", + "version": "3.19.1-30", "identifier": "com.ccswitchmulti.desktop", "build": { "frontendDist": "../dist", From 9ca173a08397830878879af9e5a83651169d41cd Mon Sep 17 00:00:00 2001 From: BigStrongSun <54140710+BigStrongSun@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:59:06 +0800 Subject: [PATCH 05/15] test(proxy): reproduce hosted tools streaming regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RED: a streaming Codex Responses request with tool_choice=auto and globally advertised hosted tools must not be converted into an upstream non-streaming request. Explicit web_search/image_generation selection and genuinely non-streaming requests retain the buffered hosted loop. Compilation fails because the transport policy helper does not yet exist. 本次提交由BigStrongsSun完成 --- src-tauri/src/proxy/forwarder.rs | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index e6556375..4ddd9809 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -9432,6 +9432,44 @@ mod tests { assert_eq!(account_id.as_deref(), Some("acct_1")); } + #[test] + fn streaming_auto_tool_choice_preserves_upstream_stream_instead_of_hosted_loop() { + let request = serde_json::json!({ + "stream": true, + "tool_choice": "auto", + "tools": [ + {"type": "web_search"}, + {"type": "function", "name": "shell", "parameters": {"type": "object"}} + ] + }); + + assert!(!should_enable_hosted_tool_loop(&request)); + } + + #[test] + fn explicit_hosted_tool_choice_may_use_buffered_hosted_loop() { + for hosted_type in ["web_search", "image_generation"] { + let request = serde_json::json!({ + "stream": true, + "tool_choice": {"type": hosted_type}, + "tools": [{"type": hosted_type}] + }); + + assert!(should_enable_hosted_tool_loop(&request)); + } + } + + #[test] + fn non_streaming_auto_request_keeps_hosted_tool_loop() { + let request = serde_json::json!({ + "stream": false, + "tool_choice": "auto", + "tools": [{"type": "web_search"}] + }); + + assert!(should_enable_hosted_tool_loop(&request)); + } + /// 验证 hosted web_search loop 会消费第一轮工具调用、回灌 tool output 并返回最终 Chat 响应。 #[tokio::test] async fn hosted_web_search_loop_appends_tool_output_and_marks_response() { From c45d0dfaa57561e23f6d870073762e3e687680db Mon Sep 17 00:00:00 2001 From: BigStrongSun <54140710+BigStrongSun@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:20:24 +0800 Subject: [PATCH 06/15] fix(proxy): preserve streaming for automatic hosted tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Do not let globally advertised OpenAI hosted tools force every Responses-to-Chat request into stream=false. The transport policy now uses the actual client streaming semantics, including Accept headers, and omits hosted-only definitions from streaming tool_choice=auto projections while preserving ordinary client tools. Explicit web_search/image_generation choices and genuinely non-streaming requests retain the existing bounded hosted loop. This removes the Qwen long-context blank-thinking regression without forcing tool calls, guessing from user text, or disabling Matrix MCP/client tools. Validation: new RED/GREEN policy tests, existing hosted loop tests, rustfmt, and git diff checks passed. 本次提交由BigStrongsSun完成 --- src-tauri/src/proxy/forwarder.rs | 51 +++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 4ddd9809..d260970d 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -2309,6 +2309,10 @@ impl RequestForwarder { // 转换请求体(如果需要) let request_prepare_started_at = std::time::Instant::now(); let mut codex_chat_tool_context: Option = None; + let client_requested_streaming = + is_streaming_request(&effective_endpoint, &mapped_body, headers); + let hosted_tool_loop_allowed = !codex_responses_to_chat + || should_enable_hosted_tool_loop(&mapped_body, client_requested_streaming); let mut request_body = if codex_responses_to_chat || codex_responses_to_messages { let mut mapped_body = mapped_body; let explicit_prompt_cache_key = mapped_body @@ -2338,11 +2342,16 @@ impl RequestForwarder { } if let Some(context) = codex_chat_tool_context.as_mut() { context.apply_hosted_tool_switches( - hosted_tool_bridge_enabled(&codex_router_provider.settings_config, "webSearch"), - hosted_tool_bridge_enabled( - &codex_router_provider.settings_config, - "imageGeneration", - ), + hosted_tool_loop_allowed + && hosted_tool_bridge_enabled( + &codex_router_provider.settings_config, + "webSearch", + ), + hosted_tool_loop_allowed + && hosted_tool_bridge_enabled( + &codex_router_provider.settings_config, + "imageGeneration", + ), ); } let mut chat_body = super::providers::transform_codex_chat::responses_to_chat_completions_with_reasoning_text_only_and_cache( @@ -5829,6 +5838,32 @@ fn hosted_tool_bridge_enabled(settings: &Value, tool: &str) -> bool { .unwrap_or(true) } +/// Decide whether the buffered Chat hosted-tool loop owns this request. +/// +/// Streaming `auto` requests prioritize incremental agent progress: hosted +/// tools are omitted from the Chat projection, while ordinary client tools +/// remain available. An explicit hosted selection is safe to buffer because +/// the caller requested that exact bridge. Non-streaming requests retain the +/// existing automatic hosted-tool loop. +fn should_enable_hosted_tool_loop(request: &Value, client_requested_streaming: bool) -> bool { + if !client_requested_streaming { + return true; + } + + let Some(choice) = request.get("tool_choice").and_then(Value::as_object) else { + return false; + }; + let choice_type = choice.get("type").and_then(Value::as_str); + if matches!(choice_type, Some("web_search" | "image_generation")) { + return true; + } + choice_type == Some("function") + && matches!( + choice.get("name").and_then(Value::as_str), + Some("web_search" | "generate_image") + ) +} + /// 解析 hosted tool 调用凭据:优先显式 API Key,再回退请求自带的 Codex OAuth,最后用 CCSM 托管 OAuth。 async fn resolve_hosted_tool_client( app_handle: Option<&tauri::AppHandle>, @@ -9443,7 +9478,7 @@ mod tests { ] }); - assert!(!should_enable_hosted_tool_loop(&request)); + assert!(!should_enable_hosted_tool_loop(&request, true)); } #[test] @@ -9455,7 +9490,7 @@ mod tests { "tools": [{"type": hosted_type}] }); - assert!(should_enable_hosted_tool_loop(&request)); + assert!(should_enable_hosted_tool_loop(&request, true)); } } @@ -9467,7 +9502,7 @@ mod tests { "tools": [{"type": "web_search"}] }); - assert!(should_enable_hosted_tool_loop(&request)); + assert!(should_enable_hosted_tool_loop(&request, false)); } /// 验证 hosted web_search loop 会消费第一轮工具调用、回灌 tool output 并返回最终 Chat 响应。 From 6314c12b2fd0bdf9cd2aa76f459daf1e2107844b Mon Sep 17 00:00:00 2001 From: BigStrongSun <54140710+BigStrongSun@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:27:14 +0800 Subject: [PATCH 07/15] chore(release): prepare CCSwitchMulti 3.19.1-31 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump all application and Rust version sources from v30 to v31. Add Chinese release notes and project memory documenting the two independent Qwen3.8 root causes, the exact streaming policy boundary, remote Tool Guard deployment evidence, regression totals, required live acceptance, and rollback ownership. UTF-8 strict decoding and no-BOM checks passed for all changed text files; rustfmt and git diff checks passed. 本次提交由BigStrongsSun完成 --- docs/release-notes/v3.19.1-31-zh.md | 21 +++++++++++++++++++++ memory.md | 6 ++++++ package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 docs/release-notes/v3.19.1-31-zh.md diff --git a/docs/release-notes/v3.19.1-31-zh.md b/docs/release-notes/v3.19.1-31-zh.md new file mode 100644 index 00000000..3839c21d --- /dev/null +++ b/docs/release-notes/v3.19.1-31-zh.md @@ -0,0 +1,21 @@ +# CCSwitchMulti v3.19.1-31 + +本版本修复 Qwen 等 Responses-to-Chat 路由在长任务中看似“做到一半卡住”的传输根因。 + +## 修复内容 + +- Hosted Web Search 或 Image Generation 全局启用时,不再把每个 Codex 流式请求无条件改成 `stream=false`。 +- 流式 `tool_choice=auto` 请求优先保留增量输出;只移除当前流式桥无法安全拦截的 OpenAI Hosted 工具,终端、文件、Git、MCP 和其他客户端工具保持可用。 +- 显式选择 `web_search`/`image_generation` 时继续使用原有的有界 Hosted Tool loop;客户端原本就是非流式时也保持原行为。 +- 流式判定同时遵循请求体和 HTTP 头,不假设 `stream` 字段一定存在。 + +## 验证 + +- 新增 RED/GREEN 回归,覆盖流式 auto、显式 Hosted 工具选择和非流式 auto 三条边界。 +- 原有 Hosted Web Search loop 回归通过。 +- Rust library:3000 项通过,0 项失败,2 项忽略。 +- `cargo check --lib`、rustfmt 和 Git whitespace 检查通过。 + +## 相关远端修复 + +roglinux 的 Qwen3.8 透明代理同步完成 Tool Guard 根修:模型识别改为读取实际运行环境和可配置别名;模型切换事务会重启共享 EnvironmentFile 的 proxy/dashboard 消费者,避免实际模型已经是 `qwen3.8`、代理仍按 `qwen3.6` 判断。真实 canary 已验证 Guard 生效且正常最终回答不被强制调用工具。 diff --git a/memory.md b/memory.md index 575d943c..af6d1c07 100644 --- a/memory.md +++ b/memory.md @@ -3491,3 +3491,9 @@ - 固定构建前完整门禁:Rust library 2996/2996,Vitest 123 files / 1002 tests,原生 Windows PowerShell 5.1 下 release-build-config 6/6、事务安装 47/47;`cargo check --lib`、typecheck、Prettier、rustfmt 和 `git diff --check` 通过。Pester 3.4.0 的 `Should Throw` 在 PowerShell 7 下会误报,必须按仓库既定边界使用 Windows PowerShell 5.1,不能把运行器不兼容当生产失败或伪装成通过。 - 第一次 v28 本地流水线虽 exit 0,但构建日志出现 `__TAURI_BUNDLE_TYPE variable not found`;事务 `ccsm-20260815-121651-5dd2541f85cc43c69cbdf94b84c45dae` 发现安装态哈希仍等于 raw EXE、没有完成 `UNK -> NSS` 标记,按设计回滚到 v27,`RollbackError=null`,回滚后 FileVersion/ProductVersion 为 `3.19.1-27`、哈希 `CD0ED804D3D1CABD10144E31CC674A84A7855889BCF9ECF49F58C140D5167BC4`、health 200。 - 根因是 worktree 的 `node_modules` junction 指向主工作树:声明与 lock 已固定 `@tauri-apps/cli 2.10.1`,实际安装包仍为 `2.8.1` 且命令报告 `tauri-cli 2.8.0`,与 Rust `tauri-utils 2.8.3` marker 机制不匹配。普通 frozen install 在 junction 重建确认下可无变更返回 0,因此发布流水线必须先执行非交互 `pnpm install --frozen-lockfile --force`,再同时核对声明版本、实际安装 package 版本和 CLI 自报版本,之后才允许 typecheck/export;不能把事务 expected hash 改成 raw 来掩盖 updater bundle type 缺失。实际依赖重建后 package 与 CLI 均为 `2.10.1`。 +## 2026-08-15 Qwen3.8 中途停顿与 Hosted Tools 流式根修 + +- 目标会话约 189K prompt tokens、945 KB 请求体、139 条 Chat message 和 41 个工具,仍低于 Qwen3.8 的 262144 上下文;现场没有 429、5xx、context overflow 或转换丢失。长上下文只放大等待,不是根因。 +- roglinux 透明代理把 thinking/Tool Guard 模型硬编码为 `qwen3.6`,且模型切换只重启 vLLM worker:环境文件已写 `VLLM_SERVED_MODEL_NAME=qwen3.8`,长驻 proxy 进程仍持有 `qwen3.6`。独立修复仓库 `C:\Users\sunda\Documents\LLMservice\qwen38-tool-guard-fix` 用 RED/GREEN 将系统 Guard、tail Guard、流过滤和 generation limit 统一到运行时模型解析器,并让 controller/兼容 shell 切换事务重启 proxy/dashboard。生产 canary 日志为 `codex_tool_guard_applied=true`、`system_applied+tail_applied`,同时正常最终文本成功,不能用全局 `tool_choice=required` 代替。 +- CCSwitch 根因位于 `forwarder.rs`:只要原始请求带 Hosted Web Search/Image Generation 且开关启用,就把全部 Responses-to-Chat 请求改成 `stream=false`;Codex 显示流式但上游没有增量,长上下文时表现为持续“正在思考”。v31 改为语义化传输策略:流式 `tool_choice=auto` 保留 SSE 并从 Chat 投影中移除 hosted-only 工具,普通客户端工具不受影响;显式 hosted tool choice 与真正非流式请求继续走有界 loop。不得根据用户文本猜测是否搜索。 +- RED/GREEN 提交为 `9ca173a0` / `c45d0dfa`(从 v30 基线重放后的哈希)。完整 Rust library 为 3000 passed / 0 failed / 2 ignored,`cargo check --lib`、rustfmt 与 `git diff --check` 通过。安装验收必须看到同一 trace 的 `streaming=true` 和 `upstream_stream=true`,并分别验证普通工具循环、显式 Hosted 工具、正常最终回答与长上下文。 diff --git a/package.json b/package.json index 4315b1c7..e14b9978 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-switch-multi", - "version": "3.19.1-30", + "version": "3.19.1-31", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5a88acb0..c81acb97 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -761,7 +761,7 @@ dependencies = [ [[package]] name = "cc-switch" -version = "3.19.1-30" +version = "3.19.1-31" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4f5b5e82..8a2e6f9e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cc-switch" -version = "3.19.1-30" +version = "3.19.1-31" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" authors = ["Jason Young"] license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index be5500d2..3ef61563 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CCSwitchMulti", - "version": "3.19.1-30", + "version": "3.19.1-31", "identifier": "com.ccswitchmulti.desktop", "build": { "frontendDist": "../dist", From b9f2c567de357c3cba3f1f0a6545cb1fcfdfe3ff Mon Sep 17 00:00:00 2001 From: BigStrongSun <54140710+BigStrongSun@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:43:34 +0800 Subject: [PATCH 08/15] ops(release): add isolated v31 transaction launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launch the existing audited CCSwitchMulti installer transaction in a hidden independent PowerShell process with exact current/target versions and hashes, config backup, port ownership, health gate, and automatic rollback. Keeping arguments in a UTF-8 script avoids cross-shell quoting hazards and ensures the current Codex route is never left stopped on launcher failure. 本次提交由BigStrongsSun完成 --- scripts/run-v31-local-install.ps1 | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 scripts/run-v31-local-install.ps1 diff --git a/scripts/run-v31-local-install.ps1 b/scripts/run-v31-local-install.ps1 new file mode 100644 index 00000000..6972ae02 --- /dev/null +++ b/scripts/run-v31-local-install.ps1 @@ -0,0 +1,49 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent $PSScriptRoot +. (Join-Path $PSScriptRoot "release-build-config.ps1") + +$installer = "C:\Users\sunda\Documents\LLMservice\最新版ccswitchmulti\windows\installer\CCSwitchMulti_3.19.1-31_x64-setup.exe" +$rawExecutable = Join-Path $repoRoot "src-tauri\target\release\cc-switch.exe" +$installedExecutable = "C:\Users\sunda\AppData\Local\CCSwitchMulti\cc-switch.exe" +$installDirectory = Split-Path -Parent $installedExecutable +$uninstallExecutable = Join-Path $installDirectory "uninstall.exe" +$listener = Get-NetTCPConnection -State Listen -LocalPort 15721 | Select-Object -First 1 +if (-not $listener) { throw "CCSwitchMulti is not listening on port 15721" } + +$transactionId = "ccsm-20260815-qwen38-stream-v31" +$backupRoot = Join-Path "C:\Users\sunda\AppData\Local\CCSwitchMultiTransactionBackups" $transactionId +New-Item -ItemType Directory -Force -Path $backupRoot | Out-Null +$resultPath = Join-Path $backupRoot "transaction-result.json" +$stderrPath = "$resultPath.stderr" + +$arguments = @( + "-NoProfile", "-ExecutionPolicy", "Bypass", + "-File", (Join-Path $PSScriptRoot "install-ccswitchmulti-transaction.ps1"), + "-InstallerPath", $installer, + "-ExpectedInstallerHash", (Get-ReleaseFileSha256 -Path $installer), + "-ExpectedCurrentVersion", (Get-Item -LiteralPath $installedExecutable).VersionInfo.ProductVersion, + "-ExpectedCurrentHash", (Get-ReleaseFileSha256 -Path $installedExecutable), + "-ExpectedInstalledVersion", "3.19.1-31", + "-ExpectedInstalledHash", (Get-TauriNsisInstalledExeSha256 -Path $rawExecutable), + "-CurrentPid", [string]$listener.OwningProcess, + "-InstalledExecutable", $installedExecutable, + "-InstallDirectory", $installDirectory, + "-UninstallExecutable", $uninstallExecutable, + "-ConfigPath", "C:\Users\sunda\.cc-switch", + "-RegistryKey", "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\CCSwitchMulti", + "-Port", "15721", + "-HealthUri", "http://127.0.0.1:15721/health", + "-TimeoutSeconds", "120", + "-BackupRoot", $backupRoot +) + +$process = Start-Process powershell.exe -WindowStyle Hidden -PassThru -ArgumentList $arguments ` + -RedirectStandardOutput $resultPath -RedirectStandardError $stderrPath + +[pscustomobject]@{ + TransactionId = $transactionId + ProcessId = $process.Id + ResultPath = $resultPath +} | ConvertTo-Json -Compress From 3cba30a14f352e2ee7da4cdac4c2c0a4cf727733 Mon Sep 17 00:00:00 2001 From: BigStrongSun <54140710+BigStrongSun@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:46:16 +0800 Subject: [PATCH 09/15] test(proxy): add installed Qwen streaming acceptance canary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Send a real Codex-shaped Responses request through installed port 15721 with stream=true, tool_choice=auto, one hosted web_search tool, and one ordinary client function. Require SSE content type, response.completed, the expected final marker, and report time to first event so router logs can prove upstream streaming remained enabled. 本次提交由BigStrongsSun完成 --- scripts/verify_qwen38_streaming.py | 77 ++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 scripts/verify_qwen38_streaming.py diff --git a/scripts/verify_qwen38_streaming.py b/scripts/verify_qwen38_streaming.py new file mode 100644 index 00000000..5c7dc1a2 --- /dev/null +++ b/scripts/verify_qwen38_streaming.py @@ -0,0 +1,77 @@ +import json +import time +import urllib.request + + +payload = { + "model": "qwen3.8", + "stream": True, + "tool_choice": "auto", + "input": [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Reply only with CCSM_QWEN38_STREAM_OK. Do not call a tool.", + } + ], + } + ], + "tools": [ + {"type": "web_search"}, + { + "type": "function", + "name": "report_marker", + "description": "Report a marker only when explicitly requested.", + "parameters": { + "type": "object", + "properties": {"marker": {"type": "string"}}, + "required": ["marker"], + "additionalProperties": False, + }, + }, + ], +} +request = urllib.request.Request( + "http://127.0.0.1:15721/v1/responses", + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": "Bearer PROXY_MANAGED", + "Content-Type": "application/json", + "Accept": "text/event-stream", + "User-Agent": "Codex Desktop/qwen38-stream-canary", + "session_id": "qwen38-stream-rootfix-20260815", + }, +) + +started = time.monotonic() +events = [] +with urllib.request.urlopen(request, timeout=180) as response: + assert response.headers.get_content_type() == "text/event-stream", response.headers + first_event_seconds = None + for raw_line in response: + line = raw_line.decode("utf-8").strip() + if not line.startswith("data:"): + continue + if first_event_seconds is None: + first_event_seconds = time.monotonic() - started + data = line[5:].strip() + if data != "[DONE]": + events.append(json.loads(data)) + +event_types = [event.get("type") for event in events] +assert "response.completed" in event_types, event_types +serialized = json.dumps(events, ensure_ascii=False) +assert "CCSM_QWEN38_STREAM_OK" in serialized, serialized[-2000:] +print( + json.dumps( + { + "status": "CCSM_QWEN38_STREAM_OK", + "first_event_seconds": round(first_event_seconds or 0.0, 3), + "event_count": len(events), + "event_types": event_types, + }, + ensure_ascii=False, + ) +) From b71c66684b8502ef2eef1feb17d6156f86c27b5f Mon Sep 17 00:00:00 2001 From: BigStrongSun <54140710+BigStrongSun@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:48:05 +0800 Subject: [PATCH 10/15] docs(memory): record Qwen3.8 root-fix runtime acceptance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the v31 NSIS build marker and installer hash, successful independent install transaction and rollback status, installed PID/version/hash/health, real Qwen SSE timing and event count, exact router trace proving streaming=true/upstream_stream=true, and the build-version snapshot race resolved by a SkipBuild re-export. UTF-8 strict decode and no-BOM checks passed. 本次提交由BigStrongsSun完成 --- memory.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/memory.md b/memory.md index af6d1c07..77425620 100644 --- a/memory.md +++ b/memory.md @@ -3497,3 +3497,6 @@ - roglinux 透明代理把 thinking/Tool Guard 模型硬编码为 `qwen3.6`,且模型切换只重启 vLLM worker:环境文件已写 `VLLM_SERVED_MODEL_NAME=qwen3.8`,长驻 proxy 进程仍持有 `qwen3.6`。独立修复仓库 `C:\Users\sunda\Documents\LLMservice\qwen38-tool-guard-fix` 用 RED/GREEN 将系统 Guard、tail Guard、流过滤和 generation limit 统一到运行时模型解析器,并让 controller/兼容 shell 切换事务重启 proxy/dashboard。生产 canary 日志为 `codex_tool_guard_applied=true`、`system_applied+tail_applied`,同时正常最终文本成功,不能用全局 `tool_choice=required` 代替。 - CCSwitch 根因位于 `forwarder.rs`:只要原始请求带 Hosted Web Search/Image Generation 且开关启用,就把全部 Responses-to-Chat 请求改成 `stream=false`;Codex 显示流式但上游没有增量,长上下文时表现为持续“正在思考”。v31 改为语义化传输策略:流式 `tool_choice=auto` 保留 SSE 并从 Chat 投影中移除 hosted-only 工具,普通客户端工具不受影响;显式 hosted tool choice 与真正非流式请求继续走有界 loop。不得根据用户文本猜测是否搜索。 - RED/GREEN 提交为 `9ca173a0` / `c45d0dfa`(从 v30 基线重放后的哈希)。完整 Rust library 为 3000 passed / 0 failed / 2 ignored,`cargo check --lib`、rustfmt 与 `git diff --check` 通过。安装验收必须看到同一 trace 的 `streaming=true` 和 `upstream_stream=true`,并分别验证普通工具循环、显式 Hosted 工具、正常最终回答与长上下文。 +- v31 本地 release 构建日志明确出现 Tauri `Patching ... with bundle type information: nsis`,安装包 SHA-256 为 `665CDBF69AE889CAA5AD3473A3AB71CAD4B99C79633EB41CDF93B86E15FE88F5`。事务 `ccsm-20260815-214338-8abca640d04e4137bf314eaa3d95264d` 返回 `Success`、`Error=null`、`RollbackError=null`;安装版 PID/15721 owner 均为 `48992`,ProductVersion `3.19.1-31`,SHA-256 `DE307C845D02CE59AF334DFEE98C2A2BC193E9A1A0981266BEFC59A5C0754A96`,health HTTP 200。 +- 安装版真实 Qwen canary 使用 `stream=true`、`tool_choice=auto`,同时携带 hosted `web_search` 与普通 function。首个 SSE 事件 0.593 秒到达,共 50 个事件并以 `response.completed` 结束;router trace `ef86c36b-0970-4665-9a50-2c8b7371365d` 显示 `/responses -> /chat/completions`、Qwen route HTTP 200、`streaming=true`、`upstream_stream=true`。这证明全局 Hosted Tools 不再让普通 Agent 请求失去增量输出。 +- 发布流水线曾在进程启动时读取 v30,随后 worktree 提升 v31,导致实际成功构建 v31 但导出阶段仍寻找 v30;重新执行 `export-latest-ccswitchmulti.ps1 -SkipBuild` 后按当前 v31 正确生成安装包、签名和 `latest.json`。以后版本提升必须发生在启动发布流水线之前,不能在持锁构建期间改变版本源。 From da911e3dab934fc64ca34b09674e7b59221f5b28 Mon Sep 17 00:00:00 2001 From: BigStrongSun <54140710+BigStrongSun@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:48:39 +0800 Subject: [PATCH 11/15] test(proxy): verify streamed client tool call survives hosted filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend installed acceptance with a second Qwen request that still advertises hosted web_search but explicitly requires the ordinary report_marker function. Require streamed function-call argument completion, the correct tool name, and exact marker to prove the auto-stream policy removes only hosted-only definitions and preserves agent tools. 本次提交由BigStrongsSun完成 --- scripts/verify_qwen38_streaming.py | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/scripts/verify_qwen38_streaming.py b/scripts/verify_qwen38_streaming.py index 5c7dc1a2..51a85348 100644 --- a/scripts/verify_qwen38_streaming.py +++ b/scripts/verify_qwen38_streaming.py @@ -75,3 +75,36 @@ ensure_ascii=False, ) ) + +tool_payload = json.loads(json.dumps(payload)) +tool_payload["input"][0]["content"][0]["text"] = ( + "Call report_marker exactly once with marker CCSM_QWEN38_TOOL_OK. Do not answer with text." +) +tool_request = urllib.request.Request( + "http://127.0.0.1:15721/v1/responses", + data=json.dumps(tool_payload).encode("utf-8"), + headers={ + "Authorization": "Bearer PROXY_MANAGED", + "Content-Type": "application/json", + "Accept": "text/event-stream", + "User-Agent": "Codex Desktop/qwen38-tool-canary", + "session_id": "qwen38-client-tool-rootfix-20260815", + }, +) +tool_events = [] +with urllib.request.urlopen(tool_request, timeout=180) as response: + for raw_line in response: + line = raw_line.decode("utf-8").strip() + if not line.startswith("data:"): + continue + data = line[5:].strip() + if data != "[DONE]": + tool_events.append(json.loads(data)) + +tool_serialized = json.dumps(tool_events, ensure_ascii=False) +assert "response.function_call_arguments.done" in [ + event.get("type") for event in tool_events +], tool_serialized[-2000:] +assert "report_marker" in tool_serialized, tool_serialized[-2000:] +assert "CCSM_QWEN38_TOOL_OK" in tool_serialized, tool_serialized[-2000:] +print("CCSM_QWEN38_TOOL_OK") From af0c2599fb41fd09c7d6e09fb53d5564e4fba910 Mon Sep 17 00:00:00 2001 From: BigStrongSun <54140710+BigStrongSun@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:49:05 +0800 Subject: [PATCH 12/15] docs(memory): record streamed client-tool acceptance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the installed report_marker canary and exact router trace proving ordinary client tools remain available and stream their function-call arguments even when hosted web_search is globally advertised. 本次提交由BigStrongsSun完成 --- memory.md | 1 + 1 file changed, 1 insertion(+) diff --git a/memory.md b/memory.md index 77425620..d5d474ec 100644 --- a/memory.md +++ b/memory.md @@ -3499,4 +3499,5 @@ - RED/GREEN 提交为 `9ca173a0` / `c45d0dfa`(从 v30 基线重放后的哈希)。完整 Rust library 为 3000 passed / 0 failed / 2 ignored,`cargo check --lib`、rustfmt 与 `git diff --check` 通过。安装验收必须看到同一 trace 的 `streaming=true` 和 `upstream_stream=true`,并分别验证普通工具循环、显式 Hosted 工具、正常最终回答与长上下文。 - v31 本地 release 构建日志明确出现 Tauri `Patching ... with bundle type information: nsis`,安装包 SHA-256 为 `665CDBF69AE889CAA5AD3473A3AB71CAD4B99C79633EB41CDF93B86E15FE88F5`。事务 `ccsm-20260815-214338-8abca640d04e4137bf314eaa3d95264d` 返回 `Success`、`Error=null`、`RollbackError=null`;安装版 PID/15721 owner 均为 `48992`,ProductVersion `3.19.1-31`,SHA-256 `DE307C845D02CE59AF334DFEE98C2A2BC193E9A1A0981266BEFC59A5C0754A96`,health HTTP 200。 - 安装版真实 Qwen canary 使用 `stream=true`、`tool_choice=auto`,同时携带 hosted `web_search` 与普通 function。首个 SSE 事件 0.593 秒到达,共 50 个事件并以 `response.completed` 结束;router trace `ef86c36b-0970-4665-9a50-2c8b7371365d` 显示 `/responses -> /chat/completions`、Qwen route HTTP 200、`streaming=true`、`upstream_stream=true`。这证明全局 Hosted Tools 不再让普通 Agent 请求失去增量输出。 +- 第二个安装版 canary 在相同 hosted `web_search` 广告下要求普通 `report_marker`,实际收到流式 `response.function_call_arguments.done`、正确工具名和 `CCSM_QWEN38_TOOL_OK` 参数;trace `ed4f112d-8604-4857-b8b3-9f81c00d38c2` 同样为 HTTP 200、`streaming=true`、`upstream_stream=true`。因此策略只移除 hosted-only 定义,没有误删 Codex 的终端/文件/MCP 类客户端工具。 - 发布流水线曾在进程启动时读取 v30,随后 worktree 提升 v31,导致实际成功构建 v31 但导出阶段仍寻找 v30;重新执行 `export-latest-ccswitchmulti.ps1 -SkipBuild` 后按当前 v31 正确生成安装包、签名和 `latest.json`。以后版本提升必须发生在启动发布流水线之前,不能在持锁构建期间改变版本源。 From 8c129b03be707333c46ac3357f3eb8832dee2d2b Mon Sep 17 00:00:00 2001 From: Shawn Pro Date: Sat, 15 Aug 2026 17:06:49 +0800 Subject: [PATCH 13/15] =?UTF-8?q?fix(codex):=20=E4=BF=AE=E5=A4=8D=20reason?= =?UTF-8?q?ing=20=E6=A1=A3=E4=BD=8D=E4=BD=93=E7=B3=BB=E5=A4=9A=E5=A4=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - builtin 能力清单增加 Kimi K3/K3-256K(low/high/max,default high) - reasoning 声明解析/校验失败改为打日志,不再静默清空 - 前端取消勾选档位时清理 effortMap 孤儿映射;保存时校验映射 target 合法 - 子智能体 V2 编译:Unknown 能力 + Fixed 策略放行;Fixed 档位宽校验 (effort_map 映射后合法则通过,保留显式档位由代理层运行时映射) - 投影档位收窄为模型真实能力(deepseek/k3 变 3 档),effort_value_mode 保留完整映射,代理端先映射后校验(窄显示 + 宽映射兜底) - 子智能体能力解析新增官方缓存来源(cc_switch_owned 时读 backup), luna/sol 等官方模型继承官方档位 - 修复 v28 死锁:未启用 profile 用 status.status 区分 Disabled/Unroutable, 开关与编辑按钮恢复可用 - 更新 5 处档位断言,新增 5 个测试;全量 2995 passed --- src-tauri/src/codex_config.rs | 160 ++++++++++++++++-- src-tauri/src/codex_subagent_profiles.rs | 68 +++++++- src-tauri/src/proxy/providers/codex.rs | 27 ++- .../src/proxy/providers/codex_reasoning.rs | 73 ++++++-- .../proxy/providers/transform_codex_chat.rs | 41 ++++- .../codex/CodexSubagentProfileEditor.tsx | 21 ++- .../providers/forms/CodexFormFields.tsx | 34 +++- .../providers/forms/ProviderForm.tsx | 19 +++ 8 files changed, 392 insertions(+), 51 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index d4792b26..a80c0edd 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -1358,6 +1358,91 @@ fn sort_codex_catalog_specs_for_picker( indexed_specs.into_iter().map(|(_, spec)| spec).collect() } +/// 读取 Codex 官方模型缓存(models 数组)。 +/// +/// CC Switch 接管后会把路由目录写进 models_cache.json(etag 标记为 CC_SWITCH 拥有), +/// 官方原始档位在 backup 文件里。此处与 enrich_codex_catalog_with_official_metadata +/// 保持同一选择逻辑:缓存被 CC Switch 拥有时优先读 backup。 +/// 任何读取/解析失败都返回 None(静默降级,不阻断投影)。 +fn codex_official_models_cache() -> Option> { + let cache_path = get_codex_models_cache_path(); + let backup_path = get_codex_models_cache_backup_path(); + let existing_cache = read_json_file_if_exists(&cache_path).ok().flatten(); + let official_cache = match existing_cache.as_ref() { + Some(cache) if codex_models_cache_is_cc_switch_owned(cache) => { + read_json_file_if_exists(&backup_path) + .ok() + .flatten() + .or_else(|| existing_cache.clone()) + } + _ => existing_cache, + }?; + let models = official_cache.get("models").and_then(Value::as_array)?.clone(); + Some(models) +} + +/// 从 Codex 官方缓存为指定 slug 构造 reasoning capability。 +/// +/// 官方缓存字段是 snake_case,`supported_reasoning_levels` 可能是字符串数组 +/// (["low","medium",...])或对象数组([{"effort":"low","description":...},...]): +/// CCSM 写入的 cache 为字符串数组,官方 backup 为对象数组,两种都兼容。 +/// 官方 GPT 模型走 OpenAI 顶层 `reasoning_effort` 字段,effort_map 用 identity。 +/// 任何校验失败都返回 None(保守降级为 Unknown,不产生虚假档位)。 +fn official_reasoning_capability_for_model( + model: &str, + official_models: &[Value], +) -> Option { + use crate::proxy::providers::codex_reasoning::{ + CodexModelReasoningCapability, CodexModelReasoningUpstream, + }; + let entry = official_models.iter().find(|entry| { + entry + .get("slug") + .and_then(Value::as_str) + .is_some_and(|slug| slug.eq_ignore_ascii_case(model)) + })?; + let levels: Vec = entry + .get("supported_reasoning_levels") + .and_then(Value::as_array)? + .iter() + .filter_map(|level| { + level + .as_str() + .or_else(|| level.get("effort").and_then(Value::as_str)) + .map(str::trim) + .map(ToString::to_string) + }) + .filter(|level| !level.is_empty()) + .collect(); + if levels.is_empty() { + return None; + } + let default_effort = entry + .get("default_reasoning_level") + .and_then(Value::as_str) + .map(str::trim) + .filter(|level| !level.is_empty()) + .map(ToString::to_string); + let capability = CodexModelReasoningCapability { + supported: true, + supported_efforts: levels.clone(), + default_effort, + disable_allowed: false, + upstream: CodexModelReasoningUpstream { + format: "string".to_string(), + parameter: "reasoning_effort".to_string(), + effort_map: levels + .into_iter() + .map(|level| (level.clone(), level)) + .collect(), + }, + output_format: None, + source: Some("official".to_string()), + }; + capability.validate().ok()?; + Some(capability) +} + fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec { let Some(models) = settings .get("modelCatalog") @@ -1373,6 +1458,7 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec Vec mod tests { use super::*; + #[test] + fn official_reasoning_capability_reads_snake_case_levels() { + let official = serde_json::json!([{ + "slug": "gpt-5.6-luna", + "supported_reasoning_levels": ["low", "medium", "high", "xhigh", "max"], + "default_reasoning_level": "medium" + }]); + let models = official.as_array().unwrap().clone(); + let capability = official_reasoning_capability_for_model("gpt-5.6-luna", &models) + .expect("luna official capability"); + assert_eq!( + capability.supported_efforts, + vec!["low", "medium", "high", "xhigh", "max"] + ); + assert_eq!(capability.default_effort.as_deref(), Some("medium")); + assert_eq!(capability.source.as_deref(), Some("official")); + // 官方档位含 ultra 的模型(sol/terra)也能通过 validate + let sol = serde_json::json!([{ + "slug": "gpt-5.6-sol", + "supported_reasoning_levels": ["low", "medium", "high", "xhigh", "max", "ultra"], + "default_reasoning_level": "low" + }]); + let sol_models = sol.as_array().unwrap().clone(); + let sol_capability = official_reasoning_capability_for_model("gpt-5.6-sol", &sol_models) + .expect("sol official capability"); + assert!(sol_capability.supported_efforts.contains(&"ultra".to_string())); + // 不匹配的 slug 返回 None + assert!(official_reasoning_capability_for_model("gpt-5.6-sol", &models).is_none()); + } + + #[test] + fn official_reasoning_capability_accepts_object_levels_from_backup() { + // 官方 backup 文件(models_cache.cc-switch-backup.json)里 + // supported_reasoning_levels 是对象数组 {effort, description},必须兼容。 + let official = serde_json::json!([{ + "slug": "gpt-5.6-luna", + "supported_reasoning_levels": [ + {"effort": "low", "description": "Fast"}, + {"effort": "medium", "description": "Balanced"}, + {"effort": "high", "description": "Deep"}, + {"effort": "xhigh", "description": "Extra deep"}, + {"effort": "max", "description": "Maximum"} + ], + "default_reasoning_level": "medium" + }]); + let models = official.as_array().unwrap().clone(); + let capability = official_reasoning_capability_for_model("gpt-5.6-luna", &models) + .expect("object levels must resolve"); + assert_eq!( + capability.supported_efforts, + vec!["low", "medium", "high", "xhigh", "max"] + ); + assert_eq!(capability.default_effort.as_deref(), Some("medium")); + } + fn codex_subagent_profile_status_json( settings: &Value, provider_context: Option<&ProviderClassificationContext>, @@ -6200,7 +6342,7 @@ mod tests { "supportKind": "effort_levels", "source": "builtin", "confidence": "confirmed", - "codexSelectableEfforts": ["none", "low", "medium", "high", "xhigh", "max"], + "codexSelectableEfforts": ["low", "high", "max"], "providerAcceptedEfforts": ["low", "high", "max"], "providerDefaultEffort": "high", "disableAllowed": true, @@ -6296,7 +6438,7 @@ mod tests { "supportKind": "effort_levels", "source": "builtin", "confidence": "confirmed", - "codexSelectableEfforts": ["none", "low", "medium", "high", "xhigh", "max"], + "codexSelectableEfforts": ["low", "high", "max"], "providerAcceptedEfforts": ["low", "high", "max"], "providerDefaultEffort": "high", "disableAllowed": true, @@ -7054,7 +7196,7 @@ mod tests { "supportKind": "effort_levels", "source": "builtin", "confidence": "confirmed", - "codexSelectableEfforts": ["none", "low", "medium", "high", "xhigh", "max"], + "codexSelectableEfforts": ["low", "high", "max"], "providerAcceptedEfforts": ["low", "high", "max"], "providerDefaultEffort": "high", "disableAllowed": true, @@ -10547,14 +10689,8 @@ openai_base_url = "http://127.0.0.1:15721/v1" assert_eq!(entry["default_reasoning_level"], "high"); assert_eq!(entry["defaultReasoningEffort"], "high"); - assert_eq!( - levels, - vec!["none", "low", "medium", "high", "xhigh", "max"] - ); - assert_eq!( - desktop_levels, - vec!["none", "low", "medium", "high", "xhigh", "max"] - ); + assert_eq!(levels, vec!["low", "high", "max"]); + assert_eq!(desktop_levels, vec!["low", "high", "max"]); assert!(!levels.contains(&"ultra")); } diff --git a/src-tauri/src/codex_subagent_profiles.rs b/src-tauri/src/codex_subagent_profiles.rs index 57ab2151..15dc22c0 100644 --- a/src-tauri/src/codex_subagent_profiles.rs +++ b/src-tauri/src/codex_subagent_profiles.rs @@ -1,7 +1,7 @@ //! Codex V2 questionnaire persistence, validation, compilation, and safe preview projection. use crate::proxy::providers::codex_reasoning::{ - CodexReasoningEffort, ResolvedSubagentReasoningCapability, + CodexReasoningEffort, ReasoningSupportKind, ResolvedSubagentReasoningCapability, }; use serde::ser::{SerializeMap, SerializeStruct}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -820,14 +820,31 @@ fn compile_reasoning_policy( "fixed reasoning policy requires effort", ) })?; - if !capability.codex_selectable_efforts.contains(&effort) { - return Err(validation_error( - "unsupported_reasoning_effort", - Some(profile_key), - "fixed reasoning effort is not supported by the target model", - )); + match capability.support_kind { + // 能力明确:宽校验 + 保留显式档位。legacy/schema1 声明的映射档位 + // (xhigh/medium 等)经 effort_map 映射后若落在模型真实档位内 + // (如 high),则编译通过并**保留原档位**,由代理层运行时映射 + // 到上游;映射后仍不在可选档位内才拒绝(Unsupported/BooleanOnly + // 的 selectable 集合不含该档位,同样会在此拒绝)。 + ReasoningSupportKind::EffortLevels + | ReasoningSupportKind::BooleanOnly + | ReasoningSupportKind::Unsupported => { + let resolved = capability.effort_map.get(&effort).unwrap_or(&effort); + if capability.codex_selectable_efforts.contains(resolved) { + Ok(Some(effort)) + } else { + return Err(validation_error( + "unsupported_reasoning_effort", + Some(profile_key), + "fixed reasoning effort is not supported by the target model", + )); + } + } + // 能力未知(Unknown):无法验证档位合法性,但 Unknown ≠ Unsupported。 + // 信任用户显式声明的 Fixed effort,避免 schema1 旧配置(legacy effort + // 迁移为 Fixed)在模型目录未声明能力时直接编译失败(v27/v28 回归)。 + ReasoningSupportKind::Unknown => Ok(Some(effort)), } - Ok(Some(effort)) } ReasoningRuntimePolicy::Disabled => { if !capability.disable_allowed @@ -2743,6 +2760,41 @@ mod tests { ); } + #[test] + fn reasoning_policy_fixed_unknown_capability_trusts_declared_effort() { + // schema1 旧配置(legacy reasoningEffort)迁移为 Fixed 后,目标模型能力 + // Unknown 时不得编译失败——Unknown ≠ Unsupported,信任用户显式声明。 + // v27/v28 曾因 selectable 为空集而报 unsupported_reasoning_effort, + // 导致 "Unable to inspect Codex subagent profiles" 全线失败。 + let capability = ResolvedSubagentReasoningCapability { + support_kind: ReasoningSupportKind::Unknown, + source: None, + confidence: ReasoningConfidence::Unverified, + codex_selectable_efforts: vec![], + provider_accepted_efforts: vec![], + provider_default_effort: None, + disable_allowed: false, + effort_map: BTreeMap::new(), + }; + assert_eq!( + compile_reasoning_policy( + &fixed_reasoning(CodexReasoningEffort::High), + &capability, + "legacy-model", + ) + .expect("Unknown capability + Fixed must compile"), + Some(CodexReasoningEffort::High) + ); + // EffortLevels 能力下 Ultra 仍被拒绝(能力明确时不放行) + let deepseek = deepseek_reasoning(); + assert!(compile_reasoning_policy( + &fixed_reasoning(CodexReasoningEffort::Ultra), + &deepseek, + "flash", + ) + .is_err()); + } + #[test] fn codex_subagent_v2_delegated_effort_is_not_selected_from_complex_strength() { generated_for_profile( diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index 8bd29219..bad10a26 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -1928,12 +1928,9 @@ fn encode_codex_capability_effort_mode( .map(|effort| effort.as_str()) .collect::>() .join(","); - let mappings = supported_efforts + let mappings = effort_map .iter() - .map(|effort| { - let mapped = effort_map.get(effort).unwrap_or(effort); - format!("{}={}", effort.as_str(), mapped.as_str()) - }) + .map(|(source, target)| format!("{}={}", source.as_str(), target.as_str())) .collect::>() .join(","); format!("capability|{allowed}|{mappings}") @@ -2794,6 +2791,26 @@ mod tests { }; use serde_json::json; + #[test] + fn capability_effort_mode_keeps_wide_mappings_for_narrow_selectable() { + let capability = crate::proxy::providers::codex_reasoning::builtin_reasoning_capability_for_model( + "deepseek-v4-flash", + ) + .expect("deepseek builtin"); + let resolved = crate::proxy::providers::codex_reasoning::resolve_subagent_reasoning_capability( + Some(&capability), + ); + let mode = encode_codex_capability_effort_mode( + &resolved.codex_selectable_efforts, + &resolved.effort_map, + ); + // allowed 收窄为真实档位,mappings 保留 medium/xhigh 上游映射(宽映射兜底) + assert_eq!( + mode, + "capability|low,high,max|low=low,medium=high,high=high,xhigh=high,max=max" + ); + } + fn create_provider(config: serde_json::Value) -> Provider { Provider { id: "test".to_string(), diff --git a/src-tauri/src/proxy/providers/codex_reasoning.rs b/src-tauri/src/proxy/providers/codex_reasoning.rs index 4467224e..66c1dcac 100644 --- a/src-tauri/src/proxy/providers/codex_reasoning.rs +++ b/src-tauri/src/proxy/providers/codex_reasoning.rs @@ -206,14 +206,9 @@ pub fn resolve_subagent_reasoning_capability( } } - let selectable_set = effort_map - .keys() + let selectable_set = provider_effort_set + .iter() .copied() - .chain( - capability - .disable_allowed - .then_some(CodexReasoningEffort::None), - ) .collect::>(); let codex_selectable_efforts = CodexReasoningEffort::ORDERED .into_iter() @@ -258,9 +253,21 @@ pub fn builtin_reasoning_capability_for_model( model: &str, ) -> Option { let normalized = model.trim().to_ascii_lowercase(); - if !matches!(normalized.as_str(), "deepseek-v4-flash" | "deepseek-v4-pro") { + // 官方维护清单:DeepSeek V4 与 Kimi K3 均支持 reasoning_effort: low/high/max(默认 high)。 + // 保持精确匹配,未知第三方模型不得继承 GPT 通用档位。 + if !matches!( + normalized.as_str(), + "deepseek-v4-flash" | "deepseek-v4-pro" | "k3" | "k3-256k" + ) { return None; } + // DeepSeek Responses 返回 reasoning_content 字段;Kimi 响应字段未确认, + // 不声明 output_format(代理层按默认行为处理,避免错误字段破坏转换)。 + let output_format = if normalized.starts_with("deepseek") { + Some("reasoning_content".into()) + } else { + None + }; Some(CodexModelReasoningCapability { supported: true, supported_efforts: vec!["low".into(), "high".into(), "max".into()], @@ -279,7 +286,7 @@ pub fn builtin_reasoning_capability_for_model( .into_iter() .collect(), }, - output_format: Some("reasoning_content".into()), + output_format, source: Some("builtin".into()), }) } @@ -287,9 +294,34 @@ pub fn builtin_reasoning_capability_for_model( pub fn reasoning_capability_from_model_entry( model_entry: &Value, ) -> Option { - let value = model_entry.get("reasoning")?; - let capability: CodexModelReasoningCapability = serde_json::from_value(value.clone()).ok()?; - capability.validate().ok()?; + let Some(value) = model_entry.get("reasoning") else { + return None; + }; + if value.is_null() { + // reasoning: null 与缺失等价,均视为"未声明" + return None; + } + let model = model_entry + .get("model") + .and_then(Value::as_str) + .unwrap_or("?"); + let capability: CodexModelReasoningCapability = match serde_json::from_value(value.clone()) { + Ok(capability) => capability, + Err(error) => { + // 声明存在但无法解析:打日志暴露问题,避免用户手动声明 + // 被静默当作"未声明"而清空(v27/v28 回归)。 + log::warn!( + "Codex reasoning declaration for model {model} is not parseable and will be ignored: {error}" + ); + return None; + } + }; + if let Err(error) = capability.validate() { + log::warn!( + "Codex reasoning declaration for model {model} is invalid and will be ignored: {error}" + ); + return None; + } Some(capability) } @@ -354,7 +386,7 @@ mod tests { ); assert_eq!( resolved.codex_selectable_efforts, - efforts(&["none", "low", "medium", "high", "xhigh", "max"]) + efforts(&["low", "high", "max"]) ); assert_eq!( resolved.effort_map.get(&CodexReasoningEffort::Medium), @@ -425,4 +457,19 @@ mod tests { assert!(builtin_reasoning_capability_for_model("deepseek-v4-flash-preview").is_none()); assert!(builtin_reasoning_capability_for_model("vendor/deepseek-v4-pro").is_none()); } + + #[test] + fn restores_exact_kimi_k3_capabilities() { + for model in ["k3", "K3-256K", "k3-256k"] { + let capability = builtin_reasoning_capability_for_model(model) + .unwrap_or_else(|| panic!("{model} must resolve a Kimi capability")); + assert_eq!(capability.supported_efforts, vec!["low", "high", "max"]); + assert_eq!(capability.default_effort.as_deref(), Some("high")); + // Kimi 响应字段未确认,output_format 保持 None + assert_eq!(capability.output_format, None); + assert_eq!(capability.source.as_deref(), Some("builtin")); + } + assert!(builtin_reasoning_capability_for_model("k3-ultra").is_none()); + assert!(builtin_reasoning_capability_for_model("vendor/k3").is_none()); + } } diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index c90213bb..6fefd62e 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -889,16 +889,21 @@ fn map_capability_reasoning_effort<'a>( let _kind = sections.next(); let allowed = sections.next().unwrap_or_default(); let mappings = sections.next().unwrap_or_default(); + // 宽映射兜底:medium/xhigh 等映射档位直接命中,返回上游档位(如 high)。 + // 先查映射再校验 allowed,避免 Codex 端仍发送映射档位时被 fail closed。 + if let Some(target) = mappings + .split(',') + .filter_map(|mapping| mapping.split_once('=')) + .find_map(|(source, target)| (source == effort).then_some(target)) + { + return Ok(target); + } if !allowed.split(',').any(|candidate| candidate == effort) { return Err(ProxyError::TransformError(format!( "reasoning effort `{effort}` is not supported; allowed=[{allowed}]" ))); } - Ok(mappings - .split(',') - .filter_map(|mapping| mapping.split_once('=')) - .find_map(|(source, target)| (source == effort).then_some(target)) - .unwrap_or(effort)) + Ok(effort) } /// 写入 vLLM/HF chat template 常用的嵌套 thinking 开关,同时保留已有 kwargs。 @@ -2564,6 +2569,32 @@ pub fn chat_error_to_response_error(body: Option<&Value>) -> Value { mod tests { use super::*; + #[test] + fn capability_effort_mapping_narrow_display_wide_remap() { + let mode = + "capability|low,high,max|low=low,medium=high,high=high,xhigh=high,max=max"; + // 映射档位兜底:medium/xhigh 命中 effort_map 映射,转发到上游 high + assert_eq!( + map_capability_reasoning_effort("medium", mode).unwrap(), + "high" + ); + assert_eq!( + map_capability_reasoning_effort("xhigh", mode).unwrap(), + "high" + ); + // 真实档位 identity 映射 + assert_eq!( + map_capability_reasoning_effort("low", mode).unwrap(), + "low" + ); + assert_eq!( + map_capability_reasoning_effort("max", mode).unwrap(), + "max" + ); + // 未知档位:无映射且不在 allowed,仍 fail closed + assert!(map_capability_reasoning_effort("foo", mode).is_err()); + } + fn large_test_image_data_url() -> String { let bytes = b"CC_SWITCH_TOOL_MEDIA_SENTINEL".repeat(400); format!("data:image/png;base64,{}", STANDARD.encode(bytes)) diff --git a/src/components/codex/CodexSubagentProfileEditor.tsx b/src/components/codex/CodexSubagentProfileEditor.tsx index 05f0b8e1..3078b43b 100644 --- a/src/components/codex/CodexSubagentProfileEditor.tsx +++ b/src/components/codex/CodexSubagentProfileEditor.tsx @@ -88,7 +88,9 @@ function profileToneFor( profile: CodexSubagentV2Profile, status?: CodexSubagentProfileStatus, ): ProfileTone { - if (status?.routable === false) return "unroutable"; + // 只有真不可路由(编译状态 Unroutable)才标"不可路由"; + // 未启用的可路由 profile(编译状态 Disabled)应显示为草稿。 + if (status?.status === "unroutable") return "unroutable"; return profile.enabled ? "enabled-routable" : "draft"; } @@ -877,7 +879,7 @@ export function CodexSubagentProfileEditor({ if (providerKind === "official" && !showOfficialProfiles) return false; if (profileFilter === "enabled" && !profile.enabled) return false; if (profileFilter === "draft" && profile.enabled) return false; - if (profileFilter === "unroutable" && status?.routable !== false) { + if (profileFilter === "unroutable" && status?.status !== "unroutable") { return false; } const haystack = [ @@ -1134,7 +1136,10 @@ export function CodexSubagentProfileEditor({ variant="outline" aria-label={`编辑 ${profile.model}`} onClick={() => setOpenProfileKey(profileKey)} - disabled={isSaving || status?.routable === false} + // 真不可路由(编译状态 Unroutable)一律禁止编辑; + // 未启用的可路由 profile(编译状态 Disabled)必须可编辑, + // 否则死锁(v28 回归:未启用 → routable=false → 禁编辑)。 + disabled={isSaving || status?.status === "unroutable"} className="shrink-0" > 编辑 @@ -1143,8 +1148,12 @@ export function CodexSubagentProfileEditor({ updateProfile(profileKey, (current) => ({ @@ -1782,12 +1791,12 @@ function ProfileSummary({ - {status?.routable === false ? "不可路由" : "可路由"} + {status?.status === "unroutable" ? "不可路由" : "可路由"} { - const supportedEfforts = event - .target.checked + const checked = + event.target.checked; + const supportedEfforts = checked ? [ ...row.reasoning! .supportedEfforts, @@ -3074,11 +3075,40 @@ export function CodexFormFields({ ? row.reasoning! .defaultEffort : supportedEfforts[0]; + // 取消勾选时同步清理 effortMap 中指向被移除档位的孤儿映射, + // 否则保存后后端 validate(target 必须在 supportedEfforts) + // 会拒绝整份声明并被静默清空(v27/v28 回归)。 + const nextEffortMap: Record< + string, + CodexReasoningEffort + > = { + ...(row.reasoning!.upstream + .effortMap ?? {}), + }; + if (!checked) { + for (const [ + source, + target, + ] of Object.entries( + nextEffortMap, + )) { + if (target === effort) { + delete nextEffortMap[ + source + ]; + } + } + } handleUpdateCatalogRow(index, { reasoning: { ...row.reasoning!, supportedEfforts, defaultEffort, + upstream: { + ...row.reasoning! + .upstream, + effortMap: nextEffortMap, + }, source: "user", }, }); diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 10459c3b..442389b6 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -23,6 +23,7 @@ import type { CodexModelCatalogConfig, CodexRoutingConfig, CodexChatReasoning, + CodexReasoningEffort, PromptCacheRoutingMode, ClaudeApiKeyField, } from "@/types"; @@ -231,6 +232,24 @@ export const normalizeCodexCatalogModelsForSave = ( throw new Error(`${model}: reasoning effortMap is missing ${effort}`); } } + // 与后端 CodexModelReasoningCapability::validate 对齐: + // effortMap 每个 target 必须是 supportedEfforts 中的档位。 + // 此前只校验 key 存在性,孤儿映射(指向已移除档位)会落库后 + // 被后端拒绝并在投影时静默清空,用户手动声明"消失"。 + if (reasoning.upstream.effortMap) { + for (const [source, target] of Object.entries( + reasoning.upstream.effortMap, + )) { + if ( + target && + !reasoning.supportedEfforts.includes(target as CodexReasoningEffort) + ) { + throw new Error( + `${model}: reasoning effortMap target "${target}" (source "${source}") is not in supportedEfforts`, + ); + } + } + } } normalized.push({ From 52a6ed7032c6e0e9aaafd60d541ab1717daa4998 Mon Sep 17 00:00:00 2001 From: Shawn Pro Date: Sat, 15 Aug 2026 21:14:49 +0800 Subject: [PATCH 14/15] fix(codex): preserve reasoning declaration on model catalog refresh providerWithFetchedModelCatalog rebuilds the provider model catalog from the stored catalog on every /models fetch, but the field spread list omitted `reasoning`. User-declared reasoning levels (K3, Qwen, etc.) were silently dropped on each refresh, so fixed reasoning levels disappeared after saving the MultiRouter or refreshing the catalog. Carry `reasoning` forward from the existing provider model so manual declarations survive catalog refreshes. The rest of the sync chain (rebuildPlanModelCatalog -> buildSyncedRouteModels -> applyRouteCapabilities) already spreads the model object and preserves it. --- src/components/codex/CodexRouterWorkspacePage.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/codex/CodexRouterWorkspacePage.tsx b/src/components/codex/CodexRouterWorkspacePage.tsx index 65e06c23..83ee5f37 100644 --- a/src/components/codex/CodexRouterWorkspacePage.tsx +++ b/src/components/codex/CodexRouterWorkspacePage.tsx @@ -716,6 +716,9 @@ function providerWithFetchedModelCatalog( ? { supports_image: model.supports_image } : {}), ...(model.vision !== undefined ? { vision: model.vision } : {}), + // 模型目录刷新必须保留已有 reasoning 声明(用户手动声明的档位/能力)。 + // 否则 /models 拉取重建会把声明清空,导致档位消失(K3/Qwen 均受影响)。 + ...(model.reasoning ? { reasoning: model.reasoning } : {}), } satisfies CodexCatalogModelDraft; }); const byFetchedModel = new Map(); From c3679e277ebc382081f483212a73e2254cbd4dc1 Mon Sep 17 00:00:00 2001 From: BigStrongSun <54140710+BigStrongSun@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:14:17 +0800 Subject: [PATCH 15/15] style(rust): format merged reasoning changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the repository's current rustfmt output to the two cloud PR #9 commits after merging them into the v31 line. This is a mechanical formatting-only follow-up; combined Rust and frontend regressions passed before formatting. 本次提交由BigStrongsSun完成 --- src-tauri/src/codex_config.rs | 9 +++++++-- src-tauri/src/proxy/providers/codex.rs | 16 +++++++++------- src-tauri/src/proxy/providers/codex_reasoning.rs | 5 +---- .../src/proxy/providers/transform_codex_chat.rs | 13 +++---------- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index a80c0edd..01b98278 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -1377,7 +1377,10 @@ fn codex_official_models_cache() -> Option> { } _ => existing_cache, }?; - let models = official_cache.get("models").and_then(Value::as_array)?.clone(); + let models = official_cache + .get("models") + .and_then(Value::as_array)? + .clone(); Some(models) } @@ -6193,7 +6196,9 @@ mod tests { let sol_models = sol.as_array().unwrap().clone(); let sol_capability = official_reasoning_capability_for_model("gpt-5.6-sol", &sol_models) .expect("sol official capability"); - assert!(sol_capability.supported_efforts.contains(&"ultra".to_string())); + assert!(sol_capability + .supported_efforts + .contains(&"ultra".to_string())); // 不匹配的 slug 返回 None assert!(official_reasoning_capability_for_model("gpt-5.6-sol", &models).is_none()); } diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index bad10a26..464a6f79 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -2793,13 +2793,15 @@ mod tests { #[test] fn capability_effort_mode_keeps_wide_mappings_for_narrow_selectable() { - let capability = crate::proxy::providers::codex_reasoning::builtin_reasoning_capability_for_model( - "deepseek-v4-flash", - ) - .expect("deepseek builtin"); - let resolved = crate::proxy::providers::codex_reasoning::resolve_subagent_reasoning_capability( - Some(&capability), - ); + let capability = + crate::proxy::providers::codex_reasoning::builtin_reasoning_capability_for_model( + "deepseek-v4-flash", + ) + .expect("deepseek builtin"); + let resolved = + crate::proxy::providers::codex_reasoning::resolve_subagent_reasoning_capability(Some( + &capability, + )); let mode = encode_codex_capability_effort_mode( &resolved.codex_selectable_efforts, &resolved.effort_map, diff --git a/src-tauri/src/proxy/providers/codex_reasoning.rs b/src-tauri/src/proxy/providers/codex_reasoning.rs index 66c1dcac..b9ffddc9 100644 --- a/src-tauri/src/proxy/providers/codex_reasoning.rs +++ b/src-tauri/src/proxy/providers/codex_reasoning.rs @@ -206,10 +206,7 @@ pub fn resolve_subagent_reasoning_capability( } } - let selectable_set = provider_effort_set - .iter() - .copied() - .collect::>(); + let selectable_set = provider_effort_set.iter().copied().collect::>(); let codex_selectable_efforts = CodexReasoningEffort::ORDERED .into_iter() .filter(|effort| selectable_set.contains(effort)) diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index 6fefd62e..adf8630f 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -2571,8 +2571,7 @@ mod tests { #[test] fn capability_effort_mapping_narrow_display_wide_remap() { - let mode = - "capability|low,high,max|low=low,medium=high,high=high,xhigh=high,max=max"; + let mode = "capability|low,high,max|low=low,medium=high,high=high,xhigh=high,max=max"; // 映射档位兜底:medium/xhigh 命中 effort_map 映射,转发到上游 high assert_eq!( map_capability_reasoning_effort("medium", mode).unwrap(), @@ -2583,14 +2582,8 @@ mod tests { "high" ); // 真实档位 identity 映射 - assert_eq!( - map_capability_reasoning_effort("low", mode).unwrap(), - "low" - ); - assert_eq!( - map_capability_reasoning_effort("max", mode).unwrap(), - "max" - ); + assert_eq!(map_capability_reasoning_effort("low", mode).unwrap(), "low"); + assert_eq!(map_capability_reasoning_effort("max", mode).unwrap(), "max"); // 未知档位:无映射且不在 allowed,仍 fail closed assert!(map_capability_reasoning_effort("foo", mode).is_err()); }