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/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/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 0ac9ac34..362be8df 100644 --- a/memory.md +++ b/memory.md @@ -3491,6 +3491,16 @@ - 固定构建前完整门禁: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 工具、正常最终回答与长上下文。 +- 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`。以后版本提升必须发生在启动发布流水线之前,不能在持锁构建期间改变版本源。 ## 2026-08-15 v3.19.1-28 可信构建、事务安装与 UI 验收 diff --git a/package.json b/package.json index a1ba13ff..e14b9978 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-switch-multi", - "version": "3.19.1-28", + "version": "3.19.1-31", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "type": "module", "scripts": { 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 diff --git a/scripts/verify_qwen38_streaming.py b/scripts/verify_qwen38_streaming.py new file mode 100644 index 00000000..51a85348 --- /dev/null +++ b/scripts/verify_qwen38_streaming.py @@ -0,0 +1,110 @@ +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, + ) +) + +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") diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3077ab1c..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-28" +version = "3.19.1-31" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 28dd50c2..8a2e6f9e 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-31" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" authors = ["Jason Young"] license = "MIT" 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/codex_subagent_profiles.rs b/src-tauri/src/codex_subagent_profiles.rs index b2c7d09e..15dc22c0 100644 --- a/src-tauri/src/codex_subagent_profiles.rs +++ b/src-tauri/src/codex_subagent_profiles.rs @@ -1098,13 +1098,42 @@ 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); - let mut chars = source.chars(); - chars + +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| { + 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() + .map(|character| character.to_uppercase().collect::() + chars.as_str()) + .unwrap_or_default(); + 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 { @@ -1165,10 +1194,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), @@ -1934,6 +1960,73 @@ 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 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"])); diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index e6556375..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>, @@ -9432,6 +9467,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, true)); + } + + #[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, true)); + } + } + + #[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, false)); + } + /// 验证 hosted web_search loop 会消费第一轮工具调用、回灌 tool output 并返回最终 Chat 响应。 #[tokio::test] async fn hosted_web_search_loop_appends_tool_output_and_marks_response() { 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()); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a1491c75..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-28", + "version": "3.19.1-31", "identifier": "com.ccswitchmulti.desktop", "build": { "frontendDist": "../dist",