From 4f5250fc4dac72f2f34b516db5527be3e3ef243b Mon Sep 17 00:00:00 2001 From: cc10143 <58852033+cc10143@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:38:39 +0800 Subject: [PATCH 01/66] fix(proxy): strip cache_control from OpenAI format conversion (#3841) * fix(proxy): strip cache_control from OpenAI format conversion (#3805) - Remove cache_control passthrough from system messages, text blocks, and tools to prevent 400 errors on strict OpenAI-compatible endpoints - Always simplify single text block content to plain string format - Fixes two format conversion bugs reported in issue #3805 * fix(proxy): apply cargo fmt to fix CI formatting check --- src-tauri/src/proxy/providers/transform.rs | 157 ++++++++++++--------- 1 file changed, 94 insertions(+), 63 deletions(-) diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 56b25d726..5349d6701 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -142,18 +142,13 @@ pub fn anthropic_to_openai_with_reasoning_content( messages.push(json!({"role": "system", "content": text})); } } else if let Some(arr) = system.as_array() { - // 多个 system message — preserve cache_control for compatible proxies for msg in arr { if let Some(text) = msg.get("text").and_then(|t| t.as_str()) { let text = strip_leading_anthropic_billing_header(text); if text.is_empty() { continue; } - let mut sys_msg = json!({"role": "system", "content": text}); - if let Some(cc) = msg.get("cache_control") { - sys_msg["cache_control"] = cc.clone(); - } - messages.push(sys_msg); + messages.push(json!({"role": "system", "content": text})); } } } @@ -207,18 +202,14 @@ pub fn anthropic_to_openai_with_reasoning_content( .iter() .filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool")) .map(|t| { - let mut tool = json!({ + json!({ "type": "function", "function": { "name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""), "description": t.get("description"), "parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({}))) } - }); - if let Some(cc) = t.get("cache_control") { - tool["cache_control"] = cc.clone(); - } - tool + }) }) .collect(); @@ -294,10 +285,6 @@ fn normalize_openai_system_messages(messages: &mut Vec) { } let mut parts = Vec::new(); - let mut inherited_cache_control: Option = None; - let mut cache_control_conflict = false; - let mut saw_cache_control = false; - let mut saw_missing_cache_control = false; messages.retain(|message| { if message.get("role").and_then(|value| value.as_str()) != Some("system") { return true; @@ -318,28 +305,11 @@ fn normalize_openai_system_messages(messages: &mut Vec) { _ => {} } - if let Some(cache_control) = message.get("cache_control") { - saw_cache_control = true; - match &inherited_cache_control { - None => inherited_cache_control = Some(cache_control.clone()), - Some(existing) if existing == cache_control => {} - Some(_) => cache_control_conflict = true, - } - } else { - saw_missing_cache_control = true; - } - false }); if !parts.is_empty() { - let mut merged = json!({"role": "system", "content": parts.join("\n")}); - if !(cache_control_conflict || (saw_cache_control && saw_missing_cache_control)) { - if let Some(cache_control) = inherited_cache_control { - merged["cache_control"] = cache_control; - } - } - messages.insert(0, merged); + messages.insert(0, json!({"role": "system", "content": parts.join("\n")})); } } @@ -379,11 +349,7 @@ fn convert_message_to_openai( match block_type { "text" => { if let Some(text) = block.get("text").and_then(|t| t.as_str()) { - let mut part = json!({"type": "text", "text": text}); - if let Some(cc) = block.get("cache_control") { - part["cache_control"] = cc.clone(); - } - content_parts.push(part); + content_parts.push(json!({"type": "text", "text": text})); } } "image" => { @@ -458,14 +424,9 @@ fn convert_message_to_openai( if content_parts.is_empty() { msg["content"] = Value::Null; } else if content_parts.len() == 1 { - // When cache_control is present, keep array format to preserve it - let has_cache_control = content_parts[0].get("cache_control").is_some(); - if !has_cache_control { - if let Some(text) = content_parts[0].get("text") { - msg["content"] = text.clone(); - } else { - msg["content"] = json!(content_parts); - } + // 单 text block 简化为纯字符串 + if let Some(text) = content_parts[0].get("text") { + msg["content"] = text.clone(); } else { msg["content"] = json!(content_parts); } @@ -829,7 +790,7 @@ mod tests { } #[test] - fn test_anthropic_to_openai_preserves_matching_system_cache_control_when_merging() { + fn test_anthropic_to_openai_strips_cache_control_from_merged_system() { let input = json!({ "model": "claude-3-sonnet", "max_tokens": 1024, @@ -847,12 +808,12 @@ mod tests { result["messages"][0]["content"], "You are Claude Code.\nBe concise." ); - assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral"); + assert!(result["messages"][0].get("cache_control").is_none()); assert_eq!(result["messages"][1]["role"], "user"); } #[test] - fn test_anthropic_to_openai_drops_mixed_present_absent_system_cache_control_when_merging() { + fn test_anthropic_to_openai_strips_cache_control_from_mixed_system() { let input = json!({ "model": "claude-3-sonnet", "max_tokens": 1024, @@ -873,7 +834,7 @@ mod tests { } #[test] - fn test_anthropic_to_openai_drops_conflicting_system_cache_control_when_merging() { + fn test_anthropic_to_openai_strips_cache_control_from_conflicting_system() { let input = json!({ "model": "claude-3-sonnet", "max_tokens": 1024, @@ -1199,7 +1160,7 @@ mod tests { } #[test] - fn test_anthropic_to_openai_cache_control_preserved() { + fn test_anthropic_to_openai_strips_all_cache_control() { let input = json!({ "model": "claude-3-opus", "max_tokens": 1024, @@ -1221,19 +1182,89 @@ mod tests { }); let result = anthropic_to_openai(input).unwrap(); - // System message cache_control preserved - assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral"); - // Text block cache_control preserved - assert_eq!( - result["messages"][1]["content"][0]["cache_control"]["type"], - "ephemeral" + // System message: no cache_control + assert!(result["messages"][0].get("cache_control").is_none()); + // User message: content simplified to string (no cache_control → flat string) + assert_eq!(result["messages"][1]["content"], "Hello"); + // Tool: no cache_control + assert!(result["tools"][0].get("cache_control").is_none()); + } + + /// 精确复现 Issue #3805 报告的 400 错误场景: + /// GLM/Qwen 等严格校验模型拒绝 cache_control 和 content 数组格式 + #[test] + fn test_regression_gh3805_no_cache_control_leak_to_openai() { + let input = json!({ + "model": "glm-5.1", + "max_tokens": 1024, + "system": [ + {"type": "text", "text": "You are helpful.", "cache_control": {"type": "ephemeral"}} + ], + "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral"}} + ]} + ], + "tools": [{ + "name": "search", + "description": "Search the web", + "input_schema": {"type": "object"}, + "cache_control": {"type": "ephemeral"} + }] + }); + + let result = anthropic_to_openai(input).unwrap(); + + // 验证: messages 中不存在 cache_control + for (i, msg) in result["messages"].as_array().unwrap().iter().enumerate() { + assert!( + msg.get("cache_control").is_none(), + "messages[{i}] must not have cache_control" + ); + } + + // 验证: content 中没有 cache_control + for (i, msg) in result["messages"].as_array().unwrap().iter().enumerate() { + if let Some(content) = msg.get("content") { + assert!( + !content.is_array() + || content + .as_array() + .unwrap() + .iter() + .all(|part| part.get("cache_control").is_none()), + "messages[{i}] content parts must not have cache_control" + ); + } + } + + // 验证: system content 为纯字符串格式(不是数组) + let sys_msg = &result["messages"][0]; + assert_eq!(sys_msg["role"], "system"); + assert!( + sys_msg["content"].is_string(), + "system content must be string, got: {}", + sys_msg["content"] ); - assert_eq!( - result["messages"][1]["content"][0]["cache_control"]["ttl"], - "5m" + + // 验证: user content 为纯字符串格式(不是数组) + let user_msg = &result["messages"][1]; + assert_eq!(user_msg["role"], "user"); + assert!( + user_msg["content"].is_string(), + "user content must be string, got: {}", + user_msg["content"] ); - // Tool cache_control preserved - assert_eq!(result["tools"][0]["cache_control"]["type"], "ephemeral"); + + // 验证: tools 中不存在 cache_control + if let Some(tools) = result["tools"].as_array() { + for (i, tool) in tools.iter().enumerate() { + assert!( + tool.get("cache_control").is_none(), + "tools[{i}] must not have cache_control" + ); + } + } } #[test] From f1118d370f5898916f6e431479f2baf6f1e340af Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 8 Jun 2026 12:14:49 +0800 Subject: [PATCH 02/66] chore(release): prepare v3.16.2 Add the v3.16.2 CHANGELOG entry covering the 41 commits since v3.16.1, bump the version across package.json, tauri.conf.json, Cargo.toml, and Cargo.lock, and add trilingual (zh/en/ja) release notes. --- CHANGELOG.md | 58 ++++++ docs/release-notes/v3.16.2-en.md | 347 +++++++++++++++++++++++++++++++ docs/release-notes/v3.16.2-ja.md | 347 +++++++++++++++++++++++++++++++ docs/release-notes/v3.16.2-zh.md | 347 +++++++++++++++++++++++++++++++ package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 8 files changed, 1103 insertions(+), 4 deletions(-) create mode 100644 docs/release-notes/v3.16.2-en.md create mode 100644 docs/release-notes/v3.16.2-ja.md create mode 100644 docs/release-notes/v3.16.2-zh.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af8ef1e3..fa6bdd3fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,64 @@ All notable changes to CC Switch will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.16.2] - 2026-06-07 + +Development since v3.16.1 focuses on broadening data portability and usage observability — S3-compatible cloud sync, OpenCode session usage import, and an opt-in official-subscription quota template — while hardening Codex Chat Completions routing (stream truncation, `tool_choice` / custom-tool / reasoning-token edge cases, file and audio attachments, and a Codex CLI models endpoint), strengthening proxy robustness (ephemeral ports, takeover/placeholder restore, system-message normalization, clearer upstream errors, and a text-only image fallback), fixing coding-plan quota lookups (Zhipu, MiniMax) and several Windows/macOS issues, adding the CherryIN and ZenMux providers, and refreshing the user manual. + +**Stats**: 41 commits | 132 files changed | +11,116 insertions | -1,636 deletions + +### Added + +- **S3-Compatible Cloud Sync**: Cloud Sync now supports S3-compatible object storage as a second backend alongside WebDAV, using hand-rolled AWS Signature V4 signing for broad compatibility. The settings panel offers one-click presets for AWS S3, MinIO, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS, and Huawei OBS plus a custom endpoint, with connection testing, manual upload/download, and auto-sync on configuration changes (provider, endpoint, MCP, prompt, skill, settings, and proxy tables — not high-frequency data like usage logs); enabling S3 sync disables active WebDAV sync and vice versa (#1351). +- **OpenCode Session Usage Sync**: Added OpenCode as a usage-statistics source that imports per-message token, cost, and model data from OpenCode's local SQLite database, with a new "OpenCode" app filter tab and an "OpenCode Session" data-source label. The database path respects `OPENCODE_DB` and `XDG_DATA_HOME` (defaulting to `~/.local/share/opencode` on all platforms), only finalized messages are imported, and the freshness check accounts for the WAL file so newly written sessions are not skipped (#3215). +- **Official Subscription Quota Template**: Added an explicit, opt-in "official subscription" usage template for Claude, Codex, and Gemini official providers that queries plan quota via CLI/OAuth credentials, replacing the previous implicit auto-query. It is disabled by default and enabled from the usage-script modal with a configurable refresh interval. +- **Unsupported Image Fallback Rectifier**: Added a proxy rectifier that replaces Anthropic image blocks with an `[Unsupported Image]` marker when the routed model is text-only (declared, or detected via a built-in model-name heuristic) or when the upstream rejects image input, so conversations are not interrupted. A new Settings toggle controls the fallback, with a separate toggle for the heuristic detection. +- **ZenMux Token Plan Provider**: Added ZenMux as a Token Plan coding-plan provider that accepts a manually entered API key and base URL in the usage-script modal and renders its quota with USD-denominated used / limit values (#2709). +- **CherryIN Preset**: Added the CherryIN aggregator gateway as a quick-config preset across all seven supported apps — Anthropic-format endpoint for Claude Code / Claude Desktop / OpenClaw / Hermes, `@ai-sdk/anthropic` for OpenCode, the OpenAI-compatible endpoint for Codex, and the Gemini-compatible endpoint for Gemini CLI — with the official brand icon, placed next to AiHubMix (#3643). +- **Codex CLI Models Endpoint**: The local proxy now answers `GET /v1/models`, which Codex CLI probes at startup, returning the cc-switch-managed Codex model catalog. A stale-catalog guard parses the live `config.toml` and only serves the catalog when `model_catalog_json` still references the cc-switch-owned file, so a leftover catalog from a previous provider is not advertised (#3818). +- **Codex Chat File and Audio Attachments**: The Codex Responses-to-Chat converter now maps `input_file` parts (carrying `file_id` or inline `file_data`) and `input_audio` parts into their Chat Completions equivalents, and emits top-level `input_*` items that were previously dropped, so file and audio attachments reach Chat-only Codex upstreams. + +### Changed + +- **Usage Dashboard Hero Redesign**: Restructured the Usage Dashboard hero and summary cards into a more compact layout, consolidating the real-token total, request count, and cost into a single top row (#3426). +- **SSSAiCode Endpoint Refresh**: Updated the SSSAiCode preset's website, signup, and API base URLs to the `sssaicodeapi.com` domain and refreshed its endpoint nodes (default `node-hk.sssaicodeapi.com`, plus `node-hk.sssaiapi.com` and `node-cf.sssaicodeapi.com`) across all seven app presets. + +### Fixed + +- **Codex Chat Truncated Stream Detection**: When a Chat Completions upstream ends a stream without a `finish_reason` or `[DONE]`, CC Switch no longer reports it as a normal completion — it finalizes normally only when the stream truly finished, emits an incomplete (`max_output_tokens`) response when partial output was produced, and emits a failed `stream_truncated` event when nothing was produced. Late-arriving reasoning is also attached to still-active streamed tool calls. +- **Codex Chat `tool_choice` Without Tools**: The Responses-to-Chat converter now drops `tool_choice` and `parallel_tool_calls` whenever the resulting tools array is absent or empty, so strict OpenAI-compatible upstreams (vLLM, enterprise gateways) no longer reject the request with "When using `tool_choice`, `tools` must be set." (#3640). +- **Codex Custom Tool Metadata Over Chat Routing**: Custom Codex tools (such as the freeform `apply_patch` tool) now preserve their full original definition — including format and grammar metadata — as a compact, order-stable JSON block in the generated Chat function description instead of a generic placeholder, keeping them usable on Chat Completions upstreams (#3644). +- **Codex Chat `reasoning_tokens` in Usage**: The Chat-to-Responses usage conversion now always includes `output_tokens_details.reasoning_tokens` (defaulting to 0), even when a provider omits `completion_tokens_details` or returns it as a non-object, satisfying the Codex CLI's strict requirement and avoiding repeated parse failures and retries (#3514). +- **Codex Cross-Turn Reasoning for Custom and Search Tools**: The cross-turn reasoning cache in Codex Chat history now covers the full tool-call set (`function_call`, `custom_tool_call`, `tool_search_call`) and their outputs, so `apply_patch` and tool-search calls keep their `reasoning_content` when restored via `previous_response_id`. +- **Ephemeral Proxy Port Resolution**: When the proxy listens on port 0 (OS-assigned), takeover now starts the proxy first to learn the real port and writes it into the Live configs and database, so client URLs no longer point at a broken `:0` address; the Claude Desktop gateway URL is rejected if no concrete port has been resolved. +- **Proxy Placeholder Backup/Restore Loop**: If a previous proxy stop left the proxy placeholders in Live, taking over again no longer overwrites a good backup with the proxy config, and restore no longer writes the placeholder back to Live — both paths detect the placeholder state and rebuild Live from the current provider, fixing cases where the proxy toggle became a no-op and clients stayed pinned to the local proxy (#3689). +- **Official Provider Block Under Proxy Takeover**: While Local Routing takeover is active, only providers explicitly categorized as official are blocked from switching, instead of also disabling custom providers whose endpoint lives in metadata or whose fields are unfilled. The disabled Enable button now shows a lighter hint tooltip in place of the red "Blocked" badge. +- **Localhost Listen Address Normalization**: Saving the proxy with a listen address of `localhost` now normalizes it to `127.0.0.1` before persisting, avoiding binding inconsistencies (#3016). +- **Anthropic System Message Normalization**: For Anthropic-format providers, system-role entries inside the `messages` array are collapsed and merged into the top-level `system` field (preserving order and any existing top-level system), preventing strict upstreams from rejecting non-leading system messages; OpenAI Chat routing is untouched (#3775). +- **Claude Desktop 1M-Context Model Routing**: Claude Desktop appends a `[1m]` marker to the model name when the 1M-context beta is active (e.g. `claude-opus-4-8[1m]`). The proxy now strips that suffix before route lookup so exact, alias, legacy, and role-keyword matching resolve correctly, fixing `route_unknown` (HTTP 400) failures when switching to a 1M-capable model mid-conversation. +- **Codex 413 Error Clarity**: When a Codex upstream gateway rejects an oversized request with HTTP 413, the proxy now returns a dedicated message identifying it as the provider's server-side body-size limit (not a CC Switch limit) with recovery steps (run `/compact`, drop large logs or inline images, or ask the provider to raise its limit), instead of echoing the raw upstream HTML page. +- **Proxy Panel Error Detail**: When toggling proxy takeover fails, the proxy panel toast now includes the underlying backend error detail instead of only a generic failure message (#3656). +- **Copilot Infinite-Whitespace Threshold**: Raised the streaming infinite-whitespace abort threshold from 20 to 500 consecutive whitespace characters, so legitimate tool calls with deeply indented code arguments are no longer falsely aborted while still catching the real Copilot infinite-whitespace bug (#2647). +- **Subscription Tier Tray Rendering**: Fixed tray and quota rendering for official subscription tiers via a unified tier-to-label mapping: Claude/Codex no longer drop the seven-day window, Gemini Pro/Flash/Flash-Lite tiers no longer leak raw machine names, and multi-window plans (e.g. Opus + Sonnet) now display the worst utilization instead of the first match. +- **Inflated Claude Stream Input Tokens**: Some Anthropic-compatible streaming providers (e.g. Qwen, MiniMax) report the full context as `input_tokens` in `message_start`, double-counting the cached portion and artificially lowering the displayed cache hit rate. The parser now prefers a smaller positive `input_tokens` from `message_delta` and adopts the paired cache counts from the same usage block; native Claude and OpenRouter-converted paths are unchanged. +- **Zhipu Quota Query Endpoint Routing**: The Zhipu coding-plan quota lookup was hard-coded to `api.z.ai`, so users on the mainland China preset (`open.bigmodel.cn`) could not retrieve usage when the international endpoint was unreachable. The quota request now routes to the host matching the user's configured base URL (#3702). +- **MiniMax Balance API and Pricing**: Adapted MiniMax coding-plan quota to its new balance API (which returns remaining-percent fields instead of usage counts that broke the old parser and left the tray blank), filtered out non-coding models (e.g. video), handled plans without a weekly limit, and seeded default pricing for MiniMax M3 (#3518). +- **GLM Coding Plan Endpoints and Model Fetch**: Corrected the ZhiPu / Z.AI GLM Coding Plan presets to the `/api/coding/paas/v4` endpoints across Codex, OpenCode, OpenClaw, and Hermes, and taught the model-list probe to query `{base}/models` for base URLs that already end in a `/v{N}` segment (keeping `/v1/models` as a fallback), so the Fetch Models button no longer 404s on versioned endpoints (#3524). +- **Codex Model Catalog Path Portability**: Codex now writes only the relative filename `cc-switch-model-catalog.json` to `config.toml` instead of an absolute path (Codex CLI resolves it from the config directory), fixing the model catalog breaking on WSL and symlinked setups where the absolute path could not be translated (#3614). +- **APINebula OpenCode SDK**: The APINebula OpenCode preset now loads `@ai-sdk/openai-compatible` instead of `@ai-sdk/openai`, so requests use the OpenAI Chat Completions format the relay expects rather than the Responses API. +- **Windows Tray Icon Residue on Exit**: Quitting CC Switch on Windows could leave a dead tray icon until hovered; the app now removes the tray icon before exiting so it disappears cleanly (#3797). +- **Windows Taskbar Icon**: Set an explicit Windows AppUserModelID at runtime and stamped the installer's desktop and start-menu shortcuts with the same ID and product icon, so CC Switch shows the correct icon and groups properly in the taskbar (#3457). +- **Windows Subdirectory Skill Updates**: Normalized backslash path separators to forward slashes when scanning installed skills on Windows, so skills nested in subdirectories (e.g. `skills/my-skill`) are matched by the update check instead of being silently skipped (#3430). +- **macOS Input Auto-Capitalization**: Disabled autocomplete, autocorrect, autocapitalize, and spellcheck on the shared text Input component so macOS no longer auto-capitalizes or auto-corrects the first letter typed into configuration fields (#3626). +- **Codex VS Code Session Previews**: Codex session previews for requests sent from VS Code could show selection or open-file content instead of the prompt when a markdown heading preceded the injected request. Both the backend title and frontend preview now match the last "## My request for Codex:" heading (the IDE injects the real request as the final section) (#3593). +- **VS Code Wording in Chinese UI**: Corrected the "Apply to Claude Code plugin" description in the Simplified and Traditional Chinese locales to write "VS Code" properly instead of "Vscode", aligning with the English and Japanese strings (#3228). + +### Docs + +- **User Manual Refresh**: Refreshed the README locales and the en / zh / ja user manuals to reflect all seven supported apps (adding Claude Desktop and Hermes), corrected the OpenCode config path to `~/.config/opencode/` (`opencode.json`), documented Hermes configuration files, updated the language docs to four languages, revised per-app MCP / Prompts / Skills availability, noted that export produces a timestamped SQL backup including usage logs, and documented the pricing model-ID matching rules (#3411). +- **Codex Official Auth Preservation Guide**: Added a trilingual (en / zh / ja) guide explaining how to keep Codex official remote control and plugins working while routing model traffic to third-party APIs, and linked it from the v3.16.1 release notes. +- **README Release-Note Links and Sponsor Markup**: Updated the Release Notes links in all README locales to point at v3.16.1 and fixed broken smart-quote characters in the README_ZH sponsor blocks so their HTML attributes render correctly (#3772). + ## [3.16.1] - 2026-06-01 Development since v3.16.0 focuses on hardening Codex provider switching and Local Routing takeover: preserving official OAuth auth and model catalogs across normal switches, hot-switches, backup restore, and edit flows; restoring Codex Chat tool/plugin compatibility over Chat Completions upstreams; improving Codex proxy diagnostics and CLI discovery; and documenting DeepSeek routing. diff --git a/docs/release-notes/v3.16.2-en.md b/docs/release-notes/v3.16.2-en.md new file mode 100644 index 000000000..0fc440e96 --- /dev/null +++ b/docs/release-notes/v3.16.2-en.md @@ -0,0 +1,347 @@ +# CC Switch v3.16.2 + +> Following the v3.16.1 Codex stability patch, this release mainly broadens data portability and usage observability — adding S3-compatible cloud sync, OpenCode session usage sync, and an official-subscription quota template — while continuing to harden Codex's Chat Completions routing for third-party providers, fixing a batch of Windows / macOS platform issues, adding the CherryIN and ZenMux providers, and fully refreshing the trilingual user manual. + +**[中文版 →](v3.16.2-zh.md) | [日本語版 →](v3.16.2-ja.md)** + +--- + +## Usage Guides + +This release adds an S3 backend for cloud sync and more usage data sources. If you want to use them, start with these docs: + +- **[Settings](../user-manual/en/1-getting-started/1.5-settings.md)**: configure cloud sync (WebDAV / S3-compatible storage) on the settings page to back up and restore providers, MCP, prompts, skills, and other config across multiple devices. +- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: understand the Usage Dashboard's data sources (proxy logs, Codex / Gemini / OpenCode session sync) and how the statistics are counted. + +--- + +> [!WARNING] +> +> ## Only Official Channels (Please Read) +> +> CC Switch is a **fully free and open-source** desktop app, and we **do not charge users any fees**. Please only obtain the software through the official channels listed below: +> +> | Channel | Only Official | +> | ------------------ | ------------------------------------------------------------------------------ | +> | Website | **[ccswitch.io](https://ccswitch.io)** | +> | Source | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** | +> | Downloads | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** | +> | Author | **[@farion1231](https://github.com/farion1231)** | +> | Report an Imposter | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** | +> +> **Any "CC Switch" website or client that asks you for payment, top-ups, or login credentials is fake.** If you have been tricked into paying, stop the transaction immediately and file a report through GitHub Issues. + +--- + +## Overview + +CC Switch v3.16.2 is a maintenance update following v3.16.1. After the previous release focused on the security of Codex official authentication and local routing takeover, this release concentrates on two things. First, broadening data portability and usage observability — adding S3-compatible cloud sync (a second cloud-backup backend alongside WebDAV), OpenCode session usage sync, and a quota-statistics template for official subscriptions. Second, continuing to polish the edges exposed when Codex routes third-party providers through Chat Completions — stream-truncation detection, `tool_choice` when tools is empty, custom-tool metadata, reasoning-token statistics, file / audio attachment conversion, and more. + +This release also fixes a batch of local proxy robustness issues (ephemeral port resolution, the takeover placeholder restore loop, Anthropic `system` message normalization, the upstream 413 message, and Claude Desktop's `[1m]` model routing), addresses several Windows / macOS platform experience issues, adds the CherryIN and ZenMux providers, and fully refreshes the trilingual user manual. + +**Release date**: 2026-06-07 + +**Stats**: 41 commits | 132 files changed | +11,116 / -1,636 lines + +--- + +## Highlights + +- **S3-compatible cloud sync**: adds S3-compatible object storage as a second cloud-backup backend alongside WebDAV, with one-click presets for AWS S3, MinIO, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS, Huawei OBS, and more. +- **More usage data sources**: added OpenCode session usage sync, plus an official-subscription quota template for Claude / Codex / Gemini official providers (explicit toggle, off by default). +- **Continued Codex Chat Completions routing hardening**: fixed stream-truncation misdetection, `tool_choice` rejection when tools is empty, custom-tool metadata loss, and missing reasoning-token stats, and added file / audio attachment conversion plus a `/v1/models` reachability endpoint. +- **A more robust local proxy**: fixed ephemeral port (port 0) resolution, the takeover placeholder restore loop, Anthropic `system` message normalization, the upstream 413 message, and Claude Desktop 1M-context model routing. +- **Platform and providers**: fixed Windows tray / taskbar icons, subdirectory skill updates, and macOS input auto-capitalization, and added the CherryIN and ZenMux providers. + +--- + +## Added + +### S3-Compatible Cloud Sync + +Cloud Sync now supports S3-compatible object storage as a second backend alongside WebDAV, signing requests with a self-implemented AWS Signature V4 for the broadest possible compatibility. The settings page offers one-click presets for AWS S3, MinIO, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS, Huawei OBS, and a custom endpoint, with connection testing, manual upload / download, and auto-sync on configuration changes (the providers, endpoint, MCP, prompt, skill, settings, and proxy tables — **not** high-frequency data like usage logs). Enabling S3 sync disables a running WebDAV sync and vice versa (#1351). + +### OpenCode Session Usage Sync + +Added OpenCode as a usage-statistics source that reads per-message token, cost, and model data from OpenCode's local SQLite database and imports it into the usage records, with a dedicated "OpenCode" app filter tab and an "OpenCode Session" data-source label. The database path respects `OPENCODE_DB` and `XDG_DATA_HOME` (defaulting to `~/.local/share/opencode` on all platforms), only finalized messages are imported, and the freshness check includes the WAL file so just-written sessions are not skipped (#3215). + +### Official Subscription Quota Template + +Because some users were concerned that the IP issuing the usage query could differ from the IP issuing in-app requests, risking an account ban, the official-subscription usage template for Claude / Codex / Gemini official providers is now an explicit, opt-in template that queries plan quota via CLI / OAuth credentials, replacing the previous implicit auto-query for official providers. The template is off by default, is enabled from the usage-script modal, and supports a configurable refresh interval. When using this feature, enabling the proxy's TUN mode is recommended. + +### Text-Only Model Image Fallback Rectifier + +Added a proxy rectifier that replaces Anthropic image blocks with an `[Unsupported Image]` placeholder when the routed model is text-only (declared, or detected by a built-in model-name heuristic) or the upstream rejects image input, so conversations are not interrupted. The settings page provides a toggle for this fallback, plus a separate toggle for the heuristic detection (which can be turned off to avoid misjudging multimodal models). + +### ZenMux Token Plan Provider + +Added ZenMux as a Token Plan Coding Plan provider. You can manually enter its API key and base URL in the usage-script modal, and it renders used / quota in USD (#2709). + +### CherryIN Preset + +Added the CherryIN aggregator gateway as a quick-config preset across all 7 managed apps — Claude Code / Claude Desktop / OpenClaw / Hermes use the Anthropic-format endpoint (open.cherryin.net), OpenCode uses `@ai-sdk/anthropic` (`/v1`), Codex uses the OpenAI-compatible endpoint, and Gemini CLI uses the Gemini-compatible endpoint — with the official brand icon, placed next to AiHubMix (#3643). + +### Codex CLI Reachability Endpoint `/v1/models` + +The local proxy now responds to `GET /v1/models`, which Codex CLI probes at startup, returning the CC Switch-managed Codex model catalog. A stale-catalog guard was added: it parses the live `config.toml` and only serves the catalog when `model_catalog_json` still points at the CC Switch-owned catalog file, avoiding exposing a previous provider's leftover catalog to Codex (#3818). + +### Codex Chat File and Audio Attachments + +Codex's Responses→Chat conversion now maps `input_file` parts (carrying `file_id` or inline `file_data`) and `input_audio` parts into their Chat Completions equivalents, and emits top-level `input_*` items that were previously dropped, so file and audio attachments reach Chat-only Codex upstreams. + +--- + +## Changed + +### Usage Dashboard Hero Redesign + +Rearranged the Usage Dashboard hero and summary cards into a more compact layout, consolidating the real-token total, request count, and cost into a single top row (#3426). + +### SSSAiCode Endpoint Refresh + +Updated the SSSAiCode preset's website, signup, and API base URLs to the `sssaicodeapi.com` domain, and refreshed its candidate endpoint nodes (default `node-hk.sssaicodeapi.com`, plus `node-hk.sssaiapi.com` and `node-cf.sssaicodeapi.com`) across all 7 app presets. + +--- + +## Fixed + +### Codex Chat Stream Truncation Detection + +When a Chat Completions upstream ends a stream without a `finish_reason` or `[DONE]`, CC Switch no longer treats it as a normal completion: it finalizes normally only when the stream truly ended; emits an incomplete (`max_output_tokens`) response when partial output was produced; and emits a failed `stream_truncated` event when nothing was produced. Late-arriving reasoning is also backfilled onto still-active streaming tool calls. + +### Codex Chat `tool_choice` Without Tools + +The Responses→Chat conversion now drops `tool_choice` and `parallel_tool_calls` when the final tools array is missing or empty (including when all tools are filtered out), avoiding 503/400 errors from strict OpenAI-compatible upstreams (vLLM, enterprise gateways) with "When using `tool_choice`, `tools` must be set." (#3640). + +### Codex Custom Tool Metadata Preserved + +Custom Codex tools (such as the freeform `apply_patch` tool) now embed their full original definition — including format and grammar metadata — as a compact, order-stable JSON block in the generated Chat function description, instead of being replaced with a generic placeholder, so they remain usable on Chat Completions upstreams (#3644). + +### Codex Chat Usage Missing `reasoning_tokens` + +The Chat→Responses usage conversion now always includes `output_tokens_details.reasoning_tokens` (defaulting to 0), even when a provider omits `completion_tokens_details` or returns a non-object, satisfying Codex CLI's strict requirement and avoiding repeated response-parse failures and retries (#3514). + +### Cross-Turn Reasoning for Codex Custom / Search Tools + +The cross-turn reasoning cache in Codex Chat history now covers the full tool-call set (`function_call`, `custom_tool_call`, `tool_search_call`) and their outputs, not just plain function calls, so `apply_patch` and tool-search calls keep their own `reasoning_content` when restored via `previous_response_id`. + +### Ephemeral Port (port 0) Resolution + +When the proxy is configured to listen on port 0 (OS-assigned), takeover now starts the proxy first to obtain the real port before writing live configs and the database, avoiding client URLs pointing at an invalid `:0` address; if no concrete port has been resolved yet, the Claude Desktop gateway URL is rejected outright. + +### Proxy Placeholder Backup / Restore Loop + +If a previous proxy stop failed to restore the original live config and left proxy placeholders in live, taking over again no longer overwrites the good backup with the proxy config, and restore no longer writes the placeholder back to live: both paths detect the placeholder state and rebuild live from the current provider as the source of truth, fixing cases where the proxy toggle became a no-op and the client was pinned to the local proxy address (#3689). + +### Provider Switching Wrongly Blocked During Proxy Takeover + +During local routing takeover, only providers explicitly classified as official are now blocked from switching, instead of also disabling custom providers whose endpoint lives in meta or whose fields are simply unfilled. The disabled "Enable" button now shows a lighter hint tooltip instead of the previous red "Blocked" badge. + +### localhost Listen Address Normalization + +When saving the proxy with a listen address of `localhost`, it is now normalized to `127.0.0.1` before persisting, avoiding binding inconsistencies (#3016). + +### Anthropic `system` Message Normalization + +For Anthropic-format providers, system-role entries inside the `messages` array are now collapsed and merged into the top-level `system` field (preserving original order and any existing top-level system), avoiding strict upstreams rejecting non-leading system messages; OpenAI Chat routing is unaffected (#3775). + +### Claude Desktop 1M-Context Model Routing + +Claude Desktop appends a `[1m]` marker to the model name when the 1M-context beta is active (e.g. `claude-opus-4-8[1m]`). The proxy now strips that suffix before route matching so exact, alias, legacy, and role-keyword matching all resolve correctly, fixing `route_unknown` (HTTP 400) failures when switching to a 1M model mid-conversation; the original model name is still kept in the `route_unknown` error for diagnostics. + +### Codex 413 Error Message + +When a Codex upstream gateway rejects an oversized request body with HTTP 413, the proxy now returns a dedicated message explaining that this is the provider's server-side body-size limit (not a CC Switch local limit), with actionable recovery steps (run `/compact`, remove large logs or inline images, or ask the provider to raise the limit), instead of echoing the upstream's raw HTML error page. + +### Proxy Panel Error Detail + +When toggling proxy takeover fails, the proxy panel toast now includes the specific error detail returned by the backend, instead of only a generic failure message (#3656). + +### Copilot Infinite-Whitespace Threshold + +Raised the streaming infinite-whitespace abort threshold from 20 to 500 consecutive whitespace characters, avoiding false aborts of legitimate tool calls whose arguments contain deeply indented code (Python, YAML, Rust, Markdown), while still catching the real Copilot infinite-whitespace bug (#2647). + +### Subscription Tier Tray Rendering + +Via a unified tier-to-label mapping, fixed rendering of official subscription tiers in the tray and quota display: Claude / Codex no longer drop the 7-day window, Gemini Pro / Flash / Flash-Lite tiers no longer leak raw machine names, and multi-window plans (e.g. Opus + Sonnet) now show the worst utilization instead of the first match. + +### Inflated Claude Stream input_tokens + +Some Anthropic-compatible streaming providers (e.g. Qwen, MiniMax) report the full context as `input_tokens` in `message_start`, double-counting the cached portion already reported separately and artificially lowering the displayed cache hit rate. The parser now prefers the smaller positive `input_tokens` from `message_delta` and adopts the paired cache counts from the same usage block; native Claude and OpenRouter-converted paths are unchanged. + +### Zhipu Quota Query Endpoint Routing + +The Zhipu Coding Plan quota query was hard-coded to `api.z.ai`, so users on the mainland preset (`open.bigmodel.cn`) could not retrieve usage when the international endpoint was unreachable. The quota request now routes to the host matching the user's configured base URL (#3702). + +### MiniMax Balance API and Pricing + +Adapted MiniMax Coding Plan quota to its new balance API (which returns remaining-percent fields instead of the usage counts the old parser relied on, which left tiers empty and the tray showing no usage), filtered out non-coding models (such as video), handled plans without a weekly limit, and added default pricing for the MiniMax M3 model (#3518). + +### GLM Coding Plan Endpoints and Model Fetch + +Fixed the Zhipu / Z.AI GLM Coding Plan presets to the `/api/coding/paas/v4` endpoints (covering Codex, OpenCode, OpenClaw, Hermes), and made the model-list probe query `{base}/models` first for base URLs that already end in a `/v{N}` version segment (keeping `/v1/models` as a fallback), so the "Fetch models" button no longer 404s on versioned endpoints (#3524). + +### Codex Model Catalog Path Portability + +Codex now writes only the relative filename `cc-switch-model-catalog.json` to `config.toml` instead of an absolute path (Codex CLI resolves it from the config directory), fixing the model catalog breaking on WSL and symlinked setups where the absolute path could not be translated (#3614). + +### APINebula's OpenCode SDK + +The APINebula OpenCode preset now loads `@ai-sdk/openai-compatible` instead of `@ai-sdk/openai`, so requests use the OpenAI Chat Completions format the relay expects, rather than the Responses API that fails against chat-completions-only upstreams. + +### Windows Tray Icon Residue After Exit + +On Windows, quitting CC Switch could leave a dead tray icon behind until the mouse passed over it. The app now explicitly removes the tray icon before exiting, so it disappears cleanly when the process ends (#3797). + +### Windows Taskbar Icon + +Sets an explicit Windows AppUserModelID at runtime and writes the same ID and product icon onto the installer's desktop and start-menu shortcuts, so CC Switch shows the correct icon and groups properly in the taskbar (#3457). + +### Windows Update Check for Subdirectory Skills + +When scanning installed skills on Windows, backslash path separators are now normalized to forward slashes, so skills nested in subdirectories (e.g. `skills/my-skill`) are matched by the update check instead of being silently skipped (#3430). + +### macOS Input Auto-Capitalization + +Disabled autocomplete, autocorrect, autocapitalize, and spellcheck on the shared text Input component, so macOS no longer auto-capitalizes or auto-corrects the first letter typed into configuration fields (#3626). + +### Codex VS Code Session Previews + +For Codex requests sent from VS Code, the session preview could show selection or open-file content instead of the real prompt when a markdown heading preceded the injected request. The backend title and frontend preview now both match the last "## My request for Codex:" heading (the IDE injects the real request as the final section), so the preview reflects the user's prompt (#3593). + +### VS Code Wording in the Chinese UI + +Corrected the "Apply to Claude Code plugin" description in Simplified and Traditional Chinese to write "VS Code" properly instead of "Vscode", aligning with the English and Japanese strings (#3228). + +--- + +## Documentation + +### User Manual Refresh + +Refreshed the README localizations and the en / zh / ja user manuals to reflect all 7 managed apps (adding Claude Desktop and Hermes to the intro and overview copy), corrected the OpenCode config path to `~/.config/opencode/` (`opencode.json`), documented Hermes config files, updated the language docs to four languages, corrected per-app MCP / prompt / skill support, noted that export now produces a timestamped SQL backup that includes usage logs, and documented the pricing model-ID matching rules (#3411). + +### Codex Official Auth Preservation Guide + +Added a Chinese / English / Japanese guide explaining how to keep Codex official remote control and official plugins working while routing model traffic to third-party APIs, and linked it from the v3.16.1 release notes. + +### README Links and Sponsor Markup + +Updated the Release Notes links in each language README to v3.16.1, and fixed broken curly-quote characters in the README_ZH sponsor blocks so their HTML attributes render correctly (#3772). + +--- + +## Upgrade Notes + +### S3 and WebDAV Cloud Sync Are Mutually Exclusive + +Cloud Sync runs only one backend at a time. Enabling S3 auto-sync disables a running WebDAV auto-sync and vice versa. If you previously used WebDAV, make sure both ends are aligned before switching to S3, so you don't assume the old backend is still backing up. + +### Restart Codex After Editing Model Mappings + +Codex reads `model_catalog_json` at startup. Even though this release rewrites the model catalog to a relative path and adds the `/v1/models` reachability endpoint, you still need to restart Codex after editing the model mapping table for the `/model` menu to refresh. + +--- + +## Risk Notice + +This release continues the risk notices from previous versions for reverse-proxy-style features. + +**Codex OAuth reverse proxy**: using a ChatGPT subscription's Codex OAuth through a reverse proxy may violate OpenAI's terms of service. See the [v3.13.0 release notes](v3.13.0-en.md#️-risk-notice) for details. + +**Codex third-party provider Chat routing**: when CC Switch local proxy converts and forwards Codex requests to third-party providers, each provider may have different requirements for billing, compliance, and data retention. Read the target provider's terms before use. + +**Claude Desktop third-party provider proxy switching**: when CC Switch's built-in proxy gateway forwards Claude Desktop requests to third-party providers, you must also follow the target provider's billing, compliance, and data-retention terms. + +By enabling these features, users accept the related risks. CC Switch is not responsible for account restrictions, warnings, or service suspensions caused by using these features. + +--- + +## Thanks + +Thanks to the following contributors for the features and fixes in v3.16.2: + +- [#1351](https://github.com/farion1231/cc-switch/pull/1351): add S3-compatible cloud storage sync, thanks [@keithyt06](https://github.com/keithyt06). +- [#3215](https://github.com/farion1231/cc-switch/pull/3215): add OpenCode session usage sync, thanks [@nothingness0db](https://github.com/nothingness0db). +- [#2709](https://github.com/farion1231/cc-switch/pull/2709): add the ZenMux Token Plan provider, thanks [@Eter365](https://github.com/Eter365). +- [#3643](https://github.com/farion1231/cc-switch/pull/3643): add the CherryIN preset provider, thanks [@zhibisora](https://github.com/zhibisora). +- [#3818](https://github.com/farion1231/cc-switch/pull/3818): add the Codex CLI reachability `GET /v1/models` endpoint, thanks [@CSberlin](https://github.com/CSberlin). +- [#3426](https://github.com/farion1231/cc-switch/pull/3426): Usage Dashboard hero redesign, thanks [@allenxu09](https://github.com/allenxu09). +- [#3640](https://github.com/farion1231/cc-switch/pull/3640): drop `tool_choice` when tools is empty, thanks [@Postroggy](https://github.com/Postroggy). +- [#3644](https://github.com/farion1231/cc-switch/pull/3644): preserve Codex custom tool metadata in chat routing, thanks [@LanternCX](https://github.com/LanternCX). +- [#3514](https://github.com/farion1231/cc-switch/pull/3514): always include `reasoning_tokens` in Chat→Responses, thanks [@yeeyzy](https://github.com/yeeyzy). +- [#3689](https://github.com/farion1231/cc-switch/pull/3689): skip backup / restore when live is already a proxy placeholder, thanks [@YongmaoLuo](https://github.com/YongmaoLuo). +- [#3016](https://github.com/farion1231/cc-switch/pull/3016): normalize the localhost listen address, thanks [@Alexlangl](https://github.com/Alexlangl). +- [#3775](https://github.com/farion1231/cc-switch/pull/3775): normalize Anthropic `system` messages, thanks [@Dearli666](https://github.com/Dearli666). +- [#3656](https://github.com/farion1231/cc-switch/pull/3656): improve error message display in the proxy panel, thanks [@lzcndm](https://github.com/lzcndm). +- [#2647](https://github.com/farion1231/cc-switch/pull/2647): raise the infinite-whitespace threshold 20 → 500, thanks [@NiuBlibing](https://github.com/NiuBlibing). +- [#3702](https://github.com/farion1231/cc-switch/pull/3702): route the Zhipu quota query to the configured base URL, thanks [@YongmaoLuo](https://github.com/YongmaoLuo). +- [#3518](https://github.com/farion1231/cc-switch/pull/3518): adapt to the MiniMax new balance API and default pricing, thanks [@LaoYueHanNi](https://github.com/LaoYueHanNi). +- [#3524](https://github.com/farion1231/cc-switch/pull/3524): fix the Zhipu Coding Plan presets and model probing for versioned endpoints, thanks [@makoMakoGo](https://github.com/makoMakoGo). +- [#3614](https://github.com/farion1231/cc-switch/pull/3614): use a relative filename for the model catalog, thanks [@steponeerror](https://github.com/steponeerror). +- [#3797](https://github.com/farion1231/cc-switch/pull/3797): fix the Windows tray icon residue after exit, thanks [@iAJue](https://github.com/iAJue). +- [#3457](https://github.com/farion1231/cc-switch/pull/3457): fix the Windows taskbar icon, thanks [@ZhangNanNan1018](https://github.com/ZhangNanNan1018). +- [#3430](https://github.com/farion1231/cc-switch/pull/3430): normalize Windows path separators to match subdirectory skill updates, thanks [@Ninthless](https://github.com/Ninthless). +- [#3626](https://github.com/farion1231/cc-switch/pull/3626): disable macOS input auto-capitalization, thanks [@ZHLHZHU](https://github.com/ZHLHZHU). +- [#3593](https://github.com/farion1231/cc-switch/pull/3593): fix Codex VS Code session previews, thanks [@xwil1](https://github.com/xwil1). +- [#3228](https://github.com/farion1231/cc-switch/pull/3228): align the VS Code wording in the Chinese UI, thanks [@Games55k](https://github.com/Games55k). +- [#3411](https://github.com/farion1231/cc-switch/pull/3411): refresh the user manual to reflect current app support, thanks [@makoMakoGo](https://github.com/makoMakoGo). +- [#3772](https://github.com/farion1231/cc-switch/pull/3772): fix README release-note links and sponsor markup, thanks [@null-easy](https://github.com/null-easy). + +Thanks also to everyone who reported Codex Chat routing, local proxy takeover, usage statistics, and platform compatibility issues after v3.16.1. Many of these fixes came directly from real-world reproduction details. + +--- + +## Download & Install + +Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) and download the build for your system. + +### System Requirements + +| System | Minimum Version | Architecture | +| ------- | ------------------------ | ----------------------------------- | +| Windows | Windows 10 and later | x64 | +| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) | +| Linux | See table below | x64 / ARM64 | + +### Windows + +| File | Description | +| ---------------------------------------- | ------------------------------------------------ | +| `CC-Switch-v3.16.2-Windows.msi` | **Recommended** - MSI installer with auto-update | +| `CC-Switch-v3.16.2-Windows-Portable.zip` | Portable build, unzip and run | + +### macOS + +| File | Description | +| -------------------------------- | ----------------------------------------------------- | +| `CC-Switch-v3.16.2-macOS.dmg` | **Recommended** - DMG installer, drag to Applications | +| `CC-Switch-v3.16.2-macOS.zip` | Unzip and drag to Applications, Universal Binary | +| `CC-Switch-v3.16.2-macOS.tar.gz` | For Homebrew install and auto-update | + +Homebrew install: + +```bash +brew install --cask cc-switch +``` + +Upgrade: + +```bash +brew upgrade --cask cc-switch +``` + +### Linux + +Linux assets are available for both **x86_64** and **ARM64** (`aarch64`). Choose the file whose architecture tag matches your machine's `uname -m` output: + +- `CC-Switch-v3.16.2-Linux-x86_64.AppImage` / `.deb` / `.rpm` +- `CC-Switch-v3.16.2-Linux-arm64.AppImage` / `.deb` / `.rpm` + +| Distribution | Recommended Format | Install Command | +| --------------------------------------- | ------------------ | --------------------------------------------------------------------- | +| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` | +| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` | +| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` | +| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR | +| Other distributions / unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` | diff --git a/docs/release-notes/v3.16.2-ja.md b/docs/release-notes/v3.16.2-ja.md new file mode 100644 index 000000000..01f356d2f --- /dev/null +++ b/docs/release-notes/v3.16.2-ja.md @@ -0,0 +1,347 @@ +# CC Switch v3.16.2 + +> v3.16.1 の Codex 安定性パッチに続き、本リリースはデータの可搬性と用量の可観測性の拡張を主眼としています。S3 互換クラウド同期、OpenCode セッション用量同期、公式サブスクリプション残量テンプレートを追加し、Codex がサードパーティプロバイダーを Chat Completions ルーティングする際の堅牢性を引き続き強化しました。あわせて Windows / macOS のプラットフォーム問題を一括修正し、CherryIN・ZenMux プロバイダーを追加し、3 言語のユーザーマニュアルを全面的に刷新しました。 + +**[English →](v3.16.2-en.md) | [中文 →](v3.16.2-zh.md)** + +--- + +## 利用ガイド + +本リリースではクラウド同期の S3 バックエンドと、より多くの用量統計ソースを追加しました。利用したい場合は、まず以下のドキュメントをご覧ください: + +- **[設定](../user-manual/ja/1-getting-started/1.5-settings.md)**: 設定ページでクラウド同期(WebDAV / S3 互換ストレージ)を構成し、プロバイダー、MCP、プロンプト、スキルなどの設定を複数デバイス間でバックアップ・復元します。 +- **[用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**: 用量ダッシュボードのデータソース(プロキシログ、Codex / Gemini / OpenCode セッション同期)と統計の数え方を確認できます。 + +--- + +> [!WARNING] +> +> ## 唯一の公式チャネル(必ずお読みください) +> +> CC Switch は**完全に無料・オープンソース**のデスクトップアプリで、**ユーザーから料金を徴収することはありません**。本ソフトウェアは下記の公式チャネルからのみ入手してください: +> +> | チャネル | 唯一の公式 | +> | ------------ | ------------------------------------------------------------------------------ | +> | 公式サイト | **[ccswitch.io](https://ccswitch.io)** | +> | ソースコード | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** | +> | ダウンロード | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** | +> | 作者 | **[@farion1231](https://github.com/farion1231)** | +> | 偽サイト通報 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** | +> +> **料金請求・チャージ・認証情報の提供を求める「CC Switch」サイトやクライアントはすべて偽物です。** 支払いを誘導された場合は直ちに操作を中止し、GitHub Issues からご報告ください。 + +--- + +## 概要 + +CC Switch v3.16.2 は v3.16.1 に続くメンテナンスアップデートです。前リリースでは Codex 公式認証とローカルルーティングのテイクオーバーのセキュリティ問題に集中しましたが、本リリースは 2 点に重きを置いています。1 つ目はデータの可搬性と用量の可観測性の拡張で、S3 互換クラウド同期(WebDAV に並ぶ 2 つ目のクラウドバックアップバックエンド)、OpenCode セッション用量同期、公式サブスクリプション向けの残量統計テンプレートを追加しました。2 つ目は、Codex がサードパーティプロバイダーを Chat Completions ルーティングする際に露呈したエッジケースの継続的な改善で、ストリーム切断の判定、tools が空のときの `tool_choice`、カスタムツールのメタデータ、推論トークン統計、ファイル / 音声添付の変換などです。 + +本リリースではローカルプロキシの堅牢性に関する問題(一時ポートの解決、テイクオーバーのプレースホルダー復元ループ、Anthropic `system` メッセージの正規化、上流 413 の文言、Claude Desktop の `[1m]` モデルルーティング)を一括修正し、いくつかの Windows / macOS のプラットフォーム体験の問題に対処し、CherryIN・ZenMux の 2 プロバイダーを追加し、3 言語のユーザーマニュアルを全面的に刷新しました。 + +**リリース日**: 2026-06-07 + +**Stats**: 41 commits | 132 files changed | +11,116 / -1,636 lines + +--- + +## ハイライト + +- **S3 互換クラウド同期**: WebDAV に並ぶ 2 つ目のクラウドバックアップバックエンドとして S3 互換オブジェクトストレージを追加。AWS S3、MinIO、Cloudflare R2、Alibaba Cloud OSS、Tencent Cloud COS、Huawei OBS などのワンクリックプリセットを内蔵します。 +- **より多くの用量データソース**: OpenCode セッション用量同期と、Claude / Codex / Gemini 公式プロバイダー向けの公式サブスクリプション残量テンプレート(明示的なトグル、デフォルトでオフ)を追加しました。 +- **Codex Chat Completions ルーティングの継続的な強化**: ストリーム切断の誤判定、tools が空のときの `tool_choice` 拒否、カスタムツールメタデータの欠落、推論トークン統計の欠落を修正し、ファイル / 音声添付の変換と `/v1/models` 到達性エンドポイントを追加しました。 +- **より堅牢なローカルプロキシ**: 一時ポート(port 0)の解決、テイクオーバーのプレースホルダー復元ループ、Anthropic `system` メッセージの正規化、上流 413 の文言、Claude Desktop の 1M コンテキストモデルルーティングを修正しました。 +- **プラットフォームとプロバイダー**: Windows のトレイ / タスクバーアイコン、サブディレクトリスキルの更新、macOS の入力自動大文字化を修正し、CherryIN・ZenMux プロバイダーを追加しました。 + +--- + +## 追加機能 + +### S3 互換クラウド同期 + +クラウド同期は WebDAV に並ぶ 2 つ目のバックエンドとして S3 互換オブジェクトストレージに対応しました。署名は自前実装の AWS Signature V4 を用い、できるだけ多くのサービスと互換性を持たせています。設定ページでは AWS S3、MinIO、Cloudflare R2、Alibaba Cloud OSS、Tencent Cloud COS、Huawei OBS、およびカスタム endpoint のワンクリックプリセットを提供し、接続テスト、手動アップロード / ダウンロード、設定変更時の自動同期(providers、endpoint、MCP、プロンプト、スキル、設定、プロキシなどの設定テーブル。用量ログのような高頻度書き込みデータは**含みません**)に対応します。S3 同期を有効化すると、実行中の WebDAV 同期は停止し、その逆も同様です(#1351)。 + +### OpenCode セッション用量同期 + +OpenCode を用量統計のソースとして追加しました。OpenCode のローカル SQLite データベースからメッセージごとの token、コスト、モデルのデータを読み取り、用量レコードへインポートします。専用の「OpenCode」アプリフィルタタブと「OpenCode Session」データソースラベルを備えます。データベースパスは `OPENCODE_DB` と `XDG_DATA_HOME` を尊重し(全プラットフォームで既定は `~/.local/share/opencode`)、完了済みのメッセージのみをインポートし、新鮮度判定で WAL ファイルも含めるため、書き込み直後のセッションがスキップされません(#3215)。 + +### 公式サブスクリプション残量テンプレート + +用量照会を発行する IP とアプリ内リクエストを発行する IP が異なるとアカウント停止のリスクがある、という一部ユーザーの懸念を受けて、Claude / Codex / Gemini 公式プロバイダー向けに、CLI / OAuth 認証情報でプラン残量を照会する明示的・任意の「公式サブスクリプション」用量テンプレートを追加し、これまでの公式プロバイダーに対する暗黙の自動照会を置き換えました。このテンプレートはデフォルトでオフで、用量スクリプトのモーダルから有効化でき、更新間隔を設定できます。本機能を利用する際は、プロキシの TUN モードを有効化することを推奨します。 + +### テキスト専用モデルの画像フォールバック整流器 + +ルーティング先のモデルがテキスト専用(明示的な宣言、または内蔵のモデル名ヒューリスティックで判定)の場合、または上流が画像入力を拒否する場合に、Anthropic の画像ブロックを `[Unsupported Image]` プレースホルダーへ置き換えるプロキシ整流器を追加し、会話の中断を防ぎます。設定ページにこのフォールバックのトグルを用意し、さらにヒューリスティック検出を制御する別のトグル(マルチモーダルモデルの誤判定を避けるためオフにできます)を用意しました。 + +### ZenMux Token Plan プロバイダー + +ZenMux を Token Plan 系の Coding Plan プロバイダーとして追加しました。用量スクリプトのモーダルで API key と base URL を手動入力でき、使用量 / 残量を米ドル建てでリッチに表示します(#2709)。 + +### CherryIN プリセット + +CherryIN アグリゲーターゲートウェイをクイック設定プリセットとして、受管 7 アプリすべてに追加しました。Claude Code / Claude Desktop / OpenClaw / Hermes は Anthropic 形式の endpoint(open.cherryin.net)、OpenCode は `@ai-sdk/anthropic`(`/v1`)、Codex は OpenAI 互換 endpoint、Gemini CLI は Gemini 互換 endpoint を使用します。公式ブランドアイコン付きで、AiHubMix の隣に配置されます(#3643)。 + +### Codex CLI 到達性エンドポイント `/v1/models` + +ローカルプロキシは、Codex CLI が起動時にプローブする `GET /v1/models` に応答し、CC Switch が管理する Codex モデルカタログを返すようになりました。あわせて古いカタログのガードを追加: live の `config.toml` を解析し、`model_catalog_json` が CC Switch 所有のカタログファイルを指している場合のみ提供することで、前のプロバイダーが残したカタログを Codex に見せてしまうことを防ぎます(#3818)。 + +### Codex Chat のファイル・音声添付 + +Codex の Responses→Chat 変換は、`input_file`(`file_id` またはインライン `file_data` を持つ)と `input_audio` のコンテンツ部分を Chat Completions の対応形態へマッピングし、これまで破棄されていたトップレベルの `input_*` 項目も出力するようになりました。これにより、ファイルと音声の添付が Chat のみ対応の Codex 上流へ届きます。 + +--- + +## 変更 + +### 用量ダッシュボードのヒーロー再設計 + +用量ダッシュボードのヒーロー領域とサマリーカードをよりコンパクトなレイアウトに再構成し、実トークン総量、リクエスト数、コストを最上部の 1 行にまとめました(#3426)。 + +### SSSAiCode エンドポイント刷新 + +SSSAiCode プリセットの公式サイト、登録、API base URL を `sssaicodeapi.com` ドメインへ更新し、endpoint 候補ノード(既定 `node-hk.sssaicodeapi.com`、ほかに `node-hk.sssaiapi.com` と `node-cf.sssaicodeapi.com`)を全 7 アプリのプリセットで刷新しました。 + +--- + +## 修正 + +### Codex Chat ストリーム切断の判定 + +Chat Completions 上流が `finish_reason` も `[DONE]` もなくストリームを終了した場合、CC Switch はこれを正常完了として扱わなくなりました: 本当に終了したときのみ正常に締め、部分的な出力があった場合は incomplete(`max_output_tokens`)レスポンスを、何も出力されなかった場合は失敗 `stream_truncated` イベントを発行します。遅れて届いた推論も、まだアクティブなストリーミングのツール呼び出しへバックフィルされます。 + +### tools が空のときの Codex Chat `tool_choice` + +Responses→Chat 変換は、最終的な tools 配列が欠落または空(すべてのツールがフィルタで除外された場合を含む)のときに `tool_choice` と `parallel_tool_calls` を破棄するようになりました。これにより、厳格な OpenAI 互換上流(vLLM、エンタープライズゲートウェイ)が「When using `tool_choice`, `tools` must be set.」で 503/400 を返すことを避けます(#3640)。 + +### Codex カスタムツールメタデータの保持 + +カスタム Codex ツール(自由形式の `apply_patch` ツールなど)は、汎用プレースホルダーへ置き換えられる代わりに、format と grammar のメタデータを含む完全な元定義を、生成される Chat 関数の説明にコンパクトで順序の安定した JSON ブロックとして埋め込むようになりました。これにより Chat Completions 上流でも引き続き利用できます(#3644)。 + +### Codex Chat 用量の `reasoning_tokens` 欠落 + +Chat→Responses の用量変換は、プロバイダーが `completion_tokens_details` を省略したり非オブジェクトを返したりしても、常に `output_tokens_details.reasoning_tokens`(既定 0)を含めるようになりました。これにより Codex CLI の厳格な要件を満たし、レスポンス解析の失敗と再試行の繰り返しを避けます(#3514)。 + +### Codex カスタム / 検索ツールのターン跨ぎ推論 + +Codex Chat 履歴のターン跨ぎ推論キャッシュが、通常の関数呼び出しだけでなく、ツール呼び出しの全集合(`function_call`、`custom_tool_call`、`tool_search_call`)とその出力をカバーするようになりました。これにより `apply_patch` とツール検索の呼び出しは、`previous_response_id` で復元されるときにそれぞれの `reasoning_content` を保持します。 + +### 一時ポート(port 0)の解決 + +プロキシが port 0(OS 割り当て)でリッスンするよう構成されている場合、テイクオーバーはまずプロキシを起動して実際のポートを取得してから live 設定とデータベースへ書き込むようになり、クライアント URL が無効な `:0` アドレスを指すことを避けます。具体的なポートがまだ解決されていない場合、Claude Desktop のゲートウェイ URL は拒否されます。 + +### プロキシプレースホルダーのバックアップ / 復元ループ + +前回プロキシ停止時に元の live 設定の復元に失敗し、プロキシプレースホルダーが live に残ってしまった場合でも、再度テイクオーバーする際に正常なバックアップをプロキシ設定で上書きすることはなくなり、復元時にプレースホルダーを live へ書き戻すこともなくなりました: いずれの経路もプレースホルダー状態を検知し、現在のプロバイダーを信頼できる情報源として live を再構築します。これにより、プロキシのトグルが何もしない状態になり、クライアントがローカルプロキシアドレスに固定されてしまう問題を修正しました(#3689)。 + +### プロキシテイクオーバー中のプロバイダー切り替え誤ブロック + +ローカルルーティングのテイクオーバー中、明示的に official と分類されたプロバイダーのみが切り替えをブロックされるようになり、endpoint が meta に存在する、またはフィールドが未入力なだけのカスタムプロバイダーまで無効化することはなくなりました。無効化された「有効化」ボタンは、以前の赤い「ブロック済み」バッジの代わりに、より軽いヒントのツールチップを表示します。 + +### localhost リッスンアドレスの正規化 + +プロキシのリッスンアドレスを `localhost` で保存した場合、永続化前に `127.0.0.1` へ正規化されるようになり、バインドの不整合を避けます(#3016)。 + +### Anthropic `system` メッセージの正規化 + +Anthropic 形式のプロバイダーでは、`messages` 配列内の system ロールのエントリを折りたたんでトップレベルの `system` フィールドへマージするようになり(元の順序と既存のトップレベル system を保持)、厳格な上流が先頭以外の system メッセージを拒否することを避けます。OpenAI Chat ルーティングは影響を受けません(#3775)。 + +### Claude Desktop 1M コンテキストモデルルーティング + +Claude Desktop は 1M コンテキスト beta が有効なとき、モデル名に `[1m]` マーカーを付加します(例: `claude-opus-4-8[1m]`)。プロキシはルーティング照合の前にこの接尾辞を除去するようになり、完全一致・エイリアス・旧名・ロールキーワードの照合がすべて正しく解決されます。これにより、会話の途中で 1M モデルへ切り替えたときの `route_unknown`(HTTP 400)の失敗を修正しました。診断用に、`route_unknown` エラーには元のモデル名を引き続き保持します。 + +### Codex 413 エラーの文言 + +Codex 上流ゲートウェイが過大なリクエストボディを HTTP 413 で拒否したとき、プロキシはこれが CC Switch のローカル制限ではなくプロバイダーのサーバー側ボディサイズ制限であることを説明する専用メッセージを返し、実行可能な回復手順(`/compact` の実行、大きなログやインライン画像の削除、プロバイダーへの上限引き上げ依頼)を提示するようになりました。上流の生の HTML エラーページをそのまま返すことはなくなりました。 + +### プロキシパネルのエラー詳細 + +プロキシのテイクオーバー切り替えに失敗したとき、プロキシパネルのトーストは、汎用の失敗メッセージだけでなく、バックエンドが返す具体的なエラー詳細を含めるようになりました(#3656)。 + +### Copilot 無限空白検出のしきい値 + +ストリーミングの無限空白の中断しきい値を、連続する空白文字 20 から 500 へ引き上げました。これにより、引数に深くインデントされたコード(Python、YAML、Rust、Markdown)を含む正当なツール呼び出しが誤って中断されることを避けつつ、本物の Copilot 無限空白バグは引き続き捕捉します(#2647)。 + +### サブスクリプション階層のトレイ表示 + +統一された階層→ラベルのマッピングにより、トレイと残量表示における公式サブスクリプション階層の表示を修正しました: Claude / Codex は 7 日ウィンドウを取りこぼさなくなり、Gemini Pro / Flash / Flash-Lite の階層は生のマシン名を漏らさなくなり、複数ウィンドウのプラン(Opus + Sonnet など)は最初の一致ではなく最悪の利用率を表示するようになりました。 + +### Claude ストリームの input_tokens 過大計上 + +一部の Anthropic 互換ストリーミングプロバイダー(Qwen、MiniMax など)は `message_start` で完全なコンテキストを `input_tokens` として報告し、別途報告済みのキャッシュ分を二重計上して、表示上のキャッシュヒット率を不当に低下させていました。パーサーは `message_delta` のより小さい正の `input_tokens` を優先し、同じ usage ブロックのキャッシュカウントを採用するようになりました。ネイティブ Claude と OpenRouter 変換の経路は変更ありません。 + +### 智譜(Zhipu)残量照会の endpoint ルーティング + +智譜 Coding Plan の残量照会は `api.z.ai` にハードコードされていたため、本土プリセット(`open.bigmodel.cn`)のユーザーは国際 endpoint が到達不能なときに用量を取得できませんでした。残量リクエストは、ユーザーが構成した base URL に一致するホストへルーティングされるようになりました(#3702)。 + +### MiniMax 残量 API と価格 + +MiniMax Coding Plan の残量を新しい残量 API に対応させました(新 API は、旧パーサーが依存していた用量カウント(階層が空になりトレイに用量が表示されなくなる)の代わりに、残り割合のフィールドを返します)。非コーディングモデル(動画など)を除外し、週次上限のないプランに対応し、MiniMax M3 モデルの既定価格を追加しました(#3518)。 + +### GLM Coding Plan の endpoint とモデル取得 + +智譜 / Z.AI の GLM Coding Plan プリセットを `/api/coding/paas/v4` endpoint に修正し(Codex、OpenCode、OpenClaw、Hermes をカバー)、すでに `/v{N}` のバージョンセグメントで終わる base URL については、モデル一覧プローブが `{base}/models` を先に照会するようにしました(`/v1/models` はフォールバックとして保持)。これにより「モデル取得」ボタンがバージョン付き endpoint で 404 にならなくなりました(#3524)。 + +### Codex モデルカタログパスの可搬性 + +Codex は `config.toml` に絶対パスではなく相対ファイル名 `cc-switch-model-catalog.json` のみを書き込むようになりました(Codex CLI は設定ディレクトリから解決します)。これにより、絶対パスを変換できない WSL やシンボリックリンク環境でモデルカタログが壊れる問題を修正しました(#3614)。 + +### APINebula の OpenCode SDK + +APINebula の OpenCode プリセットは `@ai-sdk/openai` ではなく `@ai-sdk/openai-compatible` を読み込むようになり、chat-completions のみ対応の上流で失敗する Responses API ではなく、このリレーが期待する OpenAI Chat Completions 形式でリクエストを行います。 + +### Windows 終了後のトレイアイコン残留 + +Windows では CC Switch を終了すると、マウスを重ねるまで無効なトレイアイコンが残ることがありました。アプリは終了前にトレイアイコンを明示的に削除するようになり、プロセス終了とともにきれいに消えます(#3797)。 + +### Windows タスクバーアイコン + +実行時に Windows AppUserModelID を明示的に設定し、インストーラーが生成するデスクトップとスタートメニューのショートカットに同じ ID と製品アイコンを書き込みます。これにより CC Switch がタスクバーで正しいアイコンを表示し、正しくグループ化されます(#3457)。 + +### Windows サブディレクトリスキルの更新チェック + +Windows でインストール済みスキルをスキャンする際、バックスラッシュのパス区切りをスラッシュへ正規化するようになり、サブディレクトリにネストされたスキル(`skills/my-skill` など)が静かにスキップされず、更新チェックで一致するようになりました(#3430)。 + +### macOS の入力自動大文字化 + +共有のテキスト Input コンポーネントで autocomplete、autocorrect、autocapitalize、spellcheck を無効化し、macOS が設定フィールドに入力された最初の文字を自動で大文字化・自動修正しないようにしました(#3626)。 + +### Codex VS Code セッションプレビュー + +VS Code から送信された Codex リクエストでは、注入されたリクエストの前に markdown 見出しがあると、セッションプレビューが本当のプロンプトではなく選択範囲や開いているファイルの内容を表示することがありました。バックエンドのタイトルとフロントエンドのプレビューはいずれも、最後の「## My request for Codex:」見出しに一致するようになり(IDE は本当のリクエストを最後のセクションとして注入します)、プレビューがユーザーのプロンプトを反映します(#3593)。 + +### 中国語 UI の VS Code 表記 + +簡体字・繁体字中国語の「Claude Code プラグインに適用」の説明を、「Vscode」ではなく正しく「VS Code」と表記するよう修正し、英語・日本語の文言と揃えました(#3228)。 + +--- + +## ドキュメント + +### ユーザーマニュアル刷新 + +README の各言語版と en / zh / ja のユーザーマニュアルを刷新し、受管 7 アプリすべてを反映(紹介と概要の文面に Claude Desktop と Hermes を追加)、OpenCode の設定パスを `~/.config/opencode/`(`opencode.json`)に修正、Hermes の設定ファイルの説明を追加、言語ドキュメントを 4 言語に更新、アプリごとの MCP / プロンプト / スキルの対応状況を訂正、エクスポートがタイムスタンプ付きで用量ログを含む SQL バックアップを生成することを記載、価格モデル ID のマッチングルールを追記しました(#3411)。 + +### Codex 公式認証保持ガイド + +モデル通信をサードパーティ API へ切り替えつつ、Codex の公式リモート操作と公式プラグインを動作させ続ける方法を説明する中国語 / 英語 / 日本語のガイドを追加し、v3.16.1 のリリースノートからリンクしました。 + +### README リンクとスポンサー表記 + +各言語の README のリリースノートリンクを v3.16.1 に更新し、README_ZH のスポンサーブロックで壊れていた曲線引用符文字を修正して、HTML 属性が正しくレンダリングされるようにしました(#3772)。 + +--- + +## アップグレード時の注意 + +### S3 と WebDAV のクラウド同期は排他 + +クラウド同期は同時に 1 つのバックエンドのみを実行します。S3 自動同期を有効化すると、実行中の WebDAV 自動同期は停止し、その逆も同様です。以前 WebDAV を使っていた場合は、S3 へ切り替える前に両端のデータが揃っていることを確認し、旧バックエンドがまだバックアップしていると誤解しないようにしてください。 + +### モデルマッピング変更後は Codex の再起動が必要 + +Codex は起動時に `model_catalog_json` を読み込みます。本リリースでモデルカタログを相対パスへ書き換え、`/v1/models` 到達性エンドポイントを追加しましたが、モデルマッピングテーブルを変更した後は、`/model` メニューを更新するために Codex の再起動が必要です。 + +--- + +## リスク通知 + +本リリースは、リバースプロキシ系機能に関する以前のリスク通知を引き続き適用します。 + +**Codex OAuth リバースプロキシ**: ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#️-リスクに関する注意事項) を参照してください。 + +**Codex サードパーティプロバイダー Chat ルーティング**: CC Switch ローカルプロキシで Codex リクエストを変換し、サードパーティプロバイダーへ転送する場合、課金、コンプライアンス、データ保持に関する制約はプロバイダーごとに異なります。利用前に対象プロバイダーの利用規約を確認してください。 + +**Claude Desktop サードパーティプロバイダープロキシ切り替え**: CC Switch 内蔵のプロキシゲートウェイで Claude Desktop のリクエストをサードパーティプロバイダーへ転送する場合も、対象プロバイダーの課金、コンプライアンス、データ保持に関する規約に従う必要があります。 + +上記機能を有効化したユーザーは、関連するリスクを自ら負うものとします。CC Switch は、これらの機能の利用によって発生したアカウント制限、警告、サービス停止について責任を負いません。 + +--- + +## 謝辞 + +v3.16.2 で機能と修正を届けてくださった以下のコントリビューターに感謝します: + +- [#1351](https://github.com/farion1231/cc-switch/pull/1351): S3 互換クラウドストレージ同期を追加、[@keithyt06](https://github.com/keithyt06) に感謝。 +- [#3215](https://github.com/farion1231/cc-switch/pull/3215): OpenCode セッション用量同期を追加、[@nothingness0db](https://github.com/nothingness0db) に感謝。 +- [#2709](https://github.com/farion1231/cc-switch/pull/2709): ZenMux Token Plan プロバイダーを追加、[@Eter365](https://github.com/Eter365) に感謝。 +- [#3643](https://github.com/farion1231/cc-switch/pull/3643): CherryIN プリセットプロバイダーを追加、[@zhibisora](https://github.com/zhibisora) に感謝。 +- [#3818](https://github.com/farion1231/cc-switch/pull/3818): Codex CLI 到達性確認用の `GET /v1/models` エンドポイントを追加、[@CSberlin](https://github.com/CSberlin) に感謝。 +- [#3426](https://github.com/farion1231/cc-switch/pull/3426): 用量ダッシュボードのヒーロー再設計、[@allenxu09](https://github.com/allenxu09) に感謝。 +- [#3640](https://github.com/farion1231/cc-switch/pull/3640): tools が空のとき `tool_choice` を破棄、[@Postroggy](https://github.com/Postroggy) に感謝。 +- [#3644](https://github.com/farion1231/cc-switch/pull/3644): Chat ルーティングで Codex カスタムツールメタデータを保持、[@LanternCX](https://github.com/LanternCX) に感謝。 +- [#3514](https://github.com/farion1231/cc-switch/pull/3514): Chat→Responses で常に `reasoning_tokens` を含める、[@yeeyzy](https://github.com/yeeyzy) に感謝。 +- [#3689](https://github.com/farion1231/cc-switch/pull/3689): live がすでにプロキシプレースホルダーのときバックアップ / 復元をスキップ、[@YongmaoLuo](https://github.com/YongmaoLuo) に感謝。 +- [#3016](https://github.com/farion1231/cc-switch/pull/3016): localhost リッスンアドレスを正規化、[@Alexlangl](https://github.com/Alexlangl) に感謝。 +- [#3775](https://github.com/farion1231/cc-switch/pull/3775): Anthropic `system` メッセージを正規化、[@Dearli666](https://github.com/Dearli666) に感謝。 +- [#3656](https://github.com/farion1231/cc-switch/pull/3656): プロキシパネルのエラー表示を改善、[@lzcndm](https://github.com/lzcndm) に感謝。 +- [#2647](https://github.com/farion1231/cc-switch/pull/2647): 無限空白検出のしきい値を 20 → 500 へ引き上げ、[@NiuBlibing](https://github.com/NiuBlibing) に感謝。 +- [#3702](https://github.com/farion1231/cc-switch/pull/3702): 智譜の残量照会を構成済み base URL へルーティング、[@YongmaoLuo](https://github.com/YongmaoLuo) に感謝。 +- [#3518](https://github.com/farion1231/cc-switch/pull/3518): MiniMax の新残量 API と既定価格に対応、[@LaoYueHanNi](https://github.com/LaoYueHanNi) に感謝。 +- [#3524](https://github.com/farion1231/cc-switch/pull/3524): 智譜 Coding Plan プリセットとバージョン付き endpoint のモデル探索を修正、[@makoMakoGo](https://github.com/makoMakoGo) に感謝。 +- [#3614](https://github.com/farion1231/cc-switch/pull/3614): モデルカタログを相対ファイル名に変更、[@steponeerror](https://github.com/steponeerror) に感謝。 +- [#3797](https://github.com/farion1231/cc-switch/pull/3797): Windows 終了後のトレイアイコン残留を修正、[@iAJue](https://github.com/iAJue) に感謝。 +- [#3457](https://github.com/farion1231/cc-switch/pull/3457): Windows タスクバーアイコンを修正、[@ZhangNanNan1018](https://github.com/ZhangNanNan1018) に感謝。 +- [#3430](https://github.com/farion1231/cc-switch/pull/3430): Windows のパス区切りを正規化してサブディレクトリスキルの更新に対応、[@Ninthless](https://github.com/Ninthless) に感謝。 +- [#3626](https://github.com/farion1231/cc-switch/pull/3626): macOS の入力自動大文字化を無効化、[@ZHLHZHU](https://github.com/ZHLHZHU) に感謝。 +- [#3593](https://github.com/farion1231/cc-switch/pull/3593): Codex VS Code セッションプレビューを修正、[@xwil1](https://github.com/xwil1) に感謝。 +- [#3228](https://github.com/farion1231/cc-switch/pull/3228): 中国語 UI の VS Code 表記を揃える、[@Games55k](https://github.com/Games55k) に感謝。 +- [#3411](https://github.com/farion1231/cc-switch/pull/3411): 現行のアプリ対応を反映してユーザーマニュアルを刷新、[@makoMakoGo](https://github.com/makoMakoGo) に感謝。 +- [#3772](https://github.com/farion1231/cc-switch/pull/3772): README のリリースノートリンクとスポンサー表記を修正、[@null-easy](https://github.com/null-easy) に感謝。 + +v3.16.1 リリース後に Codex Chat ルーティング、ローカルプロキシのテイクオーバー、用量統計、プラットフォーム互換性の問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。 + +--- + +## ダウンロードとインストール + +[Releases](https://github.com/farion1231/cc-switch/releases/latest) から、お使いのシステムに対応するビルドをダウンロードしてください。 + +### システム要件 + +| システム | 最低バージョン | アーキテクチャ | +| -------- | ------------------------ | ----------------------------------- | +| Windows | Windows 10 以降 | x64 | +| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) | +| Linux | 下表を参照 | x64 / ARM64 | + +### Windows + +| ファイル | 説明 | +| ---------------------------------------- | ------------------------------------------ | +| `CC-Switch-v3.16.2-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー | +| `CC-Switch-v3.16.2-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます | + +### macOS + +| ファイル | 説明 | +| -------------------------------- | ------------------------------------------------------ | +| `CC-Switch-v3.16.2-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ | +| `CC-Switch-v3.16.2-macOS.zip` | 展開して Applications へドラッグ、Universal Binary | +| `CC-Switch-v3.16.2-macOS.tar.gz` | Homebrew インストールと自動更新用 | + +Homebrew インストール: + +```bash +brew install --cask cc-switch +``` + +更新: + +```bash +brew upgrade --cask cc-switch +``` + +### Linux + +Linux アセットは **x86_64** と **ARM64**(`aarch64`)の両方を提供します。ファイル名にアーキテクチャ識別子が含まれているため、マシンの `uname -m` 出力に合わせて選択してください: + +- `CC-Switch-v3.16.2-Linux-x86_64.AppImage` / `.deb` / `.rpm` +- `CC-Switch-v3.16.2-Linux-arm64.AppImage` / `.deb` / `.rpm` + +| ディストリビューション | 推奨形式 | インストール方法 | +| --------------------------------------- | ----------- | ------------------------------------------------------------------------- | +| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` | +| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` | +| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` | +| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接起動、または AUR を使用 | +| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` | diff --git a/docs/release-notes/v3.16.2-zh.md b/docs/release-notes/v3.16.2-zh.md new file mode 100644 index 000000000..4243b977b --- /dev/null +++ b/docs/release-notes/v3.16.2-zh.md @@ -0,0 +1,347 @@ +# CC Switch v3.16.2 + +> 在 v3.16.1 的 Codex 稳定性补丁之后,这一版主要拓宽了数据的可携带性与用量观测能力——新增 S3 兼容云同步、OpenCode 会话用量同步、官方订阅额度模板——并继续加固 Codex 通过 Chat Completions 路由第三方供应商的稳健性,同时修复了一批 Windows / macOS 平台问题,新增 CherryIN、ZenMux 供应商,并全面刷新了三语用户手册。 + +**[English →](v3.16.2-en.md) | [日本語版 →](v3.16.2-ja.md)** + +--- + +## 使用攻略 + +这一版新增了云同步的 S3 后端和更多用量统计来源,如果你想用上,可以先看这些文档: + +- **[设置](../user-manual/zh/1-getting-started/1.5-settings.md)**:在设置页配置云同步(WebDAV / S3 兼容存储),用于在多台设备间备份和恢复供应商、MCP、提示词、技能等配置。 +- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源(代理日志、Codex / Gemini / OpenCode 会话同步)与统计口径。 + +--- + +> [!WARNING] +> +> ## 唯一官方渠道声明(请务必阅读) +> +> CC Switch 是**完全免费、开源**的桌面应用,**不会向用户收取任何费用**。请仅通过下列官方渠道获取本软件: +> +> | 类别 | 唯一官方 | +> | -------- | ------------------------------------------------------------------------------ | +> | 官网 | **[ccswitch.io](https://ccswitch.io)** | +> | 源码 | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** | +> | 下载 | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** | +> | 作者 | **[@farion1231](https://github.com/farion1231)** | +> | 举报山寨 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** | +> +> **任何向你收费、要求充值、或索取登录凭据的"CC Switch"网站或客户端均为假冒**。如果你被诱导支付了费用,请立即停止操作并通过 GitHub Issues 反馈。 + +--- + +## 概览 + +CC Switch v3.16.2 是 v3.16.1 之后的一版维护更新。在上一版集中处理 Codex 官方鉴权与本地路由接管的安全问题之后,这一版把重心放在两件事上:一是拓宽数据的可携带性和用量观测——新增 S3 兼容云同步(WebDAV 之外的第二套云备份后端)、OpenCode 会话用量同步,以及面向官方订阅的额度统计模板;二是继续打磨 Codex 通过 Chat Completions 路由第三方供应商时暴露出来的边角问题——流式截断判定、空 tools 下的 `tool_choice`、自定义工具元数据、推理 token 统计、文件 / 音频附件转换等。 + +此外,本版还修复了一批本地代理的稳健性问题(临时端口解析、接管占位符还原死循环、Anthropic `system` 消息归一化、上游 413 文案、Claude Desktop 的 `[1m]` 模型路由),处理了若干 Windows / macOS 平台体验问题,并新增 CherryIN、ZenMux 两个供应商,同时全面刷新了三语用户手册。 + +**发布日期**:2026-06-07 + +**更新规模**:41 commits | 132 files changed | +11,116 / -1,636 lines + +--- + +## 重点内容 + +- **S3 兼容云同步**:在 WebDAV 之外新增 S3 兼容对象存储作为第二套云备份后端,内置 AWS S3、MinIO、Cloudflare R2、阿里云 OSS、腾讯云 COS、华为 OBS 等一键预设。 +- **更多用量统计来源**:新增 OpenCode 会话用量同步,以及面向 Claude / Codex / Gemini 官方订阅的额度统计模板(显式开关、默认关闭)。 +- **Codex Chat Completions 路由继续加固**:修复流式截断误判、空 tools 下 `tool_choice` 被拒、自定义工具元数据丢失、推理 token 统计缺失,并支持文件 / 音频附件转换与 `/v1/models` 探活端点。 +- **本地代理更稳**:修复临时端口(port 0)解析、接管占位符还原死循环、Anthropic `system` 消息归一化、上游 413 文案,以及 Claude Desktop 1M 上下文模型路由。 +- **平台与供应商**:修复 Windows 托盘 / 任务栏图标、子目录技能更新、macOS 输入自动大写等问题,并新增 CherryIN、ZenMux 供应商。 + +--- + +## 新功能 + +### S3 兼容云同步 + +云同步现在支持 S3 兼容对象存储作为 WebDAV 之外的第二套后端,签名采用自实现的 AWS Signature V4,以兼容尽可能多的服务。设置页提供 AWS S3、MinIO、Cloudflare R2、阿里云 OSS、腾讯云 COS、华为 OBS 以及自定义 endpoint 的一键预设,支持连接测试、手动上传 / 下载,以及在配置变更时自动同步(providers、endpoint、MCP、提示词、技能、设置、代理等配置表,**不含**用量日志这类高频写入数据)。开启 S3 同步会停用正在运行的 WebDAV 同步,反之亦然([#1351](https://github.com/farion1231/cc-switch/pull/1351))。 + +### OpenCode 会话用量同步 + +新增 OpenCode 作为用量统计来源,从 OpenCode 本地 SQLite 数据库读取每条消息的 token、成本和模型数据并导入用量记录,并提供独立的「OpenCode」应用筛选页签和「OpenCode Session」数据来源标签。数据库路径会遵循 `OPENCODE_DB` 和 `XDG_DATA_HOME`(在所有平台默认 `~/.local/share/opencode`),只导入已完成的消息,并在判断新鲜度时把 WAL 文件一并计入,避免刚写入的会话被跳过([#3215](https://github.com/farion1231/cc-switch/pull/3215))。 + +### 官方订阅额度模板 + +由于部分用户担心发起用量查询的 IP 和发起应用内请求的不一致导致封号风险,因此为 Claude / Codex / Gemini 官方供应商新增一个显式、可选的「官方订阅」用量模板,通过 CLI / OAuth 凭据查询套餐额度,替代此前对官方供应商的隐式自动查询。该模板默认关闭,需要在用量脚本弹窗里开启,并可配置刷新间隔。使用此功能建议开启代理的 TUN 模式。 + +### 文本模型图片回退整流器 + +新增一个代理整流器:当路由到的模型仅支持文本(显式声明,或由内置的模型名启发式判定),或上游拒绝图片输入时,会把 Anthropic 图片块替换为 `[Unsupported Image]` 占位标记,避免对话被中断。设置页提供该回退功能的开关,并单独提供一个开关控制启发式检测(可关闭以避免误判多模态模型)。 + +### ZenMux Token Plan 供应商 + +新增 ZenMux 作为 Token Plan 类的 Coding Plan 供应商,可在用量脚本弹窗里手动填写 API key 和 base URL,并以美元口径富展示已用 / 额度([#2709](https://github.com/farion1231/cc-switch/pull/2709))。 + +### CherryIN 预设 + +新增 CherryIN 聚合网关作为快捷配置预设,覆盖全部 7 个受管应用——Claude Code / Claude Desktop / OpenClaw / Hermes 使用 Anthropic 格式端点(open.cherryin.net),OpenCode 使用 `@ai-sdk/anthropic`(`/v1`),Codex 使用 OpenAI 兼容端点,Gemini CLI 使用 Gemini 兼容端点,附带官方品牌图标,位置紧挨 AiHubMix([#3643](https://github.com/farion1231/cc-switch/pull/3643))。 + +### Codex CLI 模型探活端点 `/v1/models` + +本地代理现在会响应 Codex CLI 启动时探测的 `GET /v1/models`,返回 CC Switch 托管的 Codex 模型目录。同时加入了过期目录守卫:解析 live 的 `config.toml`,仅当 `model_catalog_json` 仍指向 CC Switch 持有的目录文件时才提供,避免把上一个供应商遗留的目录暴露给 Codex([#3818](https://github.com/farion1231/cc-switch/pull/3818))。 + +### Codex Chat 文件与音频附件 + +Codex 的 Responses→Chat 转换现在会把 `input_file`(携带 `file_id` 或内联 `file_data`)和 `input_audio` 内容部分映射为 Chat Completions 的对应形态,并补发此前会被丢弃的顶层 `input_*` 项,让文件和音频附件能够送达只支持 Chat 的 Codex 上游。 + +--- + +## 变更 + +### 用量看板 Hero 重新设计 + +把用量看板的 Hero 区与汇总卡片重排为更紧凑的布局,将真实 token 总量、请求数和成本合并到顶部一行展示([#3426](https://github.com/farion1231/cc-switch/pull/3426))。 + +### SSSAiCode 端点刷新 + +把 SSSAiCode 预设的官网、注册和 API base URL 更新到 `sssaicodeapi.com` 域名,并刷新其端点候选节点(默认 `node-hk.sssaicodeapi.com`,另含 `node-hk.sssaiapi.com` 和 `node-cf.sssaicodeapi.com`),覆盖全部 7 个应用预设。 + +--- + +## 修复 + +### Codex Chat 流式截断判定 + +当 Chat Completions 上游在没有 `finish_reason` 或 `[DONE]` 的情况下结束流时,CC Switch 不再把它当作正常完成:只有流真正结束才正常收尾;已产出部分内容时发出 incomplete(`max_output_tokens`)响应;完全没有产出时发出失败的 `stream_truncated` 事件。晚到的推理内容也会回填到仍在进行的流式工具调用上。 + +### Codex Chat 空 tools 下的 `tool_choice` + +Responses→Chat 转换现在会在最终 tools 数组缺失或为空(包括所有工具被过滤掉)时一并丢弃 `tool_choice` 和 `parallel_tool_calls`,避免严格的 OpenAI 兼容上游(vLLM、企业网关)以"When using `tool_choice`, `tools` must be set."报 503/400([#3640](https://github.com/farion1231/cc-switch/pull/3640))。 + +### Codex 自定义工具元数据保留 + +自定义 Codex 工具(如自由格式的 `apply_patch` 工具)现在会把完整的原始定义——包括 format 和 grammar 元数据——以紧凑、顺序稳定的 JSON 块嵌入生成的 Chat 函数描述中,而不是替换成通用占位符,从而在 Chat Completions 上游上仍可正常使用([#3644](https://github.com/farion1231/cc-switch/pull/3644))。 + +### Codex Chat 用量缺少 `reasoning_tokens` + +Chat→Responses 的用量转换现在总会包含 `output_tokens_details.reasoning_tokens`(默认 0),即使供应商省略 `completion_tokens_details` 或返回非对象也是如此,满足 Codex CLI 的严格要求,避免反复的响应解析失败和重试([#3514](https://github.com/farion1231/cc-switch/pull/3514))。 + +### Codex 自定义工具 / 搜索工具的跨轮推理 + +Codex Chat 历史里的跨轮推理缓存现在覆盖完整的工具调用集合(`function_call`、`custom_tool_call`、`tool_search_call`)及其输出,而不再仅限普通函数调用,因此 `apply_patch` 和工具搜索调用在通过 `previous_response_id` 恢复时能保留各自的 `reasoning_content`。 + +### 临时端口(port 0)解析 + +当代理被配置为监听 0 端口(由系统分配)时,接管流程现在会先启动代理以拿到真实端口,再写入 live 配置和数据库,避免客户端 URL 指向无效的 `:0` 地址;若还没解析出具体端口,Claude Desktop 的网关 URL 会被直接拒绝。 + +### 代理占位符备份 / 恢复死循环 + +如果上一次停止代理时未能还原原始 live 配置、把代理占位符遗留在了 live 中,再次接管时不会再用代理配置覆盖掉正常备份,恢复时也不会把占位符写回 live:两条路径都会识别占位符状态并以当前供应商为真相来源重建 live,修复了代理开关变成空操作、客户端被钉死在本地代理地址的问题([#3689](https://github.com/farion1231/cc-switch/pull/3689))。 + +### 代理接管期间误拦截供应商切换 + +在本地路由接管期间,现在只有显式归类为官方的供应商会被禁止切换,而不会再把端点存在 meta 里、或字段尚未填写的自定义供应商一并禁用。被禁用的「启用」按钮现在以更轻量的提示气泡替代原先的红色「已拦截」标记。 + +### localhost 监听地址归一化 + +保存代理时如果监听地址填的是 `localhost`,现在会先归一化为 `127.0.0.1` 再持久化,避免绑定不一致([#3016](https://github.com/farion1231/cc-switch/pull/3016))。 + +### Anthropic `system` 消息归一化 + +对 Anthropic 格式的供应商,`messages` 数组里的 system 角色条目现在会被折叠并合并到顶层 `system` 字段(保留原顺序以及已有的顶层 system),避免严格上游拒绝非首位的 system 消息;OpenAI Chat 路由不受影响([#3775](https://github.com/farion1231/cc-switch/pull/3775))。 + +### Claude Desktop 1M 上下文模型路由 + +Claude Desktop 在 1M 上下文 beta 激活时会给模型名追加 `[1m]` 标记(如 `claude-opus-4-8[1m]`)。代理现在会在路由匹配前先剥掉该后缀,让精确、别名、旧名和角色关键词匹配都能正确命中,修复了对话中途切换到 1M 模型时的 `route_unknown`(HTTP 400)失败;诊断用的 `route_unknown` 错误里仍保留原始模型名。 + +### Codex 413 错误文案 + +当 Codex 上游网关以 HTTP 413 拒绝过大的请求体时,代理现在返回专门的提示,说明这是供应商服务端的请求体大小限制(而非 CC Switch 本地限制),并给出可操作的恢复步骤(运行 `/compact`、移除大段日志或内联图片,或请供应商调高限制),不再原样回显上游的 HTML 错误页。 + +### 代理面板错误详情 + +切换代理接管失败时,代理面板的提示现在会带上后端返回的具体错误详情,而不是只显示一句笼统的失败信息([#3656](https://github.com/farion1231/cc-switch/pull/3656))。 + +### Copilot 无限空白检测阈值 + +把流式无限空白的中断阈值从 20 调高到 500 个连续空白字符,避免参数里含深层缩进代码(Python、YAML、Rust、Markdown)的正常工具调用被误判中断,同时仍能捕获真正的 Copilot 无限空白 bug([#2647](https://github.com/farion1231/cc-switch/pull/2647))。 + +### 订阅档位托盘渲染 + +通过统一的档位到标签映射,修复官方订阅档位在托盘和额度展示上的渲染问题:Claude / Codex 不再漏掉 7 天窗口,Gemini Pro / Flash / Flash-Lite 档位不再泄露原始机器名,多窗口套餐(如 Opus + Sonnet)现在按最差利用率展示而非取第一个匹配。 + +### Claude 流式 input_tokens 虚高 + +部分 Anthropic 兼容的流式供应商(如 Qwen、MiniMax)会在 `message_start` 里把完整上下文当作 `input_tokens` 上报,重复计入了已经单独统计的缓存部分,导致显示的缓存命中率被人为拉低。现在解析器会优先采用 `message_delta` 中更小的正 `input_tokens`,并采用同一 usage 块里配套的缓存计数;原生 Claude 和 OpenRouter 转换路径不变。 + +### 智谱配额查询端点路由 + +智谱 Coding Plan 的配额查询此前被硬编码到 `api.z.ai`,导致使用大陆预设(`open.bigmodel.cn`)的用户在国际端点不可达时查不到用量。现在配额请求会路由到与用户所配 base URL 匹配的主机([#3702](https://github.com/farion1231/cc-switch/pull/3702))。 + +### MiniMax 余额接口与定价 + +适配 MiniMax Coding Plan 配额的新余额接口(新接口返回剩余百分比字段,而非旧解析器依赖、会导致档位为空、托盘不再显示用量的用量计数),过滤掉非编程模型(如视频),兼容无周限额的套餐,并为 MiniMax M3 模型补充了默认定价([#3518](https://github.com/farion1231/cc-switch/pull/3518))。 + +### GLM Coding Plan 端点与模型拉取 + +把智谱 / Z.AI 的 GLM Coding Plan 预设修正到 `/api/coding/paas/v4` 端点(覆盖 Codex、OpenCode、OpenClaw、Hermes),并让模型列表探测对已经以 `/v{N}` 版本段结尾的 base URL 改为先查 `{base}/models`(保留 `/v1/models` 作为兜底),让「拉取模型」按钮不再在带版本号的端点上 404([#3524](https://github.com/farion1231/cc-switch/pull/3524))。 + +### Codex 模型目录路径可移植性 + +Codex 现在只把相对文件名 `cc-switch-model-catalog.json` 写入 `config.toml`,而不是绝对路径(Codex CLI 会从配置目录解析它),修复了在 WSL 和符号链接环境下绝对路径无法转换、导致模型目录失效的问题([#3614](https://github.com/farion1231/cc-switch/pull/3614))。 + +### APINebula 的 OpenCode SDK + +APINebula 的 OpenCode 预设现在加载 `@ai-sdk/openai-compatible` 而非 `@ai-sdk/openai`,让请求使用该中转期望的 OpenAI Chat Completions 格式,而不是只支持 chat-completions 的上游会失败的 Responses API。 + +### Windows 退出后托盘图标残留 + +在 Windows 上退出 CC Switch 可能会留下一个失效的托盘图标,直到鼠标划过才消失。现在应用会在退出前显式移除托盘图标,让它随进程结束干净消失([#3797](https://github.com/farion1231/cc-switch/pull/3797))。 + +### Windows 任务栏图标 + +在运行时显式设置 Windows AppUserModelID,并给安装器生成的桌面和开始菜单快捷方式写入相同的 ID 和产品图标,让 CC Switch 在任务栏上显示正确图标并正确归组([#3457](https://github.com/farion1231/cc-switch/pull/3457))。 + +### Windows 子目录技能的更新检查 + +在 Windows 上扫描已安装技能时,把反斜杠路径分隔符归一化为正斜杠,让嵌套在子目录里的技能(如 `skills/my-skill`)能被更新检查匹配到,而不是被静默跳过([#3430](https://github.com/farion1231/cc-switch/pull/3430))。 + +### macOS 输入自动大写 + +为共享的文本 Input 组件关闭自动完成、自动纠错、自动大写和拼写检查,让 macOS 不再对配置字段里输入的首字母自动大写或自动纠正([#3626](https://github.com/farion1231/cc-switch/pull/3626))。 + +### Codex VS Code 会话预览 + +从 VS Code 发起的 Codex 请求,其会话预览在注入请求前存在 markdown 标题时,可能显示选区或打开文件的内容而非真实提示。现在后端标题和前端预览都会匹配最后一个「## My request for Codex:」标题(IDE 把真实请求作为最后一节注入),让预览反映用户的提示([#3593](https://github.com/farion1231/cc-switch/pull/3593))。 + +### 中文界面 VS Code 文案 + +把简体和繁体中文里「应用到 Claude Code 插件」的描述改为正确书写「VS Code」而非「Vscode」,与英文、日文文案对齐([#3228](https://github.com/farion1231/cc-switch/pull/3228))。 + +--- + +## 文档 + +### 用户手册刷新 + +刷新了 README 各语言版本以及 en / zh / ja 用户手册,使其反映全部 7 个受管应用(在介绍和总览文案里补上 Claude Desktop 与 Hermes),把 OpenCode 配置路径修正为 `~/.config/opencode/`(`opencode.json`),补充了 Hermes 配置文件说明,把语言文档更新为四种语言,订正各应用 MCP / 提示词 / 技能的支持情况,说明导出现在会生成带时间戳、含用量日志的 SQL 备份,并补充了定价模型 ID 匹配规则([#3411](https://github.com/farion1231/cc-switch/pull/3411))。 + +### Codex 官方认证保留指南 + +新增中 / 英 / 日三语指南,说明如何在把模型流量切到第三方 API 的同时,保留 Codex 官方远程操作和官方插件的可用性,并从 v3.16.1 release notes 链接到该指南。 + +### README 链接与赞助商标记 + +把各语言 README 里的 Release Notes 链接更新到 v3.16.1,并修复 README_ZH 赞助商区块里损坏的弯引号字符,让其 HTML 属性能正确渲染([#3772](https://github.com/farion1231/cc-switch/pull/3772))。 + +--- + +## 升级提醒 + +### S3 与 WebDAV 云同步互斥 + +云同步同一时间只会运行一套后端。开启 S3 自动同步会停用正在运行的 WebDAV 自动同步,反之亦然。如果你之前用的是 WebDAV,切到 S3 前请确认两端数据已对齐,避免误以为旧后端仍在备份。 + +### 修改模型映射后仍需重启 Codex + +Codex 在启动时读取 `model_catalog_json`。即使本版已把模型目录改写为相对路径并新增了 `/v1/models` 探活端点,只要你修改了模型映射表,仍然需要重启 Codex 才能让 `/model` 菜单刷新。 + +--- + +## 风险提示 + +本版本继续沿用此前版本对反向代理类功能的风险提示。 + +**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#️-风险提示)。 + +**Codex 第三方供应商 Chat 路由**:通过 CC Switch 本地代理把 Codex 请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。 + +**Claude Desktop 第三方供应商代理切换**:通过 CC Switch 内置代理网关把 Claude Desktop 的请求转到第三方供应商时,同样需要遵守目标供应商的计费、合规与数据留存约束。 + +用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。 + +--- + +## 致谢 + +感谢以下贡献者在 v3.16.2 中提交的功能与修复: + +- [#1351](https://github.com/farion1231/cc-switch/pull/1351):新增 S3 兼容云存储同步,感谢 [@keithyt06](https://github.com/keithyt06)。 +- [#3215](https://github.com/farion1231/cc-switch/pull/3215):新增 OpenCode 会话用量同步,感谢 [@nothingness0db](https://github.com/nothingness0db)。 +- [#2709](https://github.com/farion1231/cc-switch/pull/2709):新增 ZenMux Token Plan 供应商,感谢 [@Eter365](https://github.com/Eter365)。 +- [#3643](https://github.com/farion1231/cc-switch/pull/3643):新增 CherryIN 预设供应商,感谢 [@zhibisora](https://github.com/zhibisora)。 +- [#3818](https://github.com/farion1231/cc-switch/pull/3818):新增 Codex CLI 探活用的 `GET /v1/models` 端点,感谢 [@CSberlin](https://github.com/CSberlin)。 +- [#3426](https://github.com/farion1231/cc-switch/pull/3426):用量看板 Hero 重新设计,感谢 [@allenxu09](https://github.com/allenxu09)。 +- [#3640](https://github.com/farion1231/cc-switch/pull/3640):空 tools 时丢弃 `tool_choice`,感谢 [@Postroggy](https://github.com/Postroggy)。 +- [#3644](https://github.com/farion1231/cc-switch/pull/3644):Chat 路由保留 Codex 自定义工具元数据,感谢 [@LanternCX](https://github.com/LanternCX)。 +- [#3514](https://github.com/farion1231/cc-switch/pull/3514):Chat→Responses 始终包含 `reasoning_tokens`,感谢 [@yeeyzy](https://github.com/yeeyzy)。 +- [#3689](https://github.com/farion1231/cc-switch/pull/3689):live 已是代理占位符时跳过备份 / 恢复,感谢 [@YongmaoLuo](https://github.com/YongmaoLuo)。 +- [#3016](https://github.com/farion1231/cc-switch/pull/3016):归一化 localhost 监听地址,感谢 [@Alexlangl](https://github.com/Alexlangl)。 +- [#3775](https://github.com/farion1231/cc-switch/pull/3775):规范化 Anthropic `system` 消息,感谢 [@Dearli666](https://github.com/Dearli666)。 +- [#3656](https://github.com/farion1231/cc-switch/pull/3656):改进代理面板错误信息展示,感谢 [@lzcndm](https://github.com/lzcndm)。 +- [#2647](https://github.com/farion1231/cc-switch/pull/2647):调高无限空白检测阈值 20 → 500,感谢 [@NiuBlibing](https://github.com/NiuBlibing)。 +- [#3702](https://github.com/farion1231/cc-switch/pull/3702):智谱配额查询按所配 base URL 路由,感谢 [@YongmaoLuo](https://github.com/YongmaoLuo)。 +- [#3518](https://github.com/farion1231/cc-switch/pull/3518):适配 MiniMax 余额查询新接口与默认定价,感谢 [@LaoYueHanNi](https://github.com/LaoYueHanNi)。 +- [#3524](https://github.com/farion1231/cc-switch/pull/3524):修复智谱 Coding Plan 预设与带版本号端点的模型探测,感谢 [@makoMakoGo](https://github.com/makoMakoGo)。 +- [#3614](https://github.com/farion1231/cc-switch/pull/3614):模型目录改用相对文件名,感谢 [@steponeerror](https://github.com/steponeerror)。 +- [#3797](https://github.com/farion1231/cc-switch/pull/3797):修复 Windows 退出后托盘图标残留,感谢 [@iAJue](https://github.com/iAJue)。 +- [#3457](https://github.com/farion1231/cc-switch/pull/3457):修复 Windows 任务栏图标,感谢 [@ZhangNanNan1018](https://github.com/ZhangNanNan1018)。 +- [#3430](https://github.com/farion1231/cc-switch/pull/3430):归一化 Windows 路径分隔符以匹配子目录技能更新,感谢 [@Ninthless](https://github.com/Ninthless)。 +- [#3626](https://github.com/farion1231/cc-switch/pull/3626):关闭 macOS 输入框自动大写,感谢 [@ZHLHZHU](https://github.com/ZHLHZHU)。 +- [#3593](https://github.com/farion1231/cc-switch/pull/3593):修复 Codex VS Code 会话预览,感谢 [@xwil1](https://github.com/xwil1)。 +- [#3228](https://github.com/farion1231/cc-switch/pull/3228):对齐中文界面 VS Code 文案,感谢 [@Games55k](https://github.com/Games55k)。 +- [#3411](https://github.com/farion1231/cc-switch/pull/3411):刷新用户手册以反映当前应用支持,感谢 [@makoMakoGo](https://github.com/makoMakoGo)。 +- [#3772](https://github.com/farion1231/cc-switch/pull/3772):修复 README release note 链接与赞助商标记,感谢 [@null-easy](https://github.com/null-easy)。 + +也感谢所有在 v3.16.1 发布后反馈 Codex Chat 路由、本地代理接管、用量统计和平台兼容性问题的用户,很多补丁都来自这些真实使用场景里的复现线索。 + +--- + +## 下载与安装 + +访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。 + +### 系统要求 + +| 系统 | 最低版本 | 架构 | +| ------- | -------------------------- | ----------------------------------- | +| Windows | Windows 10 及以上 | x64 | +| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) | +| Linux | 见下表 | x64 / ARM64 | + +### Windows + +| 文件 | 说明 | +| ---------------------------------------- | ----------------------------------- | +| `CC-Switch-v3.16.2-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 | +| `CC-Switch-v3.16.2-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 | + +### macOS + +| 文件 | 说明 | +| -------------------------------- | --------------------------------------------- | +| `CC-Switch-v3.16.2-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 | +| `CC-Switch-v3.16.2-macOS.zip` | 解压后拖入 Applications,Universal Binary | +| `CC-Switch-v3.16.2-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 | + +Homebrew 安装: + +```bash +brew install --cask cc-switch +``` + +更新: + +```bash +brew upgrade --cask cc-switch +``` + +### Linux + +Linux 资产同时提供 **x86_64** 和 **ARM64**(`aarch64`)两种架构。资产文件名中包含架构标识,请按你机器的 `uname -m` 输出选择对应版本: + +- `CC-Switch-v3.16.2-Linux-x86_64.AppImage` / `.deb` / `.rpm` +- `CC-Switch-v3.16.2-Linux-arm64.AppImage` / `.deb` / `.rpm` + +| 发行版 | 推荐格式 | 安装方式 | +| --------------------------------------- | ----------- | ---------------------------------------------------------------------- | +| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` | +| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` | +| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` | +| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR | +| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` | diff --git a/package.json b/package.json index beb3e207d..31386b3f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-switch", - "version": "3.16.1", + "version": "3.16.2", "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 a54f8fac3..8513f3205 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -735,7 +735,7 @@ dependencies = [ [[package]] name = "cc-switch" -version = "3.16.1" +version = "3.16.2" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a89dda485..118f8a389 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cc-switch" -version = "3.16.1" +version = "3.16.2" 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 2759fa30e..4a218be38 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": "CC Switch", - "version": "3.16.1", + "version": "3.16.2", "identifier": "com.ccswitch.desktop", "build": { "frontendDist": "../dist", From fa17194d845c18f7e55a3037941db6179ee8418a Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 8 Jun 2026 22:04:16 +0800 Subject: [PATCH 03/66] feat(presets): add CCSub provider across six apps Add CCSub, a multi-model aggregator partner, as a preset for Claude, Codex, OpenCode, OpenClaw, Claude Desktop, and Hermes. Each preset carries the referral signup link as apiKeyUrl. - Register the ccsub icon via iconUrls (1.1MB SVG URL import) + metadata - Add partnerPromotion copy in zh/en/ja - List CCSub in the sponsor section of all README locales - Use gpt-5.5 and gemini-3.1-pro as the OpenAI/Gemini model ids --- README.md | 5 ++ README_DE.md | 5 ++ README_JA.md | 5 ++ README_ZH.md | 5 ++ assets/partners/logos/ccsub.jpg | Bin 0 -> 3490 bytes src/config/claudeDesktopProviderPresets.ts | 13 ++++ src/config/claudeProviderPresets.ts | 15 +++++ src/config/codexProviderPresets.ts | 16 +++++ src/config/hermesProviderPresets.ts | 50 +++++++++++++++ src/config/openclawProviderPresets.ts | 69 +++++++++++++++++++++ src/config/opencodeProviderPresets.ts | 33 ++++++++++ src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/zh.json | 3 +- src/icons/extracted/ccsub.svg | 4 ++ src/icons/extracted/index.ts | 2 + src/icons/extracted/metadata.ts | 7 +++ 17 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 assets/partners/logos/ccsub.jpg create mode 100644 src/icons/extracted/ccsub.svg diff --git a/README.md b/README.md index d75e3392f..bac70be1b 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,11 @@ Register now via this lin Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new coding plan promotion for more budget-friendly API access! + +CCSub +Thanks to CCSub for sponsoring this project! CCSub is a stable, affordable AI API relay platform — your drop-in replacement for a Claude.ai subscription. One API key gives you access to Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini, and DeepSeek at roughly 30% of direct API cost, with no VPN required from anywhere in the world. Compatible with Claude Code, Codex, Cursor, Cline, Continue, Windsurf, and all major AI coding tools. Register via this link and get $5 free credit on sign-up. + + diff --git a/README_DE.md b/README_DE.md index 54ed50d03..bec5cf687 100644 --- a/README_DE.md +++ b/README_DE.md @@ -146,6 +146,11 @@ Registrieren Sie sich jetzt über Coding-Plan-Aktion von Atlas Cloud für kostengünstigeren API-Zugang an! + +CCSub +Danke an CCSub für die Unterstützung dieses Projekts! CCSub ist eine zuverlässige und kostengünstige AI-API-Relay-Plattform — Ihr direkter Ersatz für ein Claude.ai-Abonnement. Mit einem einzigen API-Schlüssel erhalten Sie Zugriff auf Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini und DeepSeek zu etwa 30 % der Kosten der direkten API-Nutzung — ohne VPN, weltweit nutzbar. Kompatibel mit Claude Code, Codex, Cursor, Cline, Continue, Windsurf und allen gängigen AI-Coding-Tools. Registrieren Sie sich über diesen Link und erhalten Sie $5 Startguthaben bei der Anmeldung. + + diff --git a/README_JA.md b/README_JA.md index 8a1e29ebb..d100d4d08 100644 --- a/README_JA.md +++ b/README_JA.md @@ -145,6 +145,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / Atlas Cloud は、1 つの API で動画・画像生成や LLM(大規模言語モデル)を利用できる全モーダル対応の AI 推論プラットフォームです。複数のベンダーを個別に管理する手間を省き、一度の接続で 300 以上の厳選されたマルチモーダルモデルにアクセスできます。より低コストで API を利用できる、開発者向けの新しい「コーディングプラン」プロモーションをぜひチェックしてください! + +CCSub +CCSub のご支援に感謝します!CCSub は安定した低価格の AI API リレープラットフォームで、Claude Code 公式サブスクリプションの強力な代替です。1 つの API キーで Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek の全モデルを公式直接利用の約 1/3 のコストでご利用いただけます。VPN 不要で世界中から直接接続可能。Claude Code、Codex、Cursor、Cline、Continue、Windsurf など主要な AI コーディングツールすべてに対応しています。こちらのリンクから登録すると $5 の無料クレジットがもらえます。 + + diff --git a/README_ZH.md b/README_ZH.md index 02393fccf..6059d7b93 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -146,6 +146,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 Atlas Cloud 是一个全模态 AI 推理平台,通过单一 API 为开发者提供视频生成、图像生成及 LLM 接入。免去繁琐的多供应商对接,一次连接即可调用 300+ 款全模态精选模型。立即查看 Atlas Cloud 全新“编程计划”优惠,获取更具性价比的 API 接入! + +CCSub +感谢 CCSub 赞助本项目!CCSub 是稳定、实惠的 AI API 中转平台,是 Claude Code 官方订阅的超强平替。一个 API Key 即可调用 Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek 全系列模型,价格约为官方直连的 1/3,全球直连无需梯子。兼容 Claude Code、Codex、Cursor、Cline、Continue、Windsurf 等所有主流 AI 编程工具。通过此链接注册即送 $5 体验额度! + + diff --git a/assets/partners/logos/ccsub.jpg b/assets/partners/logos/ccsub.jpg new file mode 100644 index 0000000000000000000000000000000000000000..34253552fe088ec55fdd295b4da30db5cf8bf386 GIT binary patch literal 3490 zcmdUxc{J2}AIE=VXl5iCTZ}EnI@Ym9$x@baL$b>jb#WCDX6&w#j4fkJw#vvlgk%Y2 zYi>%&GGr%_F){8~u9$grpY5FIujhIGdCv3sp7-~k@8|nH@7MX9^Z9&PL5Xf{W{f+DVatow2pO zv-`*14=xY@{wvnu{#USn;esD>v9Ys**&#o;Ky27Uf#K{NlISD6MmCTuL3~o`ze4$q zGalDy!?|m z&i|zp{OfnGJo&m;Q);kHNZib@INu*PAtyImMs*%3ujqZtQ0s9_<+LKca7$CiQ<*X= z+X#0O!mkg>E4!ScZKzA7xu){3D)eGPIRzHoA6AGJ2gyId4^GKfRMP?iYvvmp6226L z!HK@=&gq665ic^o8|>m&M`d5VYN!trMn`>kw{_BcQ#S5Q8h)(}yD{Lt;i9y&a5`T$ z>gWR)s_dDkAjf2!vYeVqnCVWV=vp!UXLyAdR7<_GELk%RkgUW8!kUWxG*PAQpTQZ zAFN>x#0tJaqRghRtSV}b4G{>f{^>;R|at0*RC=MC}#5U6&1 zP@MJI>?MpIPsHrr*4Y5!wcDkNI?54~`yZ@+w`wtISKxD9)hnpUP!L8NHODXOYYg&z zYVAi_43gTkK2MRV?pVzaGc>xrJH&yayNusVsmofJY65|Dawac0C74FWV2Im)(=1)4mmZSI$g0 zPlWsPPQD-dCadLT1%3N2sAyDmt(yfPYf4#w=NJnZs-x)82bdN)4NQi?o{T~YmDZv5 zH6pP)ITg|?xI>93*#2ldT^|~)VlHXQa8PlI zk#XFtJzg7rkj(-XXL~%>FUQg>fcdkMxSgI&+_IhaUO%lMEH}89>!Pc=6iOG2BC?r_ zF6-_&nk-5rBjoTgqNySm8#|(X`cm@tv*55!Vb60u>{mn9Keo-14L0?tEMObneCU|u zGyxZECxh=%t5r5;=4)i(p1={MTZyLRr!=_Uh- z0nQmwyVB$b33UY_TpkcqwH0Yeb5V^r zyDqOvhe8#c#V3+W9eH>7jdqOG@RPfzweh~h8L@k3c#I^{C9*XxZ0Jn3DIPr6I+pk@ z$6B#(U`h%7@+P%u7+<`gdi(JZT$Mqay_WGpov&EA?hWko?c0>6`qat|%0A)gfJgll z3(%>|Ht))E6XUyP*K3?6nDs1RD2qUGTMr3$$nexUX7%a)+oj|r2j%e((_~??F!i## z#zy)+Qfe@HwlTUSCN{JoVEPqJbUD-q_Y>2Uq_k-8)h2eu_+?P^1a^bdx+`5RzYiS_isv%MM{fiCIH$_W+pAoAIuam{H}_qU8fJf z{UauCx|l4H5U}bPJQ1ax7U`fM^{ESdJUZ@vq$pyJDdOQOm#jSRjB(z92){mMY__V1 zCsaOGu+1jiSi|80NhqyK8v;Hhzp*wl#>}9~uL9X< zrYfnbO1ynR74h|4_xeF0mYIQC(6sNCJlpZ025X$rKa*Sbl{k%2qPu&%Z({-C#+fS+ zxtV6YRi@Z3fznMG&L4kgc9TC4ECZEe=+?+3+EsLzgyBBEHiH?v_v-4e?Y-LN=yx#l zXmVQR8pDP)==T>8X)-_Y+QF$KkraF4?yOlLch#(w_&nd$`>p%$4atiS=VMOd@RJ@R zHZ&fhTE)fp&0d%RYl|<(-4zbp?BkamB2zsbP$|*%L6H+7(HPll;l!qk=~0Wp5Z zYw1_6M>wvnS5NvG@Z>1Au=~ee-!8xW>#N}Ul<&0HThflU^xN~+_qJvcuKoyixLarB z(z>;lP02MwVqwo!v6i`q8>81_1)-l2G)8f9ht{yNNp-|KOGJulA+Py67QlvbVoDDS zuk5_yZqu5ZO`x3UkZ-uV=$rZyVpdHAj?| zwO-!|igem3?*fheO}3-YE{(qyrcF5>^P}gJ1;=KLSNnEFHSe2#FyL)t>ee`VP__bj zW)5mEw-dg_#J%K_XV?pI>c>6eCeO2g#OIc==dxVM`77g}lZ1E-?$97YEP%8BaM;}< zk(izQraVDk{CkBvYmI944Q&W8LTkfa;hidfc*~9+*8||=omX`*49ENAkR|E#iXX0*7KEnar z;mBrD8~Rz)#wM*IGV*n8qq1XZP(Y`LmxUm@nnpk1$)yi$T6 z0#iuLNc*Yot=!b8ZHfJeZRJ?L)5p6<;b)9{ydJjMVidQi6}0+^W7e+)w|_S52z`#q zV{Y@u#Z&_|{;!#^QO#K%lj!pEl6bEBI*I`@=4)1Z%MU+G(*qh*^27@$iEBd%_C^Rc zw)>_~*DY}S<+Eq#u*S`^GeIcYv$|2vmIJw=n_!9~H}*n2z%;`SIzWsv=@vZb$IVI^Spt1$pHE1)?G|x&QzG literal 0 HcmV?d00001 diff --git a/src/config/claudeDesktopProviderPresets.ts b/src/config/claudeDesktopProviderPresets.ts index 88f649a7f..8ac82b580 100644 --- a/src/config/claudeDesktopProviderPresets.ts +++ b/src/config/claudeDesktopProviderPresets.ts @@ -167,6 +167,19 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [ partnerPromotionKey: "shengsuanyun", icon: "shengsuanyun", }, + { + name: "CCSub", + websiteUrl: "https://www.ccsub.net", + apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", + category: "aggregator", + baseUrl: "https://www.ccsub.net", + mode: "direct", + apiFormat: "anthropic", + modelRoutes: passthroughRoutes(true), + isPartner: true, + partnerPromotionKey: "ccsub", + icon: "ccsub", + }, { name: "PatewayAI", websiteUrl: "https://pateway.ai", diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 17abe3beb..88bd2e6dc 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -109,6 +109,21 @@ export const providerPresets: ProviderPreset[] = [ partnerPromotionKey: "shengsuanyun", icon: "shengsuanyun", }, + { + name: "CCSub", + websiteUrl: "https://www.ccsub.net", + apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://www.ccsub.net", + ANTHROPIC_AUTH_TOKEN: "", + }, + }, + category: "aggregator", + isPartner: true, + partnerPromotionKey: "ccsub", + icon: "ccsub", + }, { name: "PatewayAI", websiteUrl: "https://pateway.ai", diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 7778f4eb7..d2e6aeb6d 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -116,6 +116,22 @@ export const codexProviderPresets: CodexProviderPreset[] = [ partnerPromotionKey: "shengsuanyun", icon: "shengsuanyun", }, + { + name: "CCSub", + websiteUrl: "https://www.ccsub.net", + apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", + category: "aggregator", + auth: generateThirdPartyAuth(""), + config: generateThirdPartyConfig( + "ccsub", + "https://www.ccsub.net/v1", + "gpt-5.5", + ), + endpointCandidates: ["https://www.ccsub.net/v1"], + isPartner: true, + partnerPromotionKey: "ccsub", + icon: "ccsub", + }, { name: "PatewayAI", websiteUrl: "https://pateway.ai", diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index 74d20506f..fda9561db 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -351,6 +351,56 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ }, }, }, + { + name: "CCSub", + websiteUrl: "https://www.ccsub.net", + apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", + settingsConfig: { + name: "ccsub", + base_url: "https://www.ccsub.net/v1", + api_key: "", + api_mode: "chat_completions", + models: [ + { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + context_length: 1000000, + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + context_length: 1000000, + }, + { + id: "gpt-5.5", + name: "GPT-5.5", + context_length: 400000, + }, + { + id: "o3", + name: "o3", + context_length: 200000, + }, + { + id: "gemini-3.1-pro", + name: "Gemini 3.1 Pro", + context_length: 1000000, + }, + { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + context_length: 1000000, + }, + ], + }, + category: "aggregator", + isPartner: true, + partnerPromotionKey: "ccsub", + icon: "ccsub", + suggestedDefaults: { + model: { default: "claude-opus-4-8", provider: "ccsub" }, + }, + }, { name: "Nous Research", websiteUrl: "https://nousresearch.com", diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index cae81bf96..223327177 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -900,6 +900,75 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, }, }, + { + name: "CCSub", + websiteUrl: "https://www.ccsub.net", + apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", + settingsConfig: { + baseUrl: "https://www.ccsub.net/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + contextWindow: 1000000, + cost: { input: 5, output: 25 }, + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + contextWindow: 1000000, + cost: { input: 3, output: 15 }, + }, + { + id: "gpt-5.5", + name: "GPT-5.5", + contextWindow: 400000, + cost: { input: 5, output: 15 }, + }, + { + id: "o3", + name: "o3", + contextWindow: 200000, + cost: { input: 10, output: 40 }, + }, + { + id: "gemini-3.1-pro", + name: "Gemini 3.1 Pro", + contextWindow: 1000000, + cost: { input: 1.25, output: 10 }, + }, + { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + contextWindow: 1000000, + cost: { input: 0.14, output: 0.28 }, + }, + ], + }, + category: "aggregator", + isPartner: true, + partnerPromotionKey: "ccsub", + icon: "ccsub", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { + primary: "ccsub/claude-opus-4-8", + fallbacks: ["ccsub/claude-sonnet-4-6"], + }, + modelCatalog: { + "ccsub/claude-opus-4-8": { alias: "Opus" }, + "ccsub/claude-sonnet-4-6": { alias: "Sonnet" }, + }, + }, + }, { name: "CherryIN", websiteUrl: "https://open.cherryin.ai", diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index c8b9ae1b0..103aacdf0 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -308,6 +308,39 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, }, + { + name: "CCSub", + websiteUrl: "https://www.ccsub.net", + apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", + settingsConfig: { + npm: "@ai-sdk/openai-compatible", + name: "CCSub", + options: { + baseURL: "https://www.ccsub.net/v1", + apiKey: "", + setCacheKey: true, + }, + models: { + "claude-opus-4-8": { name: "Claude Opus 4.8" }, + "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, + "gpt-5.5": { name: "GPT-5.5" }, + o3: { name: "o3" }, + "gemini-3.1-pro": { name: "Gemini 3.1 Pro" }, + "deepseek-v4-flash": { name: "DeepSeek V4 Flash" }, + }, + }, + category: "aggregator", + isPartner: true, + partnerPromotionKey: "ccsub", + icon: "ccsub", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + }, { name: "火山Agentplan", websiteUrl: diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 25cbf306d..ad461bc75 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1001,7 +1001,8 @@ "lemondata": "Lemon Code offers a special promotion for CC Switch users. Sign up via this link to receive $1 in free credits.", "doubaoseed": "DouBao offers exclusive benefits for CC Switch users: register via this link to get 500K tokens of inference credits for all DouBao text models and 5M tokens of free Seedance 2.0 credits.", "volcengine_agentplan": "Volcengine Ark Agent Plan offers exclusive benefits for CC Switch users: subscribe via this link, starting from ¥40/month for new customers.", - "byteplus": "Register via this link to get 500,000 tokens of free inference quota per model." + "byteplus": "Register via this link to get 500,000 tokens of free inference quota per model.", + "ccsub": "CCSub is a stable, affordable AI API relay — a drop-in replacement for Claude.ai & OpenAI subscriptions, with one key for all models at ~30% of direct API cost." }, "presets": { "ucloud": "Compshare", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 022de9f1b..0abf87721 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1001,7 +1001,8 @@ "lemondata": "Lemon Code は CC Switch ユーザー向けの特別オファーを提供しています。このリンクから登録すると 1 ドル分の無料クレジットがもらえます。", "doubaoseed": "豆包大モデルは CC Switch ユーザーに専属特典を提供しています:このリンクから登録すると、豆包全テキストモデル50万トークンの推論クレジットおよびSeedance2.0の500万トークン無料クレジットがもらえます。", "volcengine_agentplan": "火山方舟 Agent Plan は CC Switch ユーザーに専属特典を提供しています:このリンクから方舟AgentPlanを購読すると、新規のお客様は初月40元から利用できます。", - "byteplus": "このリンクから登録すると、各モデルごとに50万トークンの無料推論クォータがもらえます。" + "byteplus": "このリンクから登録すると、各モデルごとに50万トークンの無料推論クォータがもらえます。", + "ccsub": "CCSub は安定・低価格の AI API リレーサービスです。Claude Code の公式サブスクリプションの代替として、1つのキーで全モデルを公式比約1/3のコストで利用できます。" }, "presets": { "ucloud": "Compshare", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 16d539116..675b2cd85 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1001,7 +1001,8 @@ "lemondata": "Lemon Code 为 CC Switch 的用户提供了特别优惠。使用此链接注册可以获得1美元免费额度。", "doubaoseed": "豆包大模型为 CC Switch 的用户提供了专属福利:通过此链接注册即可获取豆包全系列文本模型 50万tokens推理额度以及Seedance2.0的500万tokens免费额度", "volcengine_agentplan": "火山方舟 Agent Plan 为 CC Switch 的用户提供了专属福利:通过此链接订阅方舟AgentPlan,新客户首月40元起。", - "byteplus": "通过此链接注册即可获取每个模型50万tokens的免费推理额度。" + "byteplus": "通过此链接注册即可获取每个模型50万tokens的免费推理额度。", + "ccsub": "CCSub 是稳定、实惠的 AI API 中转平台,Claude Code 官方订阅的超强平替,一个 Key 覆盖全部模型,价格约为官方 1/3。" }, "presets": { "ucloud": "优云智算", diff --git a/src/icons/extracted/ccsub.svg b/src/icons/extracted/ccsub.svg new file mode 100644 index 000000000..14dc84254 --- /dev/null +++ b/src/icons/extracted/ccsub.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index 80ec6a43d..338001bf3 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -6,6 +6,7 @@ import _apinebula from "./apinebula_icon.png"; import _atlascloud from "./atlascloud_icon.png"; import _claudeapi from "./ClaudeApi.png"; import _byteplus from "./byteplus.png"; +import _ccsub from "./ccsub.svg?url"; import _claudecn from "./claudecn.png"; import _cherryin from "./cherryin.png"; import _eflowcode from "./eflowcode.png"; @@ -97,6 +98,7 @@ export const iconUrls: Record = { apinebula: _apinebula, atlascloud: _atlascloud, byteplus: _byteplus, + ccsub: _ccsub, claudeapi: _claudeapi, claudecn: _claudecn, cherryin: _cherryin, diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index eb979496e..9afa9585c 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -133,6 +133,13 @@ export const iconMetadata: Record = { keywords: ["byteplus", "volcengine", "ark", "modelark"], defaultColor: "#3370FF", }, + ccsub: { + name: "ccsub", + displayName: "CCSub", + category: "ai-provider", + keywords: ["ccsub", "aggregator", "relay", "claude", "codex", "gateway"], + defaultColor: "#1E88E5", + }, chatglm: { name: "chatglm", displayName: "chatglm", From 5beb63e67d0a9795f89907d77b060d5d09d8d0a0 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 8 Jun 2026 23:07:50 +0800 Subject: [PATCH 04/66] refactor(presets): align CCSub to end of partner block across apps Move the CCSub preset to sit right after DouBaoSeed, at the end of the partner block and before the first non-partner provider, so its position is consistent across all six apps: - Codex / OpenCode: moved up from the 2nd slot (between Shengsuanyun and the next partner) to the block tail - OpenClaw / Hermes: moved up from the aggregator section to the block tail - Claude / Claude Desktop: already at the block tail Also add the missing CHANGELOG entry for the CCSub preset, and drop the provider preset order test that enforced a now-unneeded ordering invariant. --- CHANGELOG.md | 1 + src/config/claudeDesktopProviderPresets.ts | 26 ++-- src/config/claudeProviderPresets.ts | 30 ++--- src/config/codexProviderPresets.ts | 32 ++--- src/config/hermesProviderPresets.ts | 100 +++++++-------- src/config/openclawProviderPresets.ts | 138 ++++++++++----------- src/config/opencodeProviderPresets.ts | 66 +++++----- tests/config/providerPresetOrder.test.ts | 85 ------------- 8 files changed, 197 insertions(+), 281 deletions(-) delete mode 100644 tests/config/providerPresetOrder.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6bdd3fb..b3b3879ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Development since v3.16.1 focuses on broadening data portability and usage obser - **Unsupported Image Fallback Rectifier**: Added a proxy rectifier that replaces Anthropic image blocks with an `[Unsupported Image]` marker when the routed model is text-only (declared, or detected via a built-in model-name heuristic) or when the upstream rejects image input, so conversations are not interrupted. A new Settings toggle controls the fallback, with a separate toggle for the heuristic detection. - **ZenMux Token Plan Provider**: Added ZenMux as a Token Plan coding-plan provider that accepts a manually entered API key and base URL in the usage-script modal and renders its quota with USD-denominated used / limit values (#2709). - **CherryIN Preset**: Added the CherryIN aggregator gateway as a quick-config preset across all seven supported apps — Anthropic-format endpoint for Claude Code / Claude Desktop / OpenClaw / Hermes, `@ai-sdk/anthropic` for OpenCode, the OpenAI-compatible endpoint for Codex, and the Gemini-compatible endpoint for Gemini CLI — with the official brand icon, placed next to AiHubMix (#3643). +- **CCSub Preset**: Added CCSub, a multi-model aggregator partner, as a quick-config preset across six apps — Claude Code, Claude Desktop, Codex, OpenCode, OpenClaw, and Hermes — with the official brand icon and the partner referral link prefilled as the API-key signup URL (`gpt-5.5` for the OpenAI-compatible Codex and OpenCode endpoints). - **Codex CLI Models Endpoint**: The local proxy now answers `GET /v1/models`, which Codex CLI probes at startup, returning the cc-switch-managed Codex model catalog. A stale-catalog guard parses the live `config.toml` and only serves the catalog when `model_catalog_json` still references the cc-switch-owned file, so a leftover catalog from a previous provider is not advertised (#3818). - **Codex Chat File and Audio Attachments**: The Codex Responses-to-Chat converter now maps `input_file` parts (carrying `file_id` or inline `file_data`) and `input_audio` parts into their Chat Completions equivalents, and emits top-level `input_*` items that were previously dropped, so file and audio attachments reach Chat-only Codex upstreams. diff --git a/src/config/claudeDesktopProviderPresets.ts b/src/config/claudeDesktopProviderPresets.ts index 8ac82b580..399c5c30c 100644 --- a/src/config/claudeDesktopProviderPresets.ts +++ b/src/config/claudeDesktopProviderPresets.ts @@ -167,19 +167,6 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [ partnerPromotionKey: "shengsuanyun", icon: "shengsuanyun", }, - { - name: "CCSub", - websiteUrl: "https://www.ccsub.net", - apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", - category: "aggregator", - baseUrl: "https://www.ccsub.net", - mode: "direct", - apiFormat: "anthropic", - modelRoutes: passthroughRoutes(true), - isPartner: true, - partnerPromotionKey: "ccsub", - icon: "ccsub", - }, { name: "PatewayAI", websiteUrl: "https://pateway.ai", @@ -253,6 +240,19 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [ icon: "doubao", iconColor: "#3370FF", }, + { + name: "CCSub", + websiteUrl: "https://www.ccsub.net", + apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", + category: "aggregator", + baseUrl: "https://www.ccsub.net", + mode: "direct", + apiFormat: "anthropic", + modelRoutes: passthroughRoutes(true), + isPartner: true, + partnerPromotionKey: "ccsub", + icon: "ccsub", + }, { name: "Gemini Native", websiteUrl: "https://ai.google.dev/gemini-api", diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 88bd2e6dc..18f09f5c7 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -109,21 +109,6 @@ export const providerPresets: ProviderPreset[] = [ partnerPromotionKey: "shengsuanyun", icon: "shengsuanyun", }, - { - name: "CCSub", - websiteUrl: "https://www.ccsub.net", - apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", - settingsConfig: { - env: { - ANTHROPIC_BASE_URL: "https://www.ccsub.net", - ANTHROPIC_AUTH_TOKEN: "", - }, - }, - category: "aggregator", - isPartner: true, - partnerPromotionKey: "ccsub", - icon: "ccsub", - }, { name: "PatewayAI", websiteUrl: "https://pateway.ai", @@ -208,6 +193,21 @@ export const providerPresets: ProviderPreset[] = [ icon: "doubao", iconColor: "#3370FF", }, + { + name: "CCSub", + websiteUrl: "https://www.ccsub.net", + apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://www.ccsub.net", + ANTHROPIC_AUTH_TOKEN: "", + }, + }, + category: "aggregator", + isPartner: true, + partnerPromotionKey: "ccsub", + icon: "ccsub", + }, { name: "Gemini Native", websiteUrl: "https://ai.google.dev/gemini-api", diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index d2e6aeb6d..160a54255 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -116,22 +116,6 @@ export const codexProviderPresets: CodexProviderPreset[] = [ partnerPromotionKey: "shengsuanyun", icon: "shengsuanyun", }, - { - name: "CCSub", - websiteUrl: "https://www.ccsub.net", - apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", - category: "aggregator", - auth: generateThirdPartyAuth(""), - config: generateThirdPartyConfig( - "ccsub", - "https://www.ccsub.net/v1", - "gpt-5.5", - ), - endpointCandidates: ["https://www.ccsub.net/v1"], - isPartner: true, - partnerPromotionKey: "ccsub", - icon: "ccsub", - }, { name: "PatewayAI", websiteUrl: "https://pateway.ai", @@ -231,6 +215,22 @@ export const codexProviderPresets: CodexProviderPreset[] = [ icon: "doubao", iconColor: "#3370FF", }, + { + name: "CCSub", + websiteUrl: "https://www.ccsub.net", + apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", + category: "aggregator", + auth: generateThirdPartyAuth(""), + config: generateThirdPartyConfig( + "ccsub", + "https://www.ccsub.net/v1", + "gpt-5.5", + ), + endpointCandidates: ["https://www.ccsub.net/v1"], + isPartner: true, + partnerPromotionKey: "ccsub", + icon: "ccsub", + }, { name: "Azure OpenAI", websiteUrl: diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index fda9561db..244e7f521 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -238,6 +238,56 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ }, }, }, + { + name: "CCSub", + websiteUrl: "https://www.ccsub.net", + apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", + settingsConfig: { + name: "ccsub", + base_url: "https://www.ccsub.net/v1", + api_key: "", + api_mode: "chat_completions", + models: [ + { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + context_length: 1000000, + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + context_length: 1000000, + }, + { + id: "gpt-5.5", + name: "GPT-5.5", + context_length: 400000, + }, + { + id: "o3", + name: "o3", + context_length: 200000, + }, + { + id: "gemini-3.1-pro", + name: "Gemini 3.1 Pro", + context_length: 1000000, + }, + { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + context_length: 1000000, + }, + ], + }, + category: "aggregator", + isPartner: true, + partnerPromotionKey: "ccsub", + icon: "ccsub", + suggestedDefaults: { + model: { default: "claude-opus-4-8", provider: "ccsub" }, + }, + }, { name: "OpenRouter", nameKey: "providerForm.presets.openrouter", @@ -351,56 +401,6 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ }, }, }, - { - name: "CCSub", - websiteUrl: "https://www.ccsub.net", - apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", - settingsConfig: { - name: "ccsub", - base_url: "https://www.ccsub.net/v1", - api_key: "", - api_mode: "chat_completions", - models: [ - { - id: "claude-opus-4-8", - name: "Claude Opus 4.8", - context_length: 1000000, - }, - { - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - context_length: 1000000, - }, - { - id: "gpt-5.5", - name: "GPT-5.5", - context_length: 400000, - }, - { - id: "o3", - name: "o3", - context_length: 200000, - }, - { - id: "gemini-3.1-pro", - name: "Gemini 3.1 Pro", - context_length: 1000000, - }, - { - id: "deepseek-v4-flash", - name: "DeepSeek V4 Flash", - context_length: 1000000, - }, - ], - }, - category: "aggregator", - isPartner: true, - partnerPromotionKey: "ccsub", - icon: "ccsub", - suggestedDefaults: { - model: { default: "claude-opus-4-8", provider: "ccsub" }, - }, - }, { name: "Nous Research", websiteUrl: "https://nousresearch.com", diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index 223327177..0b168da37 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -256,6 +256,75 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, }, }, + { + name: "CCSub", + websiteUrl: "https://www.ccsub.net", + apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", + settingsConfig: { + baseUrl: "https://www.ccsub.net/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + contextWindow: 1000000, + cost: { input: 5, output: 25 }, + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + contextWindow: 1000000, + cost: { input: 3, output: 15 }, + }, + { + id: "gpt-5.5", + name: "GPT-5.5", + contextWindow: 400000, + cost: { input: 5, output: 15 }, + }, + { + id: "o3", + name: "o3", + contextWindow: 200000, + cost: { input: 10, output: 40 }, + }, + { + id: "gemini-3.1-pro", + name: "Gemini 3.1 Pro", + contextWindow: 1000000, + cost: { input: 1.25, output: 10 }, + }, + { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + contextWindow: 1000000, + cost: { input: 0.14, output: 0.28 }, + }, + ], + }, + category: "aggregator", + isPartner: true, + partnerPromotionKey: "ccsub", + icon: "ccsub", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { + primary: "ccsub/claude-opus-4-8", + fallbacks: ["ccsub/claude-sonnet-4-6"], + }, + modelCatalog: { + "ccsub/claude-opus-4-8": { alias: "Opus" }, + "ccsub/claude-sonnet-4-6": { alias: "Sonnet" }, + }, + }, + }, // ========== Chinese Officials ========== { name: "DeepSeek", @@ -900,75 +969,6 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, }, }, - { - name: "CCSub", - websiteUrl: "https://www.ccsub.net", - apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", - settingsConfig: { - baseUrl: "https://www.ccsub.net/v1", - apiKey: "", - api: "openai-completions", - models: [ - { - id: "claude-opus-4-8", - name: "Claude Opus 4.8", - contextWindow: 1000000, - cost: { input: 5, output: 25 }, - }, - { - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - contextWindow: 1000000, - cost: { input: 3, output: 15 }, - }, - { - id: "gpt-5.5", - name: "GPT-5.5", - contextWindow: 400000, - cost: { input: 5, output: 15 }, - }, - { - id: "o3", - name: "o3", - contextWindow: 200000, - cost: { input: 10, output: 40 }, - }, - { - id: "gemini-3.1-pro", - name: "Gemini 3.1 Pro", - contextWindow: 1000000, - cost: { input: 1.25, output: 10 }, - }, - { - id: "deepseek-v4-flash", - name: "DeepSeek V4 Flash", - contextWindow: 1000000, - cost: { input: 0.14, output: 0.28 }, - }, - ], - }, - category: "aggregator", - isPartner: true, - partnerPromotionKey: "ccsub", - icon: "ccsub", - templateValues: { - apiKey: { - label: "API Key", - placeholder: "", - editorValue: "", - }, - }, - suggestedDefaults: { - model: { - primary: "ccsub/claude-opus-4-8", - fallbacks: ["ccsub/claude-sonnet-4-6"], - }, - modelCatalog: { - "ccsub/claude-opus-4-8": { alias: "Opus" }, - "ccsub/claude-sonnet-4-6": { alias: "Sonnet" }, - }, - }, - }, { name: "CherryIN", websiteUrl: "https://open.cherryin.ai", diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 103aacdf0..4d6cda1e2 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -308,39 +308,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, }, - { - name: "CCSub", - websiteUrl: "https://www.ccsub.net", - apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", - settingsConfig: { - npm: "@ai-sdk/openai-compatible", - name: "CCSub", - options: { - baseURL: "https://www.ccsub.net/v1", - apiKey: "", - setCacheKey: true, - }, - models: { - "claude-opus-4-8": { name: "Claude Opus 4.8" }, - "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, - "gpt-5.5": { name: "GPT-5.5" }, - o3: { name: "o3" }, - "gemini-3.1-pro": { name: "Gemini 3.1 Pro" }, - "deepseek-v4-flash": { name: "DeepSeek V4 Flash" }, - }, - }, - category: "aggregator", - isPartner: true, - partnerPromotionKey: "ccsub", - icon: "ccsub", - templateValues: { - apiKey: { - label: "API Key", - placeholder: "", - editorValue: "", - }, - }, - }, { name: "火山Agentplan", websiteUrl: @@ -440,6 +407,39 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, }, + { + name: "CCSub", + websiteUrl: "https://www.ccsub.net", + apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA", + settingsConfig: { + npm: "@ai-sdk/openai-compatible", + name: "CCSub", + options: { + baseURL: "https://www.ccsub.net/v1", + apiKey: "", + setCacheKey: true, + }, + models: { + "claude-opus-4-8": { name: "Claude Opus 4.8" }, + "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, + "gpt-5.5": { name: "GPT-5.5" }, + o3: { name: "o3" }, + "gemini-3.1-pro": { name: "Gemini 3.1 Pro" }, + "deepseek-v4-flash": { name: "DeepSeek V4 Flash" }, + }, + }, + category: "aggregator", + isPartner: true, + partnerPromotionKey: "ccsub", + icon: "ccsub", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + }, { name: "DeepSeek", websiteUrl: "https://platform.deepseek.com", diff --git a/tests/config/providerPresetOrder.test.ts b/tests/config/providerPresetOrder.test.ts deleted file mode 100644 index 4f42c8f09..000000000 --- a/tests/config/providerPresetOrder.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { providerPresets } from "@/config/claudeProviderPresets"; -import { claudeDesktopProviderPresets } from "@/config/claudeDesktopProviderPresets"; -import { codexProviderPresets } from "@/config/codexProviderPresets"; -import { opencodeProviderPresets } from "@/config/opencodeProviderPresets"; -import { openclawProviderPresets } from "@/config/openclawProviderPresets"; -import { hermesProviderPresets } from "@/config/hermesProviderPresets"; - -const namesOf = (presets: Array<{ name: string }>) => - presets.map((preset) => preset.name); - -const expectInOrder = (names: string[], expected: string[]) => { - const indexes = expected.map((name) => names.indexOf(name)); - - expect(indexes).not.toContain(-1); - expect(indexes).toEqual(expected.map((_, index) => indexes[0] + index)); -}; - -describe("provider preset order", () => { - it("Claude 预设按合作伙伴优先顺序排列", () => { - expectInOrder(namesOf(providerPresets), [ - "Shengsuanyun", - "PatewayAI", - "火山Agentplan", - "BytePlus", - "DouBaoSeed", - ]); - }); - - it("Claude Desktop 预设按合作伙伴优先顺序排列", () => { - expectInOrder(namesOf(claudeDesktopProviderPresets), [ - "Shengsuanyun", - "PatewayAI", - "火山Agentplan", - "BytePlus", - "DouBaoSeed", - ]); - }); - - it("Claude Desktop 预设包含官方登录入口", () => { - expect(claudeDesktopProviderPresets[0]).toMatchObject({ - name: "Claude Desktop Official", - category: "official", - baseUrl: "", - mode: "direct", - }); - }); - - it("Codex 预设按合作伙伴优先顺序排列", () => { - expectInOrder(namesOf(codexProviderPresets), [ - "Shengsuanyun", - "PatewayAI", - "火山Agentplan", - "BytePlus", - "DouBaoSeed", - ]); - }); - - it("OpenCode 预设把火山、BytePlus、DouBaoSeed 放在胜算云后面", () => { - expectInOrder(namesOf(opencodeProviderPresets), [ - "Shengsuanyun", - "火山Agentplan", - "BytePlus", - "DouBaoSeed", - ]); - }); - - it("OpenClaw 预设把火山、BytePlus、DouBaoSeed 放在胜算云后面", () => { - expectInOrder(namesOf(openclawProviderPresets), [ - "Shengsuanyun", - "火山Agentplan", - "BytePlus", - "DouBaoSeed", - ]); - }); - - it("Hermes 预设把火山、BytePlus、DouBaoSeed 放在胜算云后面", () => { - expectInOrder(namesOf(hermesProviderPresets), [ - "Shengsuanyun", - "火山Agentplan", - "BytePlus", - "DouBaoSeed", - ]); - }); -}); From 955ea26da9ad35471f8018850b555bf068c73039 Mon Sep 17 00:00:00 2001 From: oriengy <50244473+oriengy@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:25:28 +0800 Subject: [PATCH 05/66] fix(presets): add Kimi affiliate links (#3809) Problem: Kimi and Moonshot preset links were user-clickable without the cc-switch affiliate query.\n\nDecision: Update only UI-facing preset website/API-key links and leave API request endpoints untouched.\n\nChange: Add aff=cc-switch to Kimi/Moonshot websiteUrl values and Codex/OpenCode API-key links. Co-authored-by: xumingyuan --- src/config/claudeDesktopProviderPresets.ts | 4 ++-- src/config/claudeProviderPresets.ts | 4 ++-- src/config/codexProviderPresets.ts | 4 ++-- src/config/hermesProviderPresets.ts | 4 ++-- src/config/openclawProviderPresets.ts | 4 ++-- src/config/opencodeProviderPresets.ts | 8 ++++---- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/config/claudeDesktopProviderPresets.ts b/src/config/claudeDesktopProviderPresets.ts index 399c5c30c..3ee250a78 100644 --- a/src/config/claudeDesktopProviderPresets.ts +++ b/src/config/claudeDesktopProviderPresets.ts @@ -398,7 +398,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [ }, { name: "Kimi", - websiteUrl: "https://platform.moonshot.cn/console", + websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch", category: "cn_official", baseUrl: "https://api.moonshot.cn/anthropic", mode: "proxy", @@ -409,7 +409,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [ }, { name: "Kimi For Coding", - websiteUrl: "https://www.kimi.com/code/docs/", + websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch", category: "cn_official", baseUrl: "https://api.kimi.com/coding/", mode: "proxy", diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 18f09f5c7..1e89b8b53 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -352,7 +352,7 @@ export const providerPresets: ProviderPreset[] = [ }, { name: "Kimi", - websiteUrl: "https://platform.moonshot.cn/console", + websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch", settingsConfig: { env: { ANTHROPIC_BASE_URL: "https://api.moonshot.cn/anthropic", @@ -369,7 +369,7 @@ export const providerPresets: ProviderPreset[] = [ }, { name: "Kimi For Coding", - websiteUrl: "https://www.kimi.com/code/docs/", + websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch", settingsConfig: { env: { ANTHROPIC_BASE_URL: "https://api.kimi.com/coding/", diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 160a54255..6adaf1daa 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -404,8 +404,8 @@ requires_openai_auth = true`, }, { name: "Kimi", - websiteUrl: "https://platform.moonshot.cn/console", - apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", + websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch", + apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch", auth: generateThirdPartyAuth(""), config: generateThirdPartyConfig( "kimi", diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index 244e7f521..a2fd52ddd 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -515,7 +515,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ }, { name: "Kimi", - websiteUrl: "https://platform.moonshot.cn/console", + websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch", settingsConfig: { name: "kimi", base_url: "https://api.moonshot.cn/v1", @@ -532,7 +532,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ }, { name: "Kimi For Coding", - websiteUrl: "https://www.kimi.com/code/docs/", + websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch", settingsConfig: { name: "kimi_coding", base_url: "https://api.kimi.com/coding/", diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index 0b168da37..61847b64b 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -486,7 +486,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, { name: "Kimi k2.6", - websiteUrl: "https://platform.moonshot.cn/console", + websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch", apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", settingsConfig: { baseUrl: "https://api.moonshot.cn/v1", @@ -524,7 +524,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, { name: "Kimi For Coding", - websiteUrl: "https://www.kimi.com/code/docs/", + websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch", apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", settingsConfig: { baseUrl: "https://api.kimi.com/v1", diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 4d6cda1e2..77cd095fc 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -566,8 +566,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, { name: "Kimi k2.6", - websiteUrl: "https://platform.moonshot.cn/console", - apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", + websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch", + apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch", settingsConfig: { npm: "@ai-sdk/openai-compatible", name: "Kimi k2.6", @@ -599,8 +599,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, { name: "Kimi For Coding", - websiteUrl: "https://www.kimi.com/code/docs/", - apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", + websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch", + apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch", settingsConfig: { npm: "@ai-sdk/anthropic", name: "Kimi For Coding", From edc597ab23149ed698f6a641600513d9cc2ce71f Mon Sep 17 00:00:00 2001 From: LaoYueHanNi Date: Tue, 9 Jun 2026 20:30:05 +0800 Subject: [PATCH 06/66] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Completions=E8=BD=AC?= =?UTF-8?q?Anthropic=E6=97=B6=E4=B8=8D=E8=AE=B0=E5=BD=95=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E6=A8=A1=E5=9E=8B=E3=80=81Input=20token?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E9=94=99=E8=AF=AF=E9=97=AE=E9=A2=98=20(#2774?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(proxy): 修复completions转claude格式流式响应未记录实际命中模型 * style: cargo fmt fix * fix(proxy): 修复completions转claude格式时input与cache_read重复计费 * fix(proxy): 修复完全缓存命中时input_tokens计算错误 * test: 更新input_tokens期望值匹配去重逻辑 --- src-tauri/src/proxy/handlers.rs | 7 ++++--- src-tauri/src/proxy/providers/streaming.rs | 15 ++++++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 93a51e65c..9b216a123 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -333,7 +333,7 @@ async fn handle_claude_transform( let usage_collector = if usage_logging_enabled(state) { let state = state.clone(); let provider_id = ctx.provider.id.clone(); - let model = ctx.request_model.clone(); + let request_model = ctx.request_model.clone(); let status_code = status.as_u16(); let start_time = ctx.start_time; let session_id = ctx.session_id.clone(); @@ -343,11 +343,12 @@ async fn handle_claude_transform( Some(claude_stream_usage_event_filter), move |events, first_token_ms| { if let Some(usage) = TokenUsage::from_claude_stream_events(&events) { + let model = usage.model.clone().unwrap_or(request_model.clone()); let latency_ms = start_time.elapsed().as_millis() as u64; let state = state.clone(); let provider_id = provider_id.clone(); - let model = model.clone(); let session_id = session_id.clone(); + let request_model = request_model.clone(); tokio::spawn(async move { log_usage( @@ -355,7 +356,7 @@ async fn handle_claude_transform( &provider_id, "claude", &model, - &model, + &request_model, usage, latency_ms, first_token_ms, diff --git a/src-tauri/src/proxy/providers/streaming.rs b/src-tauri/src/proxy/providers/streaming.rs index 150d29f99..b68a2d522 100644 --- a/src-tauri/src/proxy/providers/streaming.rs +++ b/src-tauri/src/proxy/providers/streaming.rs @@ -100,11 +100,14 @@ struct ToolBlockState { const INFINITE_WHITESPACE_THRESHOLD: usize = 500; fn build_anthropic_usage_json(usage: &Usage) -> Value { + // OpenAI prompt_tokens 含缓存,Anthropic input_tokens 不含,需减去 + let cached = extract_cache_read_tokens(usage).unwrap_or(0); + let input_tokens = usage.prompt_tokens.saturating_sub(cached); let mut usage_json = json!({ - "input_tokens": usage.prompt_tokens, + "input_tokens": input_tokens, "output_tokens": usage.completion_tokens }); - if let Some(cached) = extract_cache_read_tokens(usage) { + if cached > 0 { usage_json["cache_read_input_tokens"] = json!(cached); } if let Some(created) = usage.cache_creation_input_tokens { @@ -223,8 +226,10 @@ pub fn create_anthropic_sse_stream( "output_tokens": 0 }); if let Some(u) = &chunk.usage { - start_usage["input_tokens"] = json!(u.prompt_tokens); - if let Some(cached) = extract_cache_read_tokens(u) { + let cached = extract_cache_read_tokens(u).unwrap_or(0); + let input = u.prompt_tokens.saturating_sub(cached); + start_usage["input_tokens"] = json!(input); + if cached > 0 { start_usage["cache_read_input_tokens"] = json!(cached); } if let Some(created) = u.cache_creation_input_tokens { @@ -1022,7 +1027,7 @@ mod tests { message_delta .pointer("/usage/input_tokens") .and_then(|v| v.as_u64()), - Some(13312) + Some(13212) ); assert_eq!( message_delta From 4f911727d2e9ba7a6bd6eca93fa2ceb65fd4505e Mon Sep 17 00:00:00 2001 From: que3sui Date: Wed, 10 Jun 2026 21:42:54 +0800 Subject: [PATCH 07/66] fix: prevent duplicate YAML keys in Hermes config (#3267) * fix: prevent duplicate YAML keys in Hermes config Three changes in hermes_config.rs: 1. deduplicate_top_level_keys() - scan and remove duplicate top-level keys before YAML parsing, preventing "duplicate entry" parse errors 2. remove_all_sections() - helper to strip all occurrences of a given top-level key from raw YAML text 3. replace_yaml_section() now calls remove_all_sections() on the remainder after replacing the primary occurrence, preventing duplicate sections from accumulating on repeated writes Fixes the issue where mcp_servers (or any top-level key) gets duplicated in config.yaml, causing "Failed to parse Hermes config as YAML: duplicate entry with key" errors. Co-Authored-By: que3sui <204201112+que3sui@users.noreply.github.com> * fix: handle CRLF and LF line endings in top-level key deduplication is_top_level_key_line only accepted empty, space, or tab after the colon, but deduplicate_top_level_keys uses split_inclusive('\n'), so lines end with \n (LF) or \r\n (CRLF). Without accepting \r and \n as valid post-colon characters, the dedup safety net never activates. Add \r and \n checks to is_top_level_key_line, and three tests covering LF, CRLF, and first-occurrence preservation. Co-Authored-By: Claude Opus 4.7 * refactor(hermes): keep last occurrence when healing duplicate YAML keys Reworks the healing layers on top of the CRLF root-cause fix: - deduplicate_top_level_keys: keep the LAST occurrence of each duplicated key instead of the first. Duplicates come from section replacement degrading into appends (#3633), so the last block is the newest data -- and Hermes itself reads the config with PyYAML, whose duplicate-key semantics are last-wins. Keeping the first occurrence would silently roll users back to stale config and diverge from what Hermes runs with. Healthy files take a fast path and are returned untouched. - Drop the unused dup_key variable (fails cargo clippy -- -D warnings, which CI enforces). - replace_yaml_section: clean residual duplicate sections from the remainder via remove_all_sections; values come from the keep-last healed read, so dropping all stale on-disk copies loses nothing. - Add regression tests for the actual root cause (find/replace on CRLF input must replace in place, not append), keep-last semantics, identity on healthy files, end-to-end heal-then-parse, and duplicate cleanup on write. Fixes #3633 #2973 #2529 #3310 #3762 --------- Co-authored-by: que3sui <204201112+que3sui@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 Co-authored-by: Jason --- src-tauri/src/hermes_config.rs | 257 ++++++++++++++++++++++++++++++++- 1 file changed, 253 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index cba95617d..c9321886c 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -116,10 +116,76 @@ pub fn read_hermes_config() -> Result { return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); } - serde_yaml::from_str(&content) + // Heal duplicate top-level keys left behind by the pre-CRLF-fix append + // bug (#3633); serde_yaml rejects them outright, which bricked the panel. + let deduped = deduplicate_top_level_keys(&content); + + serde_yaml::from_str(&deduped) .map_err(|e| AppError::Config(format!("Failed to parse Hermes config as YAML: {e}"))) } +/// Remove duplicate top-level YAML sections, keeping the LAST occurrence of +/// each key. +/// +/// Keep-last is deliberate, not arbitrary: the duplicates come from section +/// replacement degrading into appends (#3633), so the last block is the +/// newest data — and Hermes itself reads the file with PyYAML, whose +/// duplicate-key semantics are last-wins. Keeping the first occurrence would +/// silently roll the user back to stale config and diverge from what Hermes +/// actually runs with. +fn deduplicate_top_level_keys(raw: &str) -> String { + use std::collections::HashMap; + + // Pass 1: locate every top-level key line as (key, byte offset). + let mut sections: Vec<(&str, usize)> = Vec::new(); + let mut offset = 0; + for line in raw.split('\n') { + if is_top_level_key_line(line) { + if let Some(colon_pos) = line.find(':') { + sections.push((&line[..colon_pos], offset)); + } + } + offset += line.len() + 1; + } + + let mut remaining: HashMap<&str, usize> = HashMap::new(); + for (key, _) in §ions { + *remaining.entry(key).or_insert(0) += 1; + } + if remaining.values().all(|&count| count <= 1) { + return raw.to_string(); + } + + // Pass 2: re-emit, dropping every section that has a later occurrence of + // the same key. A section spans from its key line to the next top-level + // key line (or EOF), matching find_yaml_section_range. Content before the + // first section (comments, document markers) is always kept. + let mut result = String::with_capacity(raw.len()); + let head_end = sections + .first() + .map(|&(_, start)| start) + .unwrap_or(raw.len()); + result.push_str(&raw[..head_end]); + + for (i, &(key, start)) in sections.iter().enumerate() { + let end = sections + .get(i + 1) + .map(|&(_, next_start)| next_start) + .unwrap_or(raw.len()); + let count = remaining.get_mut(key).expect("key collected in pass 1"); + *count -= 1; + if *count > 0 { + log::warn!( + "Hermes config: dropped duplicate top-level section '{key}' (keeping the last occurrence)" + ); + continue; + } + result.push_str(&raw[start..end]); + } + + result +} + // ============================================================================ // YAML Section-Level Replacement // ============================================================================ @@ -132,6 +198,11 @@ pub fn read_hermes_config() -> Result { /// - Not be a comment (starting with `#`) /// - Not be a sequence item (starting with `-`) /// - Contain `:` followed by space, tab, newline, or end-of-line +/// +/// Lines may carry a trailing `\r` (CRLF files split on `\n`) or `\n` +/// (callers using `split_inclusive`); both count as end-of-line after the +/// colon. Rejecting `\r` here used to make every section lookup miss on +/// CRLF configs, turning section replacement into endless appends (#3633). fn is_top_level_key_line(line: &str) -> bool { if line.is_empty() { return false; @@ -142,7 +213,7 @@ fn is_top_level_key_line(line: &str) -> bool { } if let Some(colon_pos) = line.find(':') { let after_colon = &line[colon_pos + 1..]; - after_colon.is_empty() || after_colon.starts_with(' ') || after_colon.starts_with('\t') + after_colon.is_empty() || after_colon.starts_with([' ', '\t', '\r', '\n']) } else { false } @@ -196,6 +267,21 @@ fn serialize_yaml_section(key: &str, value: &serde_yaml::Value) -> Result String { + let mut result = String::with_capacity(raw.len()); + let mut rest = raw; + while let Some((start, end)) = find_yaml_section_range(rest, section_key) { + result.push_str(&rest[..start]); + rest = &rest[end..]; + } + result.push_str(rest); + result +} + /// Replace a YAML section in raw text, or append it if not found. fn replace_yaml_section( raw: &str, @@ -208,12 +294,14 @@ fn replace_yaml_section( let mut result = String::with_capacity(raw.len()); result.push_str(&raw[..start]); result.push_str(&serialized); + // Drop duplicate sections of this key from the remainder — configs + // written before the CRLF fix may carry several appended copies. + let remainder = remove_all_sections(&raw[end..], section_key); // Ensure proper separation between sections - let remainder = &raw[end..]; if !serialized.ends_with('\n') && !remainder.is_empty() && !remainder.starts_with('\n') { result.push('\n'); } - result.push_str(remainder); + result.push_str(&remainder); Ok(result) } else { // Section not found — append at end @@ -1204,6 +1292,111 @@ model: assert!(!section.starts_with("model_extra:")); } + #[test] + fn find_section_handles_crlf() { + // Regression for #3633: CRLF line endings must not hide sections. + let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\n"; + let (start, end) = find_yaml_section_range(yaml, "model").unwrap(); + let section = &yaml[start..end]; + assert!(section.starts_with("model:")); + assert!(section.contains("default: gpt-4")); + assert!(!section.contains("agent:")); + } + + // ---- deduplicate_top_level_keys tests ---- + + #[test] + fn dedup_keeps_last_occurrence() { + // Duplicates come from replace-degraded-to-append, so the last block + // is the newest data and must win (PyYAML last-wins, like Hermes). + let yaml = "\ +model: + default: gpt-4 +agent: + max_turns: 10 +model: + default: claude-opus-4-8 +"; + let result = deduplicate_top_level_keys(yaml); + assert_eq!( + result.lines().filter(|l| *l == "model:").count(), + 1, + "duplicate model: section was not removed" + ); + assert!(result.contains("claude-opus-4-8")); + assert!(!result.contains("gpt-4")); + assert!(result.contains("max_turns")); + } + + #[test] + fn dedup_handles_crlf() { + let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\nmodel:\r\n default: claude\r\n"; + let result = deduplicate_top_level_keys(yaml); + assert_eq!(result.lines().filter(|l| l.trim() == "model:").count(), 1); + assert!(result.contains("default: claude")); + assert!(!result.contains("gpt-4")); + } + + #[test] + fn dedup_is_identity_without_duplicates() { + let yaml = "\ +# Hermes config +model: + default: gpt-4 + +agent: + max_turns: 10 +"; + assert_eq!(deduplicate_top_level_keys(yaml), yaml); + } + + #[test] + fn dedup_result_parses_with_last_value() { + // End-to-end: a config that serde_yaml rejects today must parse after + // healing, and expose the newest (last) value. + let yaml = "\ +custom_providers: + - name: old-provider +model: + default: gpt-4 +custom_providers: + - name: old-provider + - name: new-provider +"; + let healed = deduplicate_top_level_keys(yaml); + let value: serde_yaml::Value = serde_yaml::from_str(&healed).unwrap(); + let providers = value + .get("custom_providers") + .unwrap() + .as_sequence() + .unwrap(); + assert_eq!(providers.len(), 2); + assert_eq!( + providers[1].get("name").unwrap().as_str().unwrap(), + "new-provider" + ); + } + + // ---- remove_all_sections tests ---- + + #[test] + fn remove_all_sections_strips_every_occurrence() { + let yaml = "\ +model: + default: gpt-4 +agent: + max_turns: 10 +model: + default: claude +model: + default: gemini +"; + let result = remove_all_sections(yaml, "model"); + assert!(!result.contains("model:")); + assert!(result.contains("agent:")); + assert!(result.contains("max_turns")); + } + // ---- replace_yaml_section tests ---- #[test] @@ -1239,6 +1432,62 @@ agent: assert!(!result.contains("openai")); } + #[test] + fn replace_section_in_crlf_config_replaces_in_place() { + // Regression for #3633: on CRLF configs every "replace" used to + // degrade into an append, piling up duplicate sections. + let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\n"; + let new_model = serde_yaml::Value::Mapping({ + let mut m = serde_yaml::Mapping::new(); + m.insert( + serde_yaml::Value::String("default".to_string()), + serde_yaml::Value::String("claude-opus-4-8".to_string()), + ); + m + }); + + let result = replace_yaml_section(yaml, "model", &new_model).unwrap(); + assert_eq!( + result.lines().filter(|l| l.trim() == "model:").count(), + 1, + "model: must be replaced in place, not appended" + ); + assert!(result.contains("claude-opus-4-8")); + assert!(!result.contains("gpt-4")); + assert!(result.contains("max_turns")); + } + + #[test] + fn replace_section_removes_residual_duplicates() { + // A config already broken by the append bug: replacing the section + // must also clean the stale duplicate copies after it. + let yaml = "\ +model: + default: gpt-4 +agent: + max_turns: 10 +model: + default: stale-copy +"; + let new_model = serde_yaml::Value::Mapping({ + let mut m = serde_yaml::Mapping::new(); + m.insert( + serde_yaml::Value::String("default".to_string()), + serde_yaml::Value::String("claude-opus-4-8".to_string()), + ); + m + }); + + let result = replace_yaml_section(yaml, "model", &new_model).unwrap(); + assert_eq!(result.lines().filter(|l| *l == "model:").count(), 1); + assert!(result.contains("claude-opus-4-8")); + assert!(!result.contains("stale-copy")); + assert!(result.contains("agent:")); + // The healed output must be valid YAML again + let parsed: Result = serde_yaml::from_str(&result); + assert!(parsed.is_ok()); + } + #[test] fn append_new_section() { let yaml = "\ From 9ea303b22451ac63cfbe29d846bc53c651b579e4 Mon Sep 17 00:00:00 2001 From: pa001024 Date: Wed, 10 Jun 2026 22:57:27 +0800 Subject: [PATCH 08/66] fix: usage script provider credential resolution (#1479) The JS-script usage path resolved {{apiKey}}/{{baseUrl}} with env-only field guessing, so apps that store credentials elsewhere (Codex: auth.OPENAI_API_KEY + config.toml base_url) always got empty values and custom-template queries failed despite a fully configured provider. - query_usage / test_usage_script now delegate to Provider::resolve_usage_credentials, the same per-app resolver used by the native balance/coding-plan path and mirrored by the frontend getProviderCredentials; explicit non-empty script values still win - test_usage_script loads the provider and applies the same fallback, so testing matches what a saved script does - the custom-template variable preview shows the effective values (script overrides first, then provider config) instead of always showing provider credentials - extract_codex_base_url documents and test-locks the frontend-mirror invariant: non-active [model_providers.*] sections are never read Reworked from the original patch to reuse the existing resolver instead of duplicating per-app extraction. Co-authored-by: Jason --- src-tauri/src/codex_config.rs | 49 ++++++- src-tauri/src/provider.rs | 10 +- src-tauri/src/services/provider/usage.rs | 167 +++++++++++++++++------ src/components/UsageScriptModal.tsx | 23 ++-- 4 files changed, 189 insertions(+), 60 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index ac9b43bf1..965d5848a 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -208,7 +208,10 @@ pub fn extract_codex_api_key(auth: Option<&Value>, config_text: Option<&str>) -> /// Extract the upstream base URL from a Codex `config.toml` string. /// /// Prefers the active `[model_providers.].base_url`, falling -/// back to a top-level `base_url` when no model provider is selected. +/// back to a top-level `base_url`. Deliberately never reads a non-active +/// `[model_providers.*]` section — the frontend `extractCodexBaseUrl` +/// (`getRecoverableBaseUrlAssignments`) excludes those too, and a leftover +/// section unrelated to the active provider must not leak into `{{baseUrl}}`. pub fn extract_codex_base_url(config_text: &str) -> Option { let doc = config_text.parse::().ok()?; @@ -1254,6 +1257,50 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn extract_base_url_prefers_active_provider_section() { + let input = r#"model_provider = "azure" + +[model_providers.azure] +base_url = "https://azure.example.com/v1" + +[model_providers.other] +base_url = "https://other.example.com/v1" +"#; + + assert_eq!( + extract_codex_base_url(input).as_deref(), + Some("https://azure.example.com/v1") + ); + } + + #[test] + fn extract_base_url_falls_back_to_top_level_only() { + let top_level = r#"base_url = "https://top-level.example.com/v1""#; + assert_eq!( + extract_codex_base_url(top_level).as_deref(), + Some("https://top-level.example.com/v1") + ); + } + + // Mirrors the frontend extractCodexBaseUrl: a non-active provider section + // is never a credential source, whether the active provider points + // elsewhere (e.g. the built-in "openai") or none is selected at all. + #[test] + fn extract_base_url_ignores_non_active_provider_sections() { + let mismatched = r#"model_provider = "openai" + +[model_providers.custom] +base_url = "https://leftover.example.com/v1" +"#; + assert_eq!(extract_codex_base_url(mismatched), None); + + let no_active = r#"[model_providers.any] +base_url = "https://single.example.com/v1" +"#; + assert_eq!(extract_codex_base_url(no_active), None); + } + #[test] fn prepare_provider_live_config_rejects_key_without_config() { let err = prepare_codex_provider_live_config(&json!({"OPENAI_API_KEY": "sk-test"}), "") diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index c4fdc1458..160ae8be9 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -108,17 +108,13 @@ impl Provider { .unwrap_or(false) } - /// Resolve `(base_url, api_key)` for native usage queries (balance / - /// coding-plan) from the stored provider config. + /// Resolve `(base_url, api_key)` for usage queries (native balance / + /// coding-plan and the JS-script `{{apiKey}}`/`{{baseUrl}}` fallback) + /// from the stored provider config. /// /// Each app persists credentials in a different shape, so callers must pass /// the owning app type. This mirrors the frontend `getProviderCredentials` /// in `UsageScriptModal.tsx`. - /// - /// TODO: the env-only helpers in `services/provider/usage.rs` - /// (`extract_api_key_from_provider` / `extract_base_url_from_provider`) - /// duplicate this per-app logic on the JS-script path and could delegate - /// here in a follow-up to remove the remaining copy. pub fn resolve_usage_credentials( &self, app_type: &crate::app_config::AppType, diff --git a/src-tauri/src/services/provider/usage.rs b/src-tauri/src/services/provider/usage.rs index 5d567dc7f..7388249b8 100644 --- a/src-tauri/src/services/provider/usage.rs +++ b/src-tauri/src/services/provider/usage.rs @@ -81,32 +81,33 @@ pub(crate) async fn execute_and_format_usage_result( } } -/// Extract API key from provider configuration -fn extract_api_key_from_provider(provider: &crate::provider::Provider) -> Option { - if let Some(env) = provider.settings_config.get("env") { - // Try multiple possible API key fields - env.get("ANTHROPIC_AUTH_TOKEN") - .or_else(|| env.get("ANTHROPIC_API_KEY")) - .or_else(|| env.get("OPENROUTER_API_KEY")) - .or_else(|| env.get("GOOGLE_API_KEY")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - } else { - None - } -} +/// Resolve `(api_key, base_url)` for the JS-script path: explicit non-empty +/// script values win, otherwise fall back to the provider's stored config via +/// `Provider::resolve_usage_credentials` — the same per-app resolver the +/// native balance/coding-plan path and the frontend `getProviderCredentials` +/// use, so `{{apiKey}}`/`{{baseUrl}}` match what the UI shows for them. +fn resolve_script_credentials( + app_type: &AppType, + provider: &crate::provider::Provider, + api_key: Option<&str>, + base_url: Option<&str>, +) -> (String, String) { + let (provider_base_url, provider_api_key) = provider.resolve_usage_credentials(app_type); -/// Extract base URL from provider configuration -fn extract_base_url_from_provider(provider: &crate::provider::Provider) -> Option { - if let Some(env) = provider.settings_config.get("env") { - // Try multiple possible base URL fields - env.get("ANTHROPIC_BASE_URL") - .or_else(|| env.get("GOOGLE_GEMINI_BASE_URL")) - .and_then(|v| v.as_str()) - .map(|s| s.trim_end_matches('/').to_string()) - } else { - None - } + let api_key = api_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .unwrap_or(provider_api_key); + + let base_url = base_url + .map(str::trim) + .filter(|value| !value.is_empty()) + // Trim like the provider path so `{{baseUrl}}/path` never doubles the slash. + .map(|value| value.trim_end_matches('/').to_owned()) + .unwrap_or(provider_base_url); + + (api_key, base_url) } /// Query provider usage (using saved script configuration) @@ -145,19 +146,12 @@ pub async fn query_usage( } // Get credentials: prioritize UsageScript values, fallback to provider config - let api_key = usage_script - .api_key - .clone() - .filter(|k| !k.is_empty()) - .or_else(|| extract_api_key_from_provider(provider)) - .unwrap_or_default(); - - let base_url = usage_script - .base_url - .clone() - .filter(|u| !u.is_empty()) - .or_else(|| extract_base_url_from_provider(provider)) - .unwrap_or_default(); + let (api_key, base_url) = resolve_script_credentials( + &app_type, + provider, + usage_script.api_key.as_deref(), + usage_script.base_url.as_deref(), + ); ( usage_script.code.clone(), @@ -185,9 +179,9 @@ pub async fn query_usage( /// Test usage script (using temporary script content, not saved) #[allow(clippy::too_many_arguments)] pub async fn test_usage_script( - _state: &AppState, - _app_type: AppType, - _provider_id: &str, + state: &AppState, + app_type: AppType, + provider_id: &str, script_code: &str, timeout: u64, api_key: Option<&str>, @@ -196,11 +190,23 @@ pub async fn test_usage_script( user_id: Option<&str>, template_type: Option<&str>, ) -> Result { - // Use provided credential parameters directly for testing + let providers = state.db.get_all_providers(app_type.as_str())?; + let provider = providers.get(provider_id).ok_or_else(|| { + AppError::localized( + "provider.not_found", + format!("供应商不存在: {provider_id}"), + format!("Provider not found: {provider_id}"), + ) + })?; + + // Resolve like the real query so testing matches what a saved script does: + // explicit values win, empty ones fall back to the provider config. + let (api_key, base_url) = resolve_script_credentials(&app_type, provider, api_key, base_url); + execute_and_format_usage_result( script_code, - api_key.unwrap_or(""), - base_url.unwrap_or(""), + &api_key, + &base_url, timeout, access_token, user_id, @@ -226,3 +232,76 @@ pub(crate) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError Ok(()) } + +#[cfg(test)] +mod tests { + use super::resolve_script_credentials; + use crate::app_config::AppType; + use crate::provider::Provider; + use serde_json::json; + + fn provider_with_settings(settings_config: serde_json::Value) -> Provider { + Provider::with_id( + "provider-1".to_string(), + "Provider".to_string(), + settings_config, + None, + ) + } + + #[test] + fn script_values_override_provider_credentials() { + let provider = provider_with_settings(json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "provider-key", + "ANTHROPIC_BASE_URL": "https://provider.example.com/" + } + })); + + let (api_key, base_url) = resolve_script_credentials( + &AppType::Claude, + &provider, + Some(" script-key "), + Some(" https://script.example.com/ "), + ); + assert_eq!(api_key, "script-key"); + assert_eq!(base_url, "https://script.example.com"); + } + + #[test] + fn empty_script_values_fall_back_to_provider_credentials() { + let provider = provider_with_settings(json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "provider-key", + "ANTHROPIC_BASE_URL": "https://provider.example.com/" + } + })); + + let (api_key, base_url) = + resolve_script_credentials(&AppType::Claude, &provider, Some(""), None); + assert_eq!(api_key, "provider-key"); + assert_eq!(base_url, "https://provider.example.com"); + } + + #[test] + fn codex_fallback_reads_auth_and_config_toml() { + let provider = provider_with_settings(json!({ + "auth": { + "OPENAI_API_KEY": "openai-key" + }, + "config": r#"model_provider = "azure" + +[model_providers.azure] +base_url = "https://azure.example.com/v1/" + +[model_providers.other] +base_url = "https://other.example.com/v1" +"# + })); + + let (api_key, base_url) = + resolve_script_credentials(&AppType::Codex, &provider, None, None); + assert_eq!(api_key, "openai-key"); + assert_eq!(base_url, "https://azure.example.com/v1"); + } +} diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index c5be05590..e05e1c733 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -331,6 +331,14 @@ const UsageScriptModal: React.FC = ({ const [testing, setTesting] = useState(false); + // {{apiKey}}/{{baseUrl}} 实际注入值,镜像后端 resolve_script_credentials 的 + // 优先级:脚本配置中的显式非空值优先(旧配置可能携带),否则回退供应商凭据。 + const effectiveScriptCredentials = { + apiKey: script.apiKey?.trim() || providerCredentials.apiKey, + baseUrl: + script.baseUrl?.trim().replace(/\/+$/, "") || providerCredentials.baseUrl, + }; + // 🔧 失焦时的验证(严格)- 仅确保有效整数 const validateTimeout = (value: string): number => { const num = Number(value); @@ -678,13 +686,12 @@ const UsageScriptModal: React.FC = ({ const preset = PRESET_TEMPLATES[presetName]; if (preset !== undefined) { if (presetName === TEMPLATE_TYPES.CUSTOM) { - // 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换 - // 这样可以避免同源检查导致的问题 - // 如果用户想使用变量,需要手动在配置中设置 baseUrl/apiKey + // 自定义模板没有凭证输入框:清空显式覆盖值后,测试与真实查询 + // 都会在后端回退到供应商配置(Provider::resolve_usage_credentials), + // 与下方“支持的变量”区域展示的 {{apiKey}}/{{baseUrl}} 取值一致。 setScript({ ...script, code: preset, - // 清除凭证,用户可选择手动输入或保持空 apiKey: undefined, baseUrl: undefined, accessToken: undefined, @@ -886,9 +893,9 @@ const UsageScriptModal: React.FC = ({ {"{{baseUrl}}"} = - {providerCredentials.baseUrl ? ( + {effectiveScriptCredentials.baseUrl ? ( - {providerCredentials.baseUrl} + {effectiveScriptCredentials.baseUrl} ) : ( @@ -903,11 +910,11 @@ const UsageScriptModal: React.FC = ({ {"{{apiKey}}"} = - {providerCredentials.apiKey ? ( + {effectiveScriptCredentials.apiKey ? ( <> {showApiKey ? ( - {providerCredentials.apiKey} + {effectiveScriptCredentials.apiKey} ) : ( From f97347fe6e102836017dd80446a50590195cbd22 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 9 Jun 2026 08:23:03 +0800 Subject: [PATCH 09/66] docs(release): restore contributor mentions in release notes --- docs/release-notes/v3.16.1-en.md | 20 ++++++------ docs/release-notes/v3.16.1-ja.md | 20 ++++++------ docs/release-notes/v3.16.1-zh.md | 20 ++++++------ docs/release-notes/v3.16.2-en.md | 52 ++++++++++++++++---------------- docs/release-notes/v3.16.2-ja.md | 52 ++++++++++++++++---------------- docs/release-notes/v3.16.2-zh.md | 52 ++++++++++++++++---------------- 6 files changed, 108 insertions(+), 108 deletions(-) diff --git a/docs/release-notes/v3.16.1-en.md b/docs/release-notes/v3.16.1-en.md index ab81e1cb0..ca796c3cb 100644 --- a/docs/release-notes/v3.16.1-en.md +++ b/docs/release-notes/v3.16.1-en.md @@ -105,7 +105,7 @@ Fixed multiple preserve-mode takeover paths that could clear or overwrite offici Fixed cases where `modelCatalog` could be cleared during live backfill, active-provider editing, provider switching, and takeover shutdown restore. Snapshot backups preserve existing `model_catalog_json` pointers; backups rebuilt from providers regenerate catalog projections from the database source of truth; editing the active provider now prefers the database model catalog instead of trusting a live reverse-parse result that may have lost its projection. -Provider switching also now always refreshes the generated Codex model catalog JSON ([#3360](https://github.com/farion1231/cc-switch/pull/3360), thanks [@Postroggy](https://github.com/Postroggy)). +Provider switching also now always refreshes the generated Codex model catalog JSON ([#3360](https://github.com/farion1231/cc-switch/pull/3360), thanks @Postroggy). ### Codex Chat Tools, Plugins, and Custom Tools Restored @@ -117,19 +117,19 @@ When Codex forwarding fails, CC Switch now returns JSON errors that include prov ### Codex Native Balance / Coding Plan Credential Lookup -Fixed native balance and Coding Plan queries using credentials from the wrong app. Each app now resolves its own provider credentials instead of carrying authentication assumptions from another app surface into the query flow ([#3355](https://github.com/farion1231/cc-switch/pull/3355), thanks [@SiskonEmilia](https://github.com/SiskonEmilia)). +Fixed native balance and Coding Plan queries using credentials from the wrong app. Each app now resolves its own provider credentials instead of carrying authentication assumptions from another app surface into the query flow ([#3355](https://github.com/farion1231/cc-switch/pull/3355), thanks @SiskonEmilia). ### Codex CLI Discovery and Model Catalog Template Fallback -Fixed a too-narrow Codex CLI discovery path for third-party Codex model catalog projection. The backend now searches common Codex CLI install locations across platforms, and falls back to a built-in GPT-5.5 model catalog template if no template can be found ([#3382](https://github.com/farion1231/cc-switch/pull/3382), thanks [@chofuhoyu](https://github.com/chofuhoyu)). +Fixed a too-narrow Codex CLI discovery path for third-party Codex model catalog projection. The backend now searches common Codex CLI install locations across platforms, and falls back to a built-in GPT-5.5 model catalog template if no template can be found ([#3382](https://github.com/farion1231/cc-switch/pull/3382), thanks @chofuhoyu). ### Claude Desktop Official Provider Add Failure -Fixed an error when adding the Claude Desktop Official provider ([#3405](https://github.com/farion1231/cc-switch/pull/3405), thanks [@Eunknight](https://github.com/Eunknight)). +Fixed an error when adding the Claude Desktop Official provider ([#3405](https://github.com/farion1231/cc-switch/pull/3405), thanks @Eunknight). ### Kimi / Moonshot Tool-Thinking History Normalization -Added Kimi / Moonshot to the Anthropic-compatible tool-thinking history normalizer. Later turns can now correctly replay reasoning and tool-call context, avoiding failures caused by history messages that do not match upstream requirements ([#3377](https://github.com/farion1231/cc-switch/pull/3377), thanks [@Neon-Wang](https://github.com/Neon-Wang)). +Added Kimi / Moonshot to the Anthropic-compatible tool-thinking history normalizer. Later turns can now correctly replay reasoning and tool-call context, avoiding failures caused by history messages that do not match upstream requirements ([#3377](https://github.com/farion1231/cc-switch/pull/3377), thanks @Neon-Wang). ### Windows Tool Version Detection @@ -171,11 +171,11 @@ By enabling these features, users accept the related risks. CC Switch is not res Thanks to the following contributors for fixes in v3.16.1: -- [#3360](https://github.com/farion1231/cc-switch/pull/3360): always update Codex model catalog JSON when switching providers, thanks [@Postroggy](https://github.com/Postroggy). -- [#3355](https://github.com/farion1231/cc-switch/pull/3355): resolve native balance / Coding Plan credentials per app, thanks [@SiskonEmilia](https://github.com/SiskonEmilia). -- [#3405](https://github.com/farion1231/cc-switch/pull/3405): fix Claude Desktop Official provider add failure, thanks [@Eunknight](https://github.com/Eunknight). -- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI multi-platform discovery and GPT-5.5 model template fallback, thanks [@chofuhoyu](https://github.com/chofuhoyu). -- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot tool-thinking history normalization, thanks [@Neon-Wang](https://github.com/Neon-Wang). +- [#3360](https://github.com/farion1231/cc-switch/pull/3360): always update Codex model catalog JSON when switching providers, thanks @Postroggy. +- [#3355](https://github.com/farion1231/cc-switch/pull/3355): resolve native balance / Coding Plan credentials per app, thanks @SiskonEmilia. +- [#3405](https://github.com/farion1231/cc-switch/pull/3405): fix Claude Desktop Official provider add failure, thanks @Eunknight. +- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI multi-platform discovery and GPT-5.5 model template fallback, thanks @chofuhoyu. +- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot tool-thinking history normalization, thanks @Neon-Wang. Thanks also to everyone who reported Codex OAuth, model catalog, local routing takeover, and Chat Completions tool-call issues after v3.16.0. Many of these fixes came directly from real-world reproduction details. diff --git a/docs/release-notes/v3.16.1-ja.md b/docs/release-notes/v3.16.1-ja.md index 748af4afd..8e461e49c 100644 --- a/docs/release-notes/v3.16.1-ja.md +++ b/docs/release-notes/v3.16.1-ja.md @@ -105,7 +105,7 @@ Codex がローカルルーティングのテイクオーバー状態にある live バックフィル、現在のプロバイダー編集、プロバイダー切り替え、テイクオーバー解除時の復元などで `modelCatalog` が空になる問題を修正しました。スナップショットバックアップは既存の `model_catalog_json` ポインターを保持します。プロバイダーから再構築されるバックアップは、データベースの信頼できる情報源からカタログ投影を再生成します。現在のプロバイダー編集時は、投影を失っている可能性のある live の逆解析結果ではなく、データベース内のモデルカタログを優先します。 -また、プロバイダー切り替え時には生成済みの Codex モデルカタログ JSON を常に更新するようになりました([#3360](https://github.com/farion1231/cc-switch/pull/3360)、[@Postroggy](https://github.com/Postroggy) に感謝)。 +また、プロバイダー切り替え時には生成済みの Codex モデルカタログ JSON を常に更新するようになりました([#3360](https://github.com/farion1231/cc-switch/pull/3360)、@Postroggy に感謝)。 ### Codex Chat ツール、プラグイン、カスタムツールの復元 @@ -117,19 +117,19 @@ Codex の転送に失敗したとき、provider、model、endpoint、上流 HTTP ### Codex ネイティブ残高 / Coding Plan の認証情報検索 -ネイティブ残高と Coding Plan の照会時に、別アプリの認証情報を誤って使う問題を修正しました。各 app は自分自身のプロバイダー認証情報を解析し、別のアプリ面の認証前提を照会フローへ持ち込まなくなりました([#3355](https://github.com/farion1231/cc-switch/pull/3355)、[@SiskonEmilia](https://github.com/SiskonEmilia) に感謝)。 +ネイティブ残高と Coding Plan の照会時に、別アプリの認証情報を誤って使う問題を修正しました。各 app は自分自身のプロバイダー認証情報を解析し、別のアプリ面の認証前提を照会フローへ持ち込まなくなりました([#3355](https://github.com/farion1231/cc-switch/pull/3355)、@SiskonEmilia に感謝)。 ### Codex CLI 探索とモデルカタログテンプレートのフォールバック -サードパーティ Codex モデルカタログ投影における Codex CLI の探索パスが狭すぎる問題を修正しました。バックエンドは複数プラットフォームの一般的な Codex CLI インストール場所を探し、それでもテンプレートが見つからない場合は内蔵の GPT-5.5 モデルカタログテンプレートへフォールバックします([#3382](https://github.com/farion1231/cc-switch/pull/3382)、[@chofuhoyu](https://github.com/chofuhoyu) に感謝)。 +サードパーティ Codex モデルカタログ投影における Codex CLI の探索パスが狭すぎる問題を修正しました。バックエンドは複数プラットフォームの一般的な Codex CLI インストール場所を探し、それでもテンプレートが見つからない場合は内蔵の GPT-5.5 モデルカタログテンプレートへフォールバックします([#3382](https://github.com/farion1231/cc-switch/pull/3382)、@chofuhoyu に感謝)。 ### Claude Desktop Official プロバイダー追加失敗 -Claude Desktop Official プロバイダー追加時のエラーを修正しました([#3405](https://github.com/farion1231/cc-switch/pull/3405)、[@Eunknight](https://github.com/Eunknight) に感謝)。 +Claude Desktop Official プロバイダー追加時のエラーを修正しました([#3405](https://github.com/farion1231/cc-switch/pull/3405)、@Eunknight に感謝)。 ### Kimi / Moonshot ツール思考履歴の正規化 -Kimi / Moonshot を Anthropic 互換ツール思考履歴 normalizer に追加しました。後続ターンで reasoning と tool-call コンテキストを正しく再生できるようになり、履歴メッセージの形が上流要件に合わず失敗する問題を避けます([#3377](https://github.com/farion1231/cc-switch/pull/3377)、[@Neon-Wang](https://github.com/Neon-Wang) に感謝)。 +Kimi / Moonshot を Anthropic 互換ツール思考履歴 normalizer に追加しました。後続ターンで reasoning と tool-call コンテキストを正しく再生できるようになり、履歴メッセージの形が上流要件に合わず失敗する問題を避けます([#3377](https://github.com/farion1231/cc-switch/pull/3377)、@Neon-Wang に感謝)。 ### Windows ツールバージョン検出 @@ -171,11 +171,11 @@ Codex は起動時に `model_catalog_json` を読み込みます。v3.16.1 で v3.16.1 で修正を届けてくださった以下のコントリビューターに感謝します: -- [#3360](https://github.com/farion1231/cc-switch/pull/3360): Codex プロバイダー切り替え時にモデルカタログ JSON を常に更新、[@Postroggy](https://github.com/Postroggy) に感謝。 -- [#3355](https://github.com/farion1231/cc-switch/pull/3355): ネイティブ残高 / Coding Plan 照会の認証情報を app ごとに解析、[@SiskonEmilia](https://github.com/SiskonEmilia) に感謝。 -- [#3405](https://github.com/farion1231/cc-switch/pull/3405): Claude Desktop Official プロバイダー追加エラーを修正、[@Eunknight](https://github.com/Eunknight) に感謝。 -- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI の複数プラットフォーム探索と GPT-5.5 モデルテンプレートフォールバック、[@chofuhoyu](https://github.com/chofuhoyu) に感謝。 -- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot ツール思考履歴の正規化、[@Neon-Wang](https://github.com/Neon-Wang) に感謝。 +- [#3360](https://github.com/farion1231/cc-switch/pull/3360): Codex プロバイダー切り替え時にモデルカタログ JSON を常に更新、@Postroggy に感謝。 +- [#3355](https://github.com/farion1231/cc-switch/pull/3355): ネイティブ残高 / Coding Plan 照会の認証情報を app ごとに解析、@SiskonEmilia に感謝。 +- [#3405](https://github.com/farion1231/cc-switch/pull/3405): Claude Desktop Official プロバイダー追加エラーを修正、@Eunknight に感謝。 +- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI の複数プラットフォーム探索と GPT-5.5 モデルテンプレートフォールバック、@chofuhoyu に感謝。 +- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot ツール思考履歴の正規化、@Neon-Wang に感謝。 v3.16.0 リリース後に Codex OAuth、モデルカタログ、ローカルルーティングのテイクオーバー、Chat Completions ツール呼び出しの問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。 diff --git a/docs/release-notes/v3.16.1-zh.md b/docs/release-notes/v3.16.1-zh.md index effdc1076..05752d355 100644 --- a/docs/release-notes/v3.16.1-zh.md +++ b/docs/release-notes/v3.16.1-zh.md @@ -105,7 +105,7 @@ Codex / Claude / Gemini 的供应商切换与本地路由接管开关现在共 修复 `modelCatalog` 在 live 回填、当前供应商编辑弹窗、供应商切换、关闭接管恢复等场景被清空的问题。快照备份会保留已有 `model_catalog_json` 指针;由供应商重建的备份会从数据库真相来源重新生成目录投影;编辑当前供应商时会优先使用数据库里的模型目录,而不是信任可能已经丢失投影的 live 反解结果。 -同时,供应商切换现在会始终刷新生成的 Codex 模型目录 JSON([#3360](https://github.com/farion1231/cc-switch/pull/3360),感谢 [@Postroggy](https://github.com/Postroggy))。 +同时,供应商切换现在会始终刷新生成的 Codex 模型目录 JSON([#3360](https://github.com/farion1231/cc-switch/pull/3360),感谢 @Postroggy)。 ### Codex Chat 工具、插件和自定义工具恢复 @@ -117,19 +117,19 @@ Codex 转发失败时,现在返回包含 provider、model、endpoint、上游 ### Codex 原生余额 / Coding Plan 查询凭据 -修复原生余额与 Coding Plan 查询时跨 app 错用凭据的问题。现在每个 app 会解析自己的供应商凭据,不再把其他应用面的认证假设带进查询流程([#3355](https://github.com/farion1231/cc-switch/pull/3355),感谢 [@SiskonEmilia](https://github.com/SiskonEmilia))。 +修复原生余额与 Coding Plan 查询时跨 app 错用凭据的问题。现在每个 app 会解析自己的供应商凭据,不再把其他应用面的认证假设带进查询流程([#3355](https://github.com/farion1231/cc-switch/pull/3355),感谢 @SiskonEmilia)。 ### Codex CLI 发现与模型目录模板兜底 -修复第三方 Codex 模型目录投影对 Codex CLI 发现路径过窄的问题。现在后端会在多平台常见安装位置寻找 Codex CLI,并在仍找不到模板时使用内置 GPT-5.5 模型目录模板兜底([#3382](https://github.com/farion1231/cc-switch/pull/3382),感谢 [@chofuhoyu](https://github.com/chofuhoyu))。 +修复第三方 Codex 模型目录投影对 Codex CLI 发现路径过窄的问题。现在后端会在多平台常见安装位置寻找 Codex CLI,并在仍找不到模板时使用内置 GPT-5.5 模型目录模板兜底([#3382](https://github.com/farion1231/cc-switch/pull/3382),感谢 @chofuhoyu)。 ### Claude Desktop 官方供应商添加失败 -修复添加 Claude Desktop 官方供应商时报错的问题([#3405](https://github.com/farion1231/cc-switch/pull/3405),感谢 [@Eunknight](https://github.com/Eunknight))。 +修复添加 Claude Desktop 官方供应商时报错的问题([#3405](https://github.com/farion1231/cc-switch/pull/3405),感谢 @Eunknight)。 ### Kimi / Moonshot 工具思考历史规范化 -把 Kimi / Moonshot 加入 Anthropic 兼容工具思考历史 normalizer。后续轮次现在能正确重放 reasoning 与 tool-call 上下文,避免因为历史消息形态不符合上游要求而失败([#3377](https://github.com/farion1231/cc-switch/pull/3377),感谢 [@Neon-Wang](https://github.com/Neon-Wang))。 +把 Kimi / Moonshot 加入 Anthropic 兼容工具思考历史 normalizer。后续轮次现在能正确重放 reasoning 与 tool-call 上下文,避免因为历史消息形态不符合上游要求而失败([#3377](https://github.com/farion1231/cc-switch/pull/3377),感谢 @Neon-Wang)。 ### Windows 工具版本探测 @@ -171,11 +171,11 @@ Codex 在启动时读取 `model_catalog_json`。因此即使 v3.16.1 已修复 感谢以下贡献者在 v3.16.1 中提交修复: -- [#3360](https://github.com/farion1231/cc-switch/pull/3360):Codex 供应商切换时始终更新模型目录 JSON,感谢 [@Postroggy](https://github.com/Postroggy)。 -- [#3355](https://github.com/farion1231/cc-switch/pull/3355):原生余额 / Coding Plan 查询按 app 解析凭据,感谢 [@SiskonEmilia](https://github.com/SiskonEmilia)。 -- [#3405](https://github.com/farion1231/cc-switch/pull/3405):修复 Claude Desktop 官方供应商添加报错,感谢 [@Eunknight](https://github.com/Eunknight)。 -- [#3382](https://github.com/farion1231/cc-switch/pull/3382):Codex CLI 多平台发现与 GPT-5.5 模型模板兜底,感谢 [@chofuhoyu](https://github.com/chofuhoyu)。 -- [#3377](https://github.com/farion1231/cc-switch/pull/3377):Kimi / Moonshot 工具思考历史规范化,感谢 [@Neon-Wang](https://github.com/Neon-Wang)。 +- [#3360](https://github.com/farion1231/cc-switch/pull/3360):Codex 供应商切换时始终更新模型目录 JSON,感谢 @Postroggy。 +- [#3355](https://github.com/farion1231/cc-switch/pull/3355):原生余额 / Coding Plan 查询按 app 解析凭据,感谢 @SiskonEmilia。 +- [#3405](https://github.com/farion1231/cc-switch/pull/3405):修复 Claude Desktop 官方供应商添加报错,感谢 @Eunknight。 +- [#3382](https://github.com/farion1231/cc-switch/pull/3382):Codex CLI 多平台发现与 GPT-5.5 模型模板兜底,感谢 @chofuhoyu。 +- [#3377](https://github.com/farion1231/cc-switch/pull/3377):Kimi / Moonshot 工具思考历史规范化,感谢 @Neon-Wang。 也感谢所有在 v3.16.0 发布后反馈 Codex OAuth、模型目录、本地路由接管和 Chat Completions 工具调用问题的用户。很多补丁都来自这些真实使用场景里的复现线索。 diff --git a/docs/release-notes/v3.16.2-en.md b/docs/release-notes/v3.16.2-en.md index 0fc440e96..df5e3105d 100644 --- a/docs/release-notes/v3.16.2-en.md +++ b/docs/release-notes/v3.16.2-en.md @@ -261,32 +261,32 @@ By enabling these features, users accept the related risks. CC Switch is not res Thanks to the following contributors for the features and fixes in v3.16.2: -- [#1351](https://github.com/farion1231/cc-switch/pull/1351): add S3-compatible cloud storage sync, thanks [@keithyt06](https://github.com/keithyt06). -- [#3215](https://github.com/farion1231/cc-switch/pull/3215): add OpenCode session usage sync, thanks [@nothingness0db](https://github.com/nothingness0db). -- [#2709](https://github.com/farion1231/cc-switch/pull/2709): add the ZenMux Token Plan provider, thanks [@Eter365](https://github.com/Eter365). -- [#3643](https://github.com/farion1231/cc-switch/pull/3643): add the CherryIN preset provider, thanks [@zhibisora](https://github.com/zhibisora). -- [#3818](https://github.com/farion1231/cc-switch/pull/3818): add the Codex CLI reachability `GET /v1/models` endpoint, thanks [@CSberlin](https://github.com/CSberlin). -- [#3426](https://github.com/farion1231/cc-switch/pull/3426): Usage Dashboard hero redesign, thanks [@allenxu09](https://github.com/allenxu09). -- [#3640](https://github.com/farion1231/cc-switch/pull/3640): drop `tool_choice` when tools is empty, thanks [@Postroggy](https://github.com/Postroggy). -- [#3644](https://github.com/farion1231/cc-switch/pull/3644): preserve Codex custom tool metadata in chat routing, thanks [@LanternCX](https://github.com/LanternCX). -- [#3514](https://github.com/farion1231/cc-switch/pull/3514): always include `reasoning_tokens` in Chat→Responses, thanks [@yeeyzy](https://github.com/yeeyzy). -- [#3689](https://github.com/farion1231/cc-switch/pull/3689): skip backup / restore when live is already a proxy placeholder, thanks [@YongmaoLuo](https://github.com/YongmaoLuo). -- [#3016](https://github.com/farion1231/cc-switch/pull/3016): normalize the localhost listen address, thanks [@Alexlangl](https://github.com/Alexlangl). -- [#3775](https://github.com/farion1231/cc-switch/pull/3775): normalize Anthropic `system` messages, thanks [@Dearli666](https://github.com/Dearli666). -- [#3656](https://github.com/farion1231/cc-switch/pull/3656): improve error message display in the proxy panel, thanks [@lzcndm](https://github.com/lzcndm). -- [#2647](https://github.com/farion1231/cc-switch/pull/2647): raise the infinite-whitespace threshold 20 → 500, thanks [@NiuBlibing](https://github.com/NiuBlibing). -- [#3702](https://github.com/farion1231/cc-switch/pull/3702): route the Zhipu quota query to the configured base URL, thanks [@YongmaoLuo](https://github.com/YongmaoLuo). -- [#3518](https://github.com/farion1231/cc-switch/pull/3518): adapt to the MiniMax new balance API and default pricing, thanks [@LaoYueHanNi](https://github.com/LaoYueHanNi). -- [#3524](https://github.com/farion1231/cc-switch/pull/3524): fix the Zhipu Coding Plan presets and model probing for versioned endpoints, thanks [@makoMakoGo](https://github.com/makoMakoGo). -- [#3614](https://github.com/farion1231/cc-switch/pull/3614): use a relative filename for the model catalog, thanks [@steponeerror](https://github.com/steponeerror). -- [#3797](https://github.com/farion1231/cc-switch/pull/3797): fix the Windows tray icon residue after exit, thanks [@iAJue](https://github.com/iAJue). -- [#3457](https://github.com/farion1231/cc-switch/pull/3457): fix the Windows taskbar icon, thanks [@ZhangNanNan1018](https://github.com/ZhangNanNan1018). -- [#3430](https://github.com/farion1231/cc-switch/pull/3430): normalize Windows path separators to match subdirectory skill updates, thanks [@Ninthless](https://github.com/Ninthless). -- [#3626](https://github.com/farion1231/cc-switch/pull/3626): disable macOS input auto-capitalization, thanks [@ZHLHZHU](https://github.com/ZHLHZHU). -- [#3593](https://github.com/farion1231/cc-switch/pull/3593): fix Codex VS Code session previews, thanks [@xwil1](https://github.com/xwil1). -- [#3228](https://github.com/farion1231/cc-switch/pull/3228): align the VS Code wording in the Chinese UI, thanks [@Games55k](https://github.com/Games55k). -- [#3411](https://github.com/farion1231/cc-switch/pull/3411): refresh the user manual to reflect current app support, thanks [@makoMakoGo](https://github.com/makoMakoGo). -- [#3772](https://github.com/farion1231/cc-switch/pull/3772): fix README release-note links and sponsor markup, thanks [@null-easy](https://github.com/null-easy). +- [#1351](https://github.com/farion1231/cc-switch/pull/1351): add S3-compatible cloud storage sync, thanks @keithyt06. +- [#3215](https://github.com/farion1231/cc-switch/pull/3215): add OpenCode session usage sync, thanks @nothingness0db. +- [#2709](https://github.com/farion1231/cc-switch/pull/2709): add the ZenMux Token Plan provider, thanks @Eter365. +- [#3643](https://github.com/farion1231/cc-switch/pull/3643): add the CherryIN preset provider, thanks @zhibisora. +- [#3818](https://github.com/farion1231/cc-switch/pull/3818): add the Codex CLI reachability `GET /v1/models` endpoint, thanks @CSberlin. +- [#3426](https://github.com/farion1231/cc-switch/pull/3426): Usage Dashboard hero redesign, thanks @allenxu09. +- [#3640](https://github.com/farion1231/cc-switch/pull/3640): drop `tool_choice` when tools is empty, thanks @Postroggy. +- [#3644](https://github.com/farion1231/cc-switch/pull/3644): preserve Codex custom tool metadata in chat routing, thanks @LanternCX. +- [#3514](https://github.com/farion1231/cc-switch/pull/3514): always include `reasoning_tokens` in Chat→Responses, thanks @yeeyzy. +- [#3689](https://github.com/farion1231/cc-switch/pull/3689): skip backup / restore when live is already a proxy placeholder, thanks @YongmaoLuo. +- [#3016](https://github.com/farion1231/cc-switch/pull/3016): normalize the localhost listen address, thanks @Alexlangl. +- [#3775](https://github.com/farion1231/cc-switch/pull/3775): normalize Anthropic `system` messages, thanks @Dearli666. +- [#3656](https://github.com/farion1231/cc-switch/pull/3656): improve error message display in the proxy panel, thanks @lzcndm. +- [#2647](https://github.com/farion1231/cc-switch/pull/2647): raise the infinite-whitespace threshold 20 → 500, thanks @NiuBlibing. +- [#3702](https://github.com/farion1231/cc-switch/pull/3702): route the Zhipu quota query to the configured base URL, thanks @YongmaoLuo. +- [#3518](https://github.com/farion1231/cc-switch/pull/3518): adapt to the MiniMax new balance API and default pricing, thanks @LaoYueHanNi. +- [#3524](https://github.com/farion1231/cc-switch/pull/3524): fix the Zhipu Coding Plan presets and model probing for versioned endpoints, thanks @makoMakoGo. +- [#3614](https://github.com/farion1231/cc-switch/pull/3614): use a relative filename for the model catalog, thanks @steponeerror. +- [#3797](https://github.com/farion1231/cc-switch/pull/3797): fix the Windows tray icon residue after exit, thanks @iAJue. +- [#3457](https://github.com/farion1231/cc-switch/pull/3457): fix the Windows taskbar icon, thanks @ZhangNanNan1018. +- [#3430](https://github.com/farion1231/cc-switch/pull/3430): normalize Windows path separators to match subdirectory skill updates, thanks @Ninthless. +- [#3626](https://github.com/farion1231/cc-switch/pull/3626): disable macOS input auto-capitalization, thanks @ZHLHZHU. +- [#3593](https://github.com/farion1231/cc-switch/pull/3593): fix Codex VS Code session previews, thanks @xwil1. +- [#3228](https://github.com/farion1231/cc-switch/pull/3228): align the VS Code wording in the Chinese UI, thanks @Games55k. +- [#3411](https://github.com/farion1231/cc-switch/pull/3411): refresh the user manual to reflect current app support, thanks @makoMakoGo. +- [#3772](https://github.com/farion1231/cc-switch/pull/3772): fix README release-note links and sponsor markup, thanks @null-easy. Thanks also to everyone who reported Codex Chat routing, local proxy takeover, usage statistics, and platform compatibility issues after v3.16.1. Many of these fixes came directly from real-world reproduction details. diff --git a/docs/release-notes/v3.16.2-ja.md b/docs/release-notes/v3.16.2-ja.md index 01f356d2f..095c26f98 100644 --- a/docs/release-notes/v3.16.2-ja.md +++ b/docs/release-notes/v3.16.2-ja.md @@ -261,32 +261,32 @@ Codex は起動時に `model_catalog_json` を読み込みます。本リリー v3.16.2 で機能と修正を届けてくださった以下のコントリビューターに感謝します: -- [#1351](https://github.com/farion1231/cc-switch/pull/1351): S3 互換クラウドストレージ同期を追加、[@keithyt06](https://github.com/keithyt06) に感謝。 -- [#3215](https://github.com/farion1231/cc-switch/pull/3215): OpenCode セッション用量同期を追加、[@nothingness0db](https://github.com/nothingness0db) に感謝。 -- [#2709](https://github.com/farion1231/cc-switch/pull/2709): ZenMux Token Plan プロバイダーを追加、[@Eter365](https://github.com/Eter365) に感謝。 -- [#3643](https://github.com/farion1231/cc-switch/pull/3643): CherryIN プリセットプロバイダーを追加、[@zhibisora](https://github.com/zhibisora) に感謝。 -- [#3818](https://github.com/farion1231/cc-switch/pull/3818): Codex CLI 到達性確認用の `GET /v1/models` エンドポイントを追加、[@CSberlin](https://github.com/CSberlin) に感謝。 -- [#3426](https://github.com/farion1231/cc-switch/pull/3426): 用量ダッシュボードのヒーロー再設計、[@allenxu09](https://github.com/allenxu09) に感謝。 -- [#3640](https://github.com/farion1231/cc-switch/pull/3640): tools が空のとき `tool_choice` を破棄、[@Postroggy](https://github.com/Postroggy) に感謝。 -- [#3644](https://github.com/farion1231/cc-switch/pull/3644): Chat ルーティングで Codex カスタムツールメタデータを保持、[@LanternCX](https://github.com/LanternCX) に感謝。 -- [#3514](https://github.com/farion1231/cc-switch/pull/3514): Chat→Responses で常に `reasoning_tokens` を含める、[@yeeyzy](https://github.com/yeeyzy) に感謝。 -- [#3689](https://github.com/farion1231/cc-switch/pull/3689): live がすでにプロキシプレースホルダーのときバックアップ / 復元をスキップ、[@YongmaoLuo](https://github.com/YongmaoLuo) に感謝。 -- [#3016](https://github.com/farion1231/cc-switch/pull/3016): localhost リッスンアドレスを正規化、[@Alexlangl](https://github.com/Alexlangl) に感謝。 -- [#3775](https://github.com/farion1231/cc-switch/pull/3775): Anthropic `system` メッセージを正規化、[@Dearli666](https://github.com/Dearli666) に感謝。 -- [#3656](https://github.com/farion1231/cc-switch/pull/3656): プロキシパネルのエラー表示を改善、[@lzcndm](https://github.com/lzcndm) に感謝。 -- [#2647](https://github.com/farion1231/cc-switch/pull/2647): 無限空白検出のしきい値を 20 → 500 へ引き上げ、[@NiuBlibing](https://github.com/NiuBlibing) に感謝。 -- [#3702](https://github.com/farion1231/cc-switch/pull/3702): 智譜の残量照会を構成済み base URL へルーティング、[@YongmaoLuo](https://github.com/YongmaoLuo) に感謝。 -- [#3518](https://github.com/farion1231/cc-switch/pull/3518): MiniMax の新残量 API と既定価格に対応、[@LaoYueHanNi](https://github.com/LaoYueHanNi) に感謝。 -- [#3524](https://github.com/farion1231/cc-switch/pull/3524): 智譜 Coding Plan プリセットとバージョン付き endpoint のモデル探索を修正、[@makoMakoGo](https://github.com/makoMakoGo) に感謝。 -- [#3614](https://github.com/farion1231/cc-switch/pull/3614): モデルカタログを相対ファイル名に変更、[@steponeerror](https://github.com/steponeerror) に感謝。 -- [#3797](https://github.com/farion1231/cc-switch/pull/3797): Windows 終了後のトレイアイコン残留を修正、[@iAJue](https://github.com/iAJue) に感謝。 -- [#3457](https://github.com/farion1231/cc-switch/pull/3457): Windows タスクバーアイコンを修正、[@ZhangNanNan1018](https://github.com/ZhangNanNan1018) に感謝。 -- [#3430](https://github.com/farion1231/cc-switch/pull/3430): Windows のパス区切りを正規化してサブディレクトリスキルの更新に対応、[@Ninthless](https://github.com/Ninthless) に感謝。 -- [#3626](https://github.com/farion1231/cc-switch/pull/3626): macOS の入力自動大文字化を無効化、[@ZHLHZHU](https://github.com/ZHLHZHU) に感謝。 -- [#3593](https://github.com/farion1231/cc-switch/pull/3593): Codex VS Code セッションプレビューを修正、[@xwil1](https://github.com/xwil1) に感謝。 -- [#3228](https://github.com/farion1231/cc-switch/pull/3228): 中国語 UI の VS Code 表記を揃える、[@Games55k](https://github.com/Games55k) に感謝。 -- [#3411](https://github.com/farion1231/cc-switch/pull/3411): 現行のアプリ対応を反映してユーザーマニュアルを刷新、[@makoMakoGo](https://github.com/makoMakoGo) に感謝。 -- [#3772](https://github.com/farion1231/cc-switch/pull/3772): README のリリースノートリンクとスポンサー表記を修正、[@null-easy](https://github.com/null-easy) に感謝。 +- [#1351](https://github.com/farion1231/cc-switch/pull/1351): S3 互換クラウドストレージ同期を追加、@keithyt06 に感謝。 +- [#3215](https://github.com/farion1231/cc-switch/pull/3215): OpenCode セッション用量同期を追加、@nothingness0db に感謝。 +- [#2709](https://github.com/farion1231/cc-switch/pull/2709): ZenMux Token Plan プロバイダーを追加、@Eter365 に感謝。 +- [#3643](https://github.com/farion1231/cc-switch/pull/3643): CherryIN プリセットプロバイダーを追加、@zhibisora に感謝。 +- [#3818](https://github.com/farion1231/cc-switch/pull/3818): Codex CLI 到達性確認用の `GET /v1/models` エンドポイントを追加、@CSberlin に感謝。 +- [#3426](https://github.com/farion1231/cc-switch/pull/3426): 用量ダッシュボードのヒーロー再設計、@allenxu09 に感謝。 +- [#3640](https://github.com/farion1231/cc-switch/pull/3640): tools が空のとき `tool_choice` を破棄、@Postroggy に感謝。 +- [#3644](https://github.com/farion1231/cc-switch/pull/3644): Chat ルーティングで Codex カスタムツールメタデータを保持、@LanternCX に感謝。 +- [#3514](https://github.com/farion1231/cc-switch/pull/3514): Chat→Responses で常に `reasoning_tokens` を含める、@yeeyzy に感謝。 +- [#3689](https://github.com/farion1231/cc-switch/pull/3689): live がすでにプロキシプレースホルダーのときバックアップ / 復元をスキップ、@YongmaoLuo に感謝。 +- [#3016](https://github.com/farion1231/cc-switch/pull/3016): localhost リッスンアドレスを正規化、@Alexlangl に感謝。 +- [#3775](https://github.com/farion1231/cc-switch/pull/3775): Anthropic `system` メッセージを正規化、@Dearli666 に感謝。 +- [#3656](https://github.com/farion1231/cc-switch/pull/3656): プロキシパネルのエラー表示を改善、@lzcndm に感謝。 +- [#2647](https://github.com/farion1231/cc-switch/pull/2647): 無限空白検出のしきい値を 20 → 500 へ引き上げ、@NiuBlibing に感謝。 +- [#3702](https://github.com/farion1231/cc-switch/pull/3702): 智譜の残量照会を構成済み base URL へルーティング、@YongmaoLuo に感謝。 +- [#3518](https://github.com/farion1231/cc-switch/pull/3518): MiniMax の新残量 API と既定価格に対応、@LaoYueHanNi に感謝。 +- [#3524](https://github.com/farion1231/cc-switch/pull/3524): 智譜 Coding Plan プリセットとバージョン付き endpoint のモデル探索を修正、@makoMakoGo に感謝。 +- [#3614](https://github.com/farion1231/cc-switch/pull/3614): モデルカタログを相対ファイル名に変更、@steponeerror に感謝。 +- [#3797](https://github.com/farion1231/cc-switch/pull/3797): Windows 終了後のトレイアイコン残留を修正、@iAJue に感謝。 +- [#3457](https://github.com/farion1231/cc-switch/pull/3457): Windows タスクバーアイコンを修正、@ZhangNanNan1018 に感謝。 +- [#3430](https://github.com/farion1231/cc-switch/pull/3430): Windows のパス区切りを正規化してサブディレクトリスキルの更新に対応、@Ninthless に感謝。 +- [#3626](https://github.com/farion1231/cc-switch/pull/3626): macOS の入力自動大文字化を無効化、@ZHLHZHU に感謝。 +- [#3593](https://github.com/farion1231/cc-switch/pull/3593): Codex VS Code セッションプレビューを修正、@xwil1 に感謝。 +- [#3228](https://github.com/farion1231/cc-switch/pull/3228): 中国語 UI の VS Code 表記を揃える、@Games55k に感謝。 +- [#3411](https://github.com/farion1231/cc-switch/pull/3411): 現行のアプリ対応を反映してユーザーマニュアルを刷新、@makoMakoGo に感謝。 +- [#3772](https://github.com/farion1231/cc-switch/pull/3772): README のリリースノートリンクとスポンサー表記を修正、@null-easy に感謝。 v3.16.1 リリース後に Codex Chat ルーティング、ローカルプロキシのテイクオーバー、用量統計、プラットフォーム互換性の問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。 diff --git a/docs/release-notes/v3.16.2-zh.md b/docs/release-notes/v3.16.2-zh.md index 4243b977b..81fcb667f 100644 --- a/docs/release-notes/v3.16.2-zh.md +++ b/docs/release-notes/v3.16.2-zh.md @@ -261,32 +261,32 @@ Codex 在启动时读取 `model_catalog_json`。即使本版已把模型目录 感谢以下贡献者在 v3.16.2 中提交的功能与修复: -- [#1351](https://github.com/farion1231/cc-switch/pull/1351):新增 S3 兼容云存储同步,感谢 [@keithyt06](https://github.com/keithyt06)。 -- [#3215](https://github.com/farion1231/cc-switch/pull/3215):新增 OpenCode 会话用量同步,感谢 [@nothingness0db](https://github.com/nothingness0db)。 -- [#2709](https://github.com/farion1231/cc-switch/pull/2709):新增 ZenMux Token Plan 供应商,感谢 [@Eter365](https://github.com/Eter365)。 -- [#3643](https://github.com/farion1231/cc-switch/pull/3643):新增 CherryIN 预设供应商,感谢 [@zhibisora](https://github.com/zhibisora)。 -- [#3818](https://github.com/farion1231/cc-switch/pull/3818):新增 Codex CLI 探活用的 `GET /v1/models` 端点,感谢 [@CSberlin](https://github.com/CSberlin)。 -- [#3426](https://github.com/farion1231/cc-switch/pull/3426):用量看板 Hero 重新设计,感谢 [@allenxu09](https://github.com/allenxu09)。 -- [#3640](https://github.com/farion1231/cc-switch/pull/3640):空 tools 时丢弃 `tool_choice`,感谢 [@Postroggy](https://github.com/Postroggy)。 -- [#3644](https://github.com/farion1231/cc-switch/pull/3644):Chat 路由保留 Codex 自定义工具元数据,感谢 [@LanternCX](https://github.com/LanternCX)。 -- [#3514](https://github.com/farion1231/cc-switch/pull/3514):Chat→Responses 始终包含 `reasoning_tokens`,感谢 [@yeeyzy](https://github.com/yeeyzy)。 -- [#3689](https://github.com/farion1231/cc-switch/pull/3689):live 已是代理占位符时跳过备份 / 恢复,感谢 [@YongmaoLuo](https://github.com/YongmaoLuo)。 -- [#3016](https://github.com/farion1231/cc-switch/pull/3016):归一化 localhost 监听地址,感谢 [@Alexlangl](https://github.com/Alexlangl)。 -- [#3775](https://github.com/farion1231/cc-switch/pull/3775):规范化 Anthropic `system` 消息,感谢 [@Dearli666](https://github.com/Dearli666)。 -- [#3656](https://github.com/farion1231/cc-switch/pull/3656):改进代理面板错误信息展示,感谢 [@lzcndm](https://github.com/lzcndm)。 -- [#2647](https://github.com/farion1231/cc-switch/pull/2647):调高无限空白检测阈值 20 → 500,感谢 [@NiuBlibing](https://github.com/NiuBlibing)。 -- [#3702](https://github.com/farion1231/cc-switch/pull/3702):智谱配额查询按所配 base URL 路由,感谢 [@YongmaoLuo](https://github.com/YongmaoLuo)。 -- [#3518](https://github.com/farion1231/cc-switch/pull/3518):适配 MiniMax 余额查询新接口与默认定价,感谢 [@LaoYueHanNi](https://github.com/LaoYueHanNi)。 -- [#3524](https://github.com/farion1231/cc-switch/pull/3524):修复智谱 Coding Plan 预设与带版本号端点的模型探测,感谢 [@makoMakoGo](https://github.com/makoMakoGo)。 -- [#3614](https://github.com/farion1231/cc-switch/pull/3614):模型目录改用相对文件名,感谢 [@steponeerror](https://github.com/steponeerror)。 -- [#3797](https://github.com/farion1231/cc-switch/pull/3797):修复 Windows 退出后托盘图标残留,感谢 [@iAJue](https://github.com/iAJue)。 -- [#3457](https://github.com/farion1231/cc-switch/pull/3457):修复 Windows 任务栏图标,感谢 [@ZhangNanNan1018](https://github.com/ZhangNanNan1018)。 -- [#3430](https://github.com/farion1231/cc-switch/pull/3430):归一化 Windows 路径分隔符以匹配子目录技能更新,感谢 [@Ninthless](https://github.com/Ninthless)。 -- [#3626](https://github.com/farion1231/cc-switch/pull/3626):关闭 macOS 输入框自动大写,感谢 [@ZHLHZHU](https://github.com/ZHLHZHU)。 -- [#3593](https://github.com/farion1231/cc-switch/pull/3593):修复 Codex VS Code 会话预览,感谢 [@xwil1](https://github.com/xwil1)。 -- [#3228](https://github.com/farion1231/cc-switch/pull/3228):对齐中文界面 VS Code 文案,感谢 [@Games55k](https://github.com/Games55k)。 -- [#3411](https://github.com/farion1231/cc-switch/pull/3411):刷新用户手册以反映当前应用支持,感谢 [@makoMakoGo](https://github.com/makoMakoGo)。 -- [#3772](https://github.com/farion1231/cc-switch/pull/3772):修复 README release note 链接与赞助商标记,感谢 [@null-easy](https://github.com/null-easy)。 +- [#1351](https://github.com/farion1231/cc-switch/pull/1351):新增 S3 兼容云存储同步,感谢 @keithyt06。 +- [#3215](https://github.com/farion1231/cc-switch/pull/3215):新增 OpenCode 会话用量同步,感谢 @nothingness0db。 +- [#2709](https://github.com/farion1231/cc-switch/pull/2709):新增 ZenMux Token Plan 供应商,感谢 @Eter365。 +- [#3643](https://github.com/farion1231/cc-switch/pull/3643):新增 CherryIN 预设供应商,感谢 @zhibisora。 +- [#3818](https://github.com/farion1231/cc-switch/pull/3818):新增 Codex CLI 探活用的 `GET /v1/models` 端点,感谢 @CSberlin。 +- [#3426](https://github.com/farion1231/cc-switch/pull/3426):用量看板 Hero 重新设计,感谢 @allenxu09。 +- [#3640](https://github.com/farion1231/cc-switch/pull/3640):空 tools 时丢弃 `tool_choice`,感谢 @Postroggy。 +- [#3644](https://github.com/farion1231/cc-switch/pull/3644):Chat 路由保留 Codex 自定义工具元数据,感谢 @LanternCX。 +- [#3514](https://github.com/farion1231/cc-switch/pull/3514):Chat→Responses 始终包含 `reasoning_tokens`,感谢 @yeeyzy。 +- [#3689](https://github.com/farion1231/cc-switch/pull/3689):live 已是代理占位符时跳过备份 / 恢复,感谢 @YongmaoLuo。 +- [#3016](https://github.com/farion1231/cc-switch/pull/3016):归一化 localhost 监听地址,感谢 @Alexlangl。 +- [#3775](https://github.com/farion1231/cc-switch/pull/3775):规范化 Anthropic `system` 消息,感谢 @Dearli666。 +- [#3656](https://github.com/farion1231/cc-switch/pull/3656):改进代理面板错误信息展示,感谢 @lzcndm。 +- [#2647](https://github.com/farion1231/cc-switch/pull/2647):调高无限空白检测阈值 20 → 500,感谢 @NiuBlibing。 +- [#3702](https://github.com/farion1231/cc-switch/pull/3702):智谱配额查询按所配 base URL 路由,感谢 @YongmaoLuo。 +- [#3518](https://github.com/farion1231/cc-switch/pull/3518):适配 MiniMax 余额查询新接口与默认定价,感谢 @LaoYueHanNi。 +- [#3524](https://github.com/farion1231/cc-switch/pull/3524):修复智谱 Coding Plan 预设与带版本号端点的模型探测,感谢 @makoMakoGo。 +- [#3614](https://github.com/farion1231/cc-switch/pull/3614):模型目录改用相对文件名,感谢 @steponeerror。 +- [#3797](https://github.com/farion1231/cc-switch/pull/3797):修复 Windows 退出后托盘图标残留,感谢 @iAJue。 +- [#3457](https://github.com/farion1231/cc-switch/pull/3457):修复 Windows 任务栏图标,感谢 @ZhangNanNan1018。 +- [#3430](https://github.com/farion1231/cc-switch/pull/3430):归一化 Windows 路径分隔符以匹配子目录技能更新,感谢 @Ninthless。 +- [#3626](https://github.com/farion1231/cc-switch/pull/3626):关闭 macOS 输入框自动大写,感谢 @ZHLHZHU。 +- [#3593](https://github.com/farion1231/cc-switch/pull/3593):修复 Codex VS Code 会话预览,感谢 @xwil1。 +- [#3228](https://github.com/farion1231/cc-switch/pull/3228):对齐中文界面 VS Code 文案,感谢 @Games55k。 +- [#3411](https://github.com/farion1231/cc-switch/pull/3411):刷新用户手册以反映当前应用支持,感谢 @makoMakoGo。 +- [#3772](https://github.com/farion1231/cc-switch/pull/3772):修复 README release note 链接与赞助商标记,感谢 @null-easy。 也感谢所有在 v3.16.1 发布后反馈 Codex Chat 路由、本地代理接管、用量统计和平台兼容性问题的用户,很多补丁都来自这些真实使用场景里的复现线索。 From 0396cd5491b81f7c8ca9a30ffc5aa966dab3ff4f Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 9 Jun 2026 11:35:38 +0800 Subject: [PATCH 10/66] fix(usage): count Claude Code Workflow sub-agent token usage collect_jsonl_files only walked //subagents/*.jsonl, so it missed Workflow sub-agent transcripts which live one level deeper at subagents/workflows/wf_*/agent-*.jsonl. As a result all Workflow token usage was invisible to the no-proxy session-log accounting. Descend into subagents/workflows/wf_*/ as well, via a new push_jsonl_children helper that keeps the fixed-depth, no-recursion design. journal.jsonl carries no assistant rows so it is skipped at parse time and needs no filename special-casing. Existing dedup (request_id PK + INSERT OR IGNORE + should_skip_session_insert) keeps the next sync's backfill idempotent. Add test_collect_jsonl_files_includes_workflow_subagents. --- src-tauri/src/services/session_usage.rs | 77 ++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/services/session_usage.rs b/src-tauri/src/services/session_usage.rs index fae5a8e50..002b34d27 100644 --- a/src-tauri/src/services/session_usage.rs +++ b/src-tauri/src/services/session_usage.rs @@ -109,9 +109,15 @@ pub fn sync_claude_session_logs(db: &Database) -> Result/`。漏掉这一层会让 Workflow 的 token +/// 用量完全不计入统计;`journal.jsonl` 不含 `type=="assistant"` 行,解析时 +/// 会被 `sync_single_file` 天然跳过,因此这里无需按文件名过滤。 fn collect_jsonl_files(projects_dir: &Path) -> Vec { let mut files = Vec::new(); @@ -136,12 +142,18 @@ fn collect_jsonl_files(projects_dir: &Path) -> Vec { // 扫描子 agent 目录: 项目/SESSION_ID/subagents/*.jsonl let subagents_dir = sub_path.join("subagents"); if subagents_dir.is_dir() { - if let Ok(agent_entries) = fs::read_dir(&subagents_dir) { - for agent_entry in agent_entries.flatten() { - let agent_path = agent_entry.path(); - if agent_path.extension().and_then(|e| e.to_str()) == Some("jsonl") - { - files.push(agent_path); + push_jsonl_children(&subagents_dir, &mut files); + + // 额外下探 Workflow 子 agent: + // 项目/SESSION_ID/subagents/workflows/wf_/*.jsonl + let workflows_dir = subagents_dir.join("workflows"); + if workflows_dir.is_dir() { + if let Ok(wf_entries) = fs::read_dir(&workflows_dir) { + for wf_entry in wf_entries.flatten() { + let wf_path = wf_entry.path(); + if wf_path.is_dir() { + push_jsonl_children(&wf_path, &mut files); + } } } } @@ -154,6 +166,18 @@ fn collect_jsonl_files(projects_dir: &Path) -> Vec { files } +/// 将 `dir` 下直接子层的所有 `.jsonl` 文件追加到 `files`(不递归)。 +fn push_jsonl_children(dir: &Path, files: &mut Vec) { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { + files.push(path); + } + } + } +} + /// 同步单个 JSONL 文件,返回 (imported, skipped) fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> { let file_path_str = file_path.to_string_lossy().to_string(); @@ -685,4 +709,39 @@ mod tests { fs::remove_dir_all(&tmp).ok(); } + + #[test] + fn test_collect_jsonl_files_includes_workflow_subagents() { + // Claude Code Workflow 把子 agent transcript 嵌在 + // 项目/SESSION_ID/subagents/workflows/wf_/ 下,比普通子 agent 深一层。 + let tmp = std::env::temp_dir().join(format!("cc-switch-test-{}", uuid::Uuid::new_v4())); + let project = tmp.join("project"); + let session_dir = project.join("test-session"); + let subagents_dir = session_dir.join("subagents"); + let wf_dir = subagents_dir.join("workflows").join("wf_test123"); + fs::create_dir_all(&wf_dir).unwrap(); + + fs::write(project.join("main.jsonl"), "{}").unwrap(); + fs::write(subagents_dir.join("agent-plain.jsonl"), "{}").unwrap(); + fs::write(wf_dir.join("agent-wf.jsonl"), "{}").unwrap(); + // journal.jsonl 也会被收集,但解析时因无 assistant 行而产出 0 条 + fs::write(wf_dir.join("journal.jsonl"), "{}").unwrap(); + + let files = collect_jsonl_files(&tmp); + let paths: Vec = files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + + // 主会话 + 普通子 agent + Workflow 子 agent(agent-wf + journal) = 4 + assert_eq!(files.len(), 4); + assert!(paths.iter().any(|p| p.contains("main.jsonl"))); + assert!(paths.iter().any(|p| p.contains("agent-plain.jsonl"))); + assert!( + paths.iter().any(|p| p.contains("agent-wf.jsonl")), + "Workflow 子 agent transcript 必须被收集" + ); + + fs::remove_dir_all(&tmp).ok(); + } } From 05bc14e82b9a7daa1a8a9abcffdd54a9f139e7f4 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 9 Jun 2026 12:04:46 +0800 Subject: [PATCH 11/66] fix(usage): import billable session messages without stop_reason The local session-log scanner dropped any assistant message that lacked a stop_reason or had output_tokens==0. Claude Code Workflow / sub-agent fan-out frequently produces messages that only wrote a message_start snapshot (output=1, stop_reason=None) without a final block, yet their input + cache_read + cache_creation tokens are already billed by Anthropic (charged once the request is accepted). Dropping them under-counted usage by ~4.1% overall, 92% concentrated in workflow/subagent transcripts. Replace the stop_reason/output gate with a billable-token check (any of input/output/cache_read/cache_creation > 0). The per-message-id dedup selection is unchanged, and request_id = "session:"+msg_id PRIMARY KEY with INSERT OR IGNORE keeps each message single-inserted, so relaxing the gate cannot double-count. Add a regression test covering a stop_reason-less message with real cache cost plus an all-zero skip. This is the parser-layer half of the Workflow under-counting fixed at the collector layer in 8d332925. --- src-tauri/src/services/session_usage.rs | 67 ++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/services/session_usage.rs b/src-tauri/src/services/session_usage.rs index 002b34d27..7d8756722 100644 --- a/src-tauri/src/services/session_usage.rs +++ b/src-tauri/src/services/session_usage.rs @@ -314,8 +314,23 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr let mut skipped: u32 = 0; for msg in messages.values() { - // 只导入有 stop_reason 的最终条目(完整的 API 调用) - if msg.stop_reason.is_none() { + // 只要产生了真实计费 token 就导入,不再强制要求 stop_reason 或 output>0。 + // + // Anthropic 在受理请求时即对 input + cache_read + cache_creation 计费 + // (这些在请求开始就确定),output 按实际生成量计。Workflow / 子 agent 的 + // 并行短命请求经常只写了 message_start 快照(output=1、stop_reason=None) + // 却没有写最终块,但其 cache/input 成本已被真实计费。旧逻辑用 stop_reason + // 非空 + output>0 双重过滤,会把这类请求整条丢弃,实测系统性低估约 4.1%, + // 且 92% 集中在 workflow/subagent。这里改为「任一计费维度 > 0 即导入」。 + // + // 去重选择逻辑(上方按 message.id 取 stop_reason 优先 / output 最大者)保持 + // 不变:它选出的代表行的 input/cache 本就准确;request_id = session:msg_id + // 主键 + INSERT OR IGNORE 保证一个 message 仍只落库一次,放宽 gate 不会双算。 + let has_billable_tokens = msg.input_tokens > 0 + || msg.output_tokens > 0 + || msg.cache_read_tokens > 0 + || msg.cache_creation_tokens > 0; + if !has_billable_tokens { continue; } @@ -325,11 +340,6 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr msg.message_id ); - // 跳过 output_tokens 为 0 的无意义条目 - if msg.output_tokens == 0 { - continue; - } - match insert_session_log_entry(db, &request_id, msg) { Ok(true) => imported += 1, Ok(false) => skipped += 1, @@ -491,7 +501,7 @@ fn insert_session_log_entry( total_cost, 0i64, // latency_ms: 会话日志无此数据 Option::::None, // first_token_ms - 200i64, // status_code: 有 stop_reason 说明请求成功 + 200i64, // status_code: 会话日志中的请求只要产生计费 token 即视为成功 Option::::None, // error_message msg.session_id, Some("session_log"), // provider_type @@ -744,4 +754,45 @@ mod tests { fs::remove_dir_all(&tmp).ok(); } + + #[test] + fn test_sync_imports_billable_message_without_stop_reason() -> Result<(), AppError> { + // 回归:stop_reason 缺失但有真实 cache/input 成本的 message(Workflow / + // 子 agent 常见的「只有 message_start 快照、没写最终块」形态)必须被计入, + // 不能因缺 stop_reason 或 output==0 而整条丢弃;全 0 token 的占位行仍应跳过。 + let db = Database::memory()?; + let tmp = std::env::temp_dir().join(format!("cc-switch-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&tmp).unwrap(); + let file = tmp.join("agent-wf.jsonl"); + + // 第一行:无 stop_reason、output=1,但 cache_read/cache_creation 很大 → 应导入 + // 第二行:全部 token 为 0 → 应跳过(无计费意义) + let billable = r#"{"type":"assistant","message":{"id":"msg_nostop","model":"claude-opus-4-8","usage":{"input_tokens":2,"output_tokens":1,"cache_read_input_tokens":48719,"cache_creation_input_tokens":2061}},"timestamp":"2026-06-07T13:01:23Z","sessionId":"session-wf"}"#; + let empty = r#"{"type":"assistant","message":{"id":"msg_empty","model":"claude-opus-4-8","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"timestamp":"2026-06-07T13:01:24Z","sessionId":"session-wf"}"#; + fs::write(&file, format!("{billable}\n{empty}\n")).unwrap(); + + let (imported, _skipped) = sync_single_file(&db, &file)?; + assert_eq!( + imported, 1, + "有 cache 成本但无 stop_reason 的 message 必须被导入" + ); + + let conn = lock_conn!(db.conn); + let cache_read: i64 = conn.query_row( + "SELECT cache_read_tokens FROM proxy_request_logs WHERE request_id = 'session:msg_nostop'", + [], + |row| row.get(0), + )?; + assert_eq!(cache_read, 48719, "cache_read 必须被完整记录"); + let empty_exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM proxy_request_logs WHERE request_id = 'session:msg_empty')", + [], + |row| row.get(0), + )?; + assert!(!empty_exists, "全 0 token 的 message 应被跳过"); + drop(conn); + + fs::remove_dir_all(&tmp).ok(); + Ok(()) + } } From 36a103bbe447562ce81ecfb03643e71db38fc7a7 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 9 Jun 2026 13:15:13 +0800 Subject: [PATCH 12/66] fix(proxy): correct usage accounting on format-conversion paths Audited all proxy format-conversion paths (Chat<->Message, Chat<->Response, Gemini<->Message) for usage/cache metering. Five issues found and fixed. The dedup mechanism (request_id PK, proxy/session source isolation) is untouched, so no double-counting is introduced. - A (Claude + openai_chat, streaming): inject stream_options.include_usage so OpenAI-compatible upstreams emit usage in the SSE tail. Without it the converted Anthropic message_delta was all-zero and the whole request's input/output/cache was dropped. Same root cause as the already-fixed Codex Chat path; the injection is extracted into a shared helper (transform::inject_openai_stream_include_usage) reused by both paths. - C (Claude + gemini_native): subtract cachedContentTokenCount from input_tokens in build_anthropic_usage so input becomes fresh input (Anthropic semantics). Previously the cache-hit tokens were billed twice because this path meters as app_type="claude" (input_includes_cache_read = false) while Gemini's promptTokenCount includes the cache. - D (Codex + openai_chat, streaming): gate log_usage on has_billable_tokens() to skip the synthetic all-zero usage the converter emits when a non-compliant upstream omits usage, preventing empty-row request-count inflation. - P2 (from_claude_stream_events): use has_billable_tokens() for the return gate instead of input>0||output>0, so a fully-cached streamed request (cache_read>0, input==output==0) is still recorded. Affects all Claude-streaming paths, not just Gemini. - P3 (Codex Chat->Responses, non-streaming): apply the same has_billable_tokens() filter the streaming branch got, since the synthesized all-zero usage makes from_codex_response return Some and bypass the `if let Some` guard. Add TokenUsage::has_billable_tokens() as the unified predicate. New tests cover include_usage injection, gemini input subtraction, the gate itself, cache-only stream recording, and synthetic all-zero codex usage. Full lib suite: 1569 passed. --- src-tauri/src/proxy/handlers.rs | 17 +++- src-tauri/src/proxy/providers/claude.rs | 41 +++++++++ src-tauri/src/proxy/providers/transform.rs | 27 ++++++ .../proxy/providers/transform_codex_chat.rs | 17 +--- .../src/proxy/providers/transform_gemini.rs | 28 +++++-- src-tauri/src/proxy/usage/parser.rs | 83 ++++++++++++++++++- 6 files changed, 188 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 9b216a123..3c5685711 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -765,6 +765,15 @@ async fn handle_codex_chat_to_responses_transform( move |events, first_token_ms| { let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap_or_default(); + // 上游遵守 OpenAI 语义省略 usage 时,Chat→Responses 转换器会合成一个 + // 全 0 的 response.completed,from_codex_response 对 input/output 字段 + // 存在(哪怕=0)即返回 Some。缺 nonzero 闸门会让全 0 usage 也被写入: + // message_id=None → dedup_request_id 退化为随机 UUID,无法去重,每笔 + // 请求插入一条无意义空行、虚增请求数。对齐 Claude transform handler 的 skip。 + if !usage.has_billable_tokens() { + log::debug!("[Codex] 流式响应 usage 全 0 或缺失,跳过消费记录"); + return; + } let model = usage.model.clone().unwrap_or_else(|| request_model.clone()); let latency_ms = start_time.elapsed().as_millis() as u64; @@ -844,7 +853,13 @@ async fn handle_codex_chat_to_responses_transform( .record_response(&responses_response) .await; - if let Some(usage) = TokenUsage::from_codex_response_auto(&responses_response) { + // 上游非流式 Chat 省略 usage 时,chat_usage_to_responses_usage 会合成全 0 usage + // (transform_codex_chat.rs:1581),from_codex_response 对 input/output 字段存在(哪怕=0) + // 即返回 Some。用 has_billable_tokens 闸门跳过全 0,避免空行虚增请求数——与流式分支 + // 及 Claude transform handler 的 skip 行为对齐。 + if let Some(usage) = TokenUsage::from_codex_response_auto(&responses_response) + .filter(TokenUsage::has_billable_tokens) + { let model = responses_response .get("model") .and_then(|m| m.as_str()) diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index ae8123d9e..51a996386 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -409,6 +409,10 @@ pub fn transform_claude_request_for_api_format( { result["prompt_cache_key"] = serde_json::json!(key); } + // 流式请求必须注入 stream_options.include_usage,否则 OpenAI 兼容上游 + // 不在 SSE 末尾吐 usage → 转换出的 Anthropic message_delta 全 0 → + // 整笔 input/output/cache 漏记(与 Codex Responses→Chat 路径同源)。 + super::transform::inject_openai_stream_include_usage(&mut result); Ok(result) } "gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow( @@ -1617,6 +1621,43 @@ mod tests { assert!(transformed.get("max_output_tokens").is_some()); } + #[test] + fn test_transform_claude_request_openai_chat_streaming_injects_include_usage() { + let provider = create_provider(json!({ + "env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1" } + })); + // 流式请求必须注入 stream_options.include_usage,否则 OpenAI 兼容上游不在 + // SSE 末尾吐 usage → 转换出的 Anthropic message_delta 全 0 → 整笔 usage 漏记。 + let body = json!({ + "model": "moonshotai/kimi-k2", + "messages": [{ "role": "user", "content": "hello" }], + "max_tokens": 128, + "stream": true + }); + let transformed = + transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None) + .unwrap(); + assert_eq!(transformed["stream"], true); + assert_eq!(transformed["stream_options"]["include_usage"], true); + } + + #[test] + fn test_transform_claude_request_openai_chat_non_streaming_omits_stream_options() { + let provider = create_provider(json!({ + "env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1" } + })); + // 非流式请求不应注入 stream_options(usage 在非流式响应体里恒有)。 + let body = json!({ + "model": "moonshotai/kimi-k2", + "messages": [{ "role": "user", "content": "hello" }], + "max_tokens": 128 + }); + let transformed = + transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None) + .unwrap(); + assert!(transformed.get("stream_options").is_none()); + } + #[test] fn test_transform_claude_request_for_codex_oauth_uses_session_cache_key() { let provider = create_provider_with_meta( diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 5349d6701..57dd7928b 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -225,6 +225,33 @@ pub fn anthropic_to_openai_with_reasoning_content( Ok(result) } +/// 为 OpenAI Chat Completions 流式请求注入 `stream_options.include_usage`。 +/// +/// OpenAI 兼容上游在流式下默认不在 SSE 里返回 usage,必须显式声明 include_usage +/// 才会在末尾吐 usage chunk。缺这一注入会导致流式请求的 token/成本/缓存全部漏记 +/// (input/output/cache 全为 0)。保留客户端可能透传的其它 stream_options 字段, +/// 仅补 include_usage;非流式请求不动。 +/// +/// 由 Claude→openai_chat(claude.rs)与 Codex Responses→Chat(transform_codex_chat.rs) +/// 两条转换路径共用,确保两个客户端方向行为一致。 +pub(crate) fn inject_openai_stream_include_usage(result: &mut Value) { + let is_stream = result + .get("stream") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !is_stream { + return; + } + match result.get_mut("stream_options") { + Some(Value::Object(opts)) => { + opts.insert("include_usage".to_string(), json!(true)); + } + _ => { + result["stream_options"] = json!({ "include_usage": true }); + } + } +} + /// Translate an Anthropic `tool_choice` into the OpenAI Chat Completions form. /// /// Anthropic forms: diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index fdd41e6a4..d43809f0f 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -336,21 +336,8 @@ pub fn responses_to_chat_completions_with_reasoning( // include_usage 才会在末尾吐 usage chunk。Codex CLI 用 Responses 协议、 // 自身不带 stream_options,缺这一注入会导致 kimi/MiniMax 等第三方流式请求的 // token/成本/缓存命中率全部漏记(input/output/cache 全为 0)。 - let is_stream = result - .get("stream") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if is_stream { - match result.get_mut("stream_options") { - // 保留客户端可能透传的其它 stream_options 字段,仅补 include_usage。 - Some(Value::Object(opts)) => { - opts.insert("include_usage".to_string(), json!(true)); - } - _ => { - result["stream_options"] = json!({ "include_usage": true }); - } - } - } + // 与 Claude→openai_chat 路径共用同一 helper,保证两个客户端方向一致。 + super::transform::inject_openai_stream_include_usage(&mut result); Ok(result) } diff --git a/src-tauri/src/proxy/providers/transform_gemini.rs b/src-tauri/src/proxy/providers/transform_gemini.rs index 7320e6e5b..d386d57a5 100644 --- a/src-tauri/src/proxy/providers/transform_gemini.rs +++ b/src-tauri/src/proxy/providers/transform_gemini.rs @@ -1101,7 +1101,7 @@ pub(crate) fn build_anthropic_usage(usage: Option<&Value>) -> Value { }); }; - let input_tokens = usage + let prompt_tokens = usage .get("promptTokenCount") .and_then(|value| value.as_u64()) .unwrap_or(0); @@ -1109,18 +1109,26 @@ pub(crate) fn build_anthropic_usage(usage: Option<&Value>) -> Value { .get("totalTokenCount") .and_then(|value| value.as_u64()) .unwrap_or(0); - let output_tokens = total_tokens.saturating_sub(input_tokens); + let cached_tokens = usage + .get("cachedContentTokenCount") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + // Gemini 的 promptTokenCount 含缓存命中(cachedContentTokenCount);而 Anthropic + // 语义下 input_tokens 必须是不含 cache 的 fresh input、cache_read 单列。本路径转成 + // Anthropic 后以 app_type=claude 记账,calculator 对 claude 设 input_includes_cache_read + // =false 不再从 input 扣 cache,因此这里必须先扣减,否则缓存 token 会被双重计费 + // (一次按完整 input 价、一次按 cache_read 价)。output 仍按 total-prompt 计算 + // (prompt 是总输入,扣减只作用于 input/cache 的拆分,不影响 output)。 + let input_tokens = prompt_tokens.saturating_sub(cached_tokens); + let output_tokens = total_tokens.saturating_sub(prompt_tokens); let mut result = json!({ "input_tokens": input_tokens, "output_tokens": output_tokens }); - if let Some(cached) = usage - .get("cachedContentTokenCount") - .and_then(|value| value.as_u64()) - { - result["cache_read_input_tokens"] = json!(cached); + if cached_tokens > 0 { + result["cache_read_input_tokens"] = json!(cached_tokens); } result @@ -1370,7 +1378,11 @@ mod tests { assert_eq!(result["content"][0]["type"], "text"); assert_eq!(result["content"][0]["text"], "Hello from Gemini"); assert_eq!(result["stop_reason"], "end_turn"); - assert_eq!(result["usage"]["input_tokens"], 12); + // input_tokens = promptTokenCount(12) - cachedContentTokenCount(3) = 9(fresh input)。 + // Gemini 的 promptTokenCount 含缓存命中,但 Anthropic 语义要求 input 不含 cache、 + // cache_read 单列;二者相加(9+3)=总输入 12。扣减避免本路径以 app_type=claude + // 记账时把缓存 token 双重计费。 + assert_eq!(result["usage"]["input_tokens"], 9); assert_eq!(result["usage"]["output_tokens"], 8); assert_eq!(result["usage"]["cache_read_input_tokens"], 3); } diff --git a/src-tauri/src/proxy/usage/parser.rs b/src-tauri/src/proxy/usage/parser.rs index 8b186efd0..40f77a4d7 100644 --- a/src-tauri/src/proxy/usage/parser.rs +++ b/src-tauri/src/proxy/usage/parser.rs @@ -37,6 +37,18 @@ impl TokenUsage { .map(|mid| format!("{SESSION_REQUEST_ID_PREFIX}{mid}")) .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) } + + /// 是否产生了任一计费维度的 token。 + /// + /// 用于在写入前过滤全 0 的空 usage:当 OpenAI 兼容上游在流式下省略 usage 时, + /// 转换器会合成一个全 0 的终止事件,若无 message_id 则 `dedup_request_id` + /// 退化为随机 UUID,导致每笔请求插入一条无意义的空行、虚增请求数。 + pub fn has_billable_tokens(&self) -> bool { + self.input_tokens > 0 + || self.output_tokens > 0 + || self.cache_read_tokens > 0 + || self.cache_creation_tokens > 0 + } } /// API 类型 @@ -185,7 +197,11 @@ impl TokenUsage { } } - if usage.input_tokens > 0 || usage.output_tokens > 0 { + // 用 has_billable_tokens 而非仅看 input/output:完全缓存命中、无输出的流式请求 + // (input==0 && output==0 但 cache_read>0)是真实的 cache-read 计费,必须保留。 + // Gemini→Anthropic 路径在 input 改为 fresh(promptTokenCount - cachedContentTokenCount) + // 后尤其会出现这种全缓存场景;旧 gate 会把它当成"无 usage"丢弃。 + if usage.has_billable_tokens() { usage.model = model; usage.message_id = message_id; Some(usage) @@ -522,6 +538,71 @@ mod tests { assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string())); } + #[test] + fn test_has_billable_tokens_gates_empty_usage() { + // 全 0 usage(如上游省略 usage 时合成的全 0 终止事件)不应计费—— + // 这是 Codex 流式空行多记修复(D)的闸门依据。 + assert!(!TokenUsage::default().has_billable_tokens()); + // 仅有 cache_read 也属于真实计费 token,必须计入。 + let only_cache = TokenUsage { + cache_read_tokens: 100, + ..Default::default() + }; + assert!(only_cache.has_billable_tokens()); + let normal = TokenUsage { + input_tokens: 10, + output_tokens: 5, + ..Default::default() + }; + assert!(normal.has_billable_tokens()); + } + + #[test] + fn test_claude_stream_cache_only_request_is_recorded() { + // P2 回归:完全缓存命中、无输出的流式请求(input==0 && output==0 但 cache_read>0) + // 是真实计费,必须保留——旧 gate `input>0 || output>0` 会把它丢弃。 + let events = vec![ + json!({ + "type": "message_start", + "message": { + "id": "msg_cacheonly", + "model": "claude-opus-4-8", + "usage": { + "input_tokens": 0, + "cache_read_input_tokens": 50000, + "cache_creation_input_tokens": 0 + } + } + }), + json!({ + "type": "message_delta", + "usage": { "output_tokens": 0 } + }), + ]; + let usage = TokenUsage::from_claude_stream_events(&events) + .expect("cache-only 流式请求必须被记录,不能被 input/output gate 丢弃"); + assert_eq!(usage.input_tokens, 0); + assert_eq!(usage.output_tokens, 0); + assert_eq!(usage.cache_read_tokens, 50000); + assert_eq!(usage.message_id, Some("msg_cacheonly".to_string())); + } + + #[test] + fn test_codex_response_auto_returns_some_for_synthetic_all_zero() { + // P3 回归:上游非流式 Chat 省略 usage 时转换器合成的全 0 usage,from_codex_response_auto + // 仍返回 Some(字段存在、无 positivity check)——证明 handlers 必须用 has_billable_tokens + // 闸门才能挡住空行,单靠 `if let Some` 不够。 + let synthetic = json!({ + "usage": { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0 } + }); + let usage = TokenUsage::from_codex_response_auto(&synthetic) + .expect("全 0 usage 字段存在时 from_codex_response_auto 返回 Some"); + assert!( + !usage.has_billable_tokens(), + "全 0 usage 必须被 has_billable_tokens 判为非计费,由 handlers 闸门跳过" + ); + } + #[test] fn test_claude_response_parsing_no_model() { let response = json!({ From cb01593f7d958607c4f1793f990536d3489240e0 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 9 Jun 2026 21:39:09 +0800 Subject: [PATCH 13/66] =?UTF-8?q?fix(proxy):=20exclude=20cache=5Fread=20an?= =?UTF-8?q?d=20cache=5Fcreation=20from=20input=20on=20Claude=E2=86=90OpenA?= =?UTF-8?q?I=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on #2774 (which fixed cache_read for the streaming openai_chat path). Two gaps remained, both double-counting cache tokens when a Claude client meters as app_type="claude" (input_includes_cache_read=false): 1. cache_read was still added to input on the non-streaming openai_chat path (transform.rs openai_to_anthropic) and the whole openai_responses family (transform_responses.rs build_anthropic_usage_from_responses, covering the non-streaming call site and both streaming_responses call sites). 2. cache_creation was never subtracted on any converted path, including the streaming openai_chat path #2774 had already touched. Claude billing treats cache_creation as a separate bucket, so an inclusive upstream carrying a direct cache_creation_input_tokens field billed it twice. All four metering points now compute: input = prompt_tokens - cache_read - cache_creation restoring the invariant input + cache_read + cache_creation == prompt_tokens. Pure OpenAI upstreams are unaffected (no cache_creation concept/field). Tests: update direct-cache assertions (40->20), add a streaming conservation regression test, and pin prompt Value { - // OpenAI prompt_tokens 含缓存,Anthropic input_tokens 不含,需减去 + // OpenAI prompt_tokens 含缓存,Anthropic input_tokens 不含,需减去 cache_read 与 cache_creation + // (三桶互斥,恒等 input + cache_read + cache_creation == prompt_tokens)。 let cached = extract_cache_read_tokens(usage).unwrap_or(0); - let input_tokens = usage.prompt_tokens.saturating_sub(cached); + let cache_creation = usage.cache_creation_input_tokens.unwrap_or(0); + let input_tokens = usage + .prompt_tokens + .saturating_sub(cached) + .saturating_sub(cache_creation); let mut usage_json = json!({ "input_tokens": input_tokens, "output_tokens": usage.completion_tokens @@ -110,8 +115,8 @@ fn build_anthropic_usage_json(usage: &Usage) -> Value { if cached > 0 { usage_json["cache_read_input_tokens"] = json!(cached); } - if let Some(created) = usage.cache_creation_input_tokens { - usage_json["cache_creation_input_tokens"] = json!(created); + if cache_creation > 0 { + usage_json["cache_creation_input_tokens"] = json!(cache_creation); } usage_json } @@ -227,13 +232,19 @@ pub fn create_anthropic_sse_stream( }); if let Some(u) = &chunk.usage { let cached = extract_cache_read_tokens(u).unwrap_or(0); - let input = u.prompt_tokens.saturating_sub(cached); + let cache_creation = + u.cache_creation_input_tokens.unwrap_or(0); + let input = u + .prompt_tokens + .saturating_sub(cached) + .saturating_sub(cache_creation); start_usage["input_tokens"] = json!(input); if cached > 0 { start_usage["cache_read_input_tokens"] = json!(cached); } - if let Some(created) = u.cache_creation_input_tokens { - start_usage["cache_creation_input_tokens"] = json!(created); + if cache_creation > 0 { + start_usage["cache_creation_input_tokens"] = + json!(cache_creation); } } @@ -1043,6 +1054,81 @@ mod tests { ); } + #[tokio::test] + async fn test_usage_chunk_subtracts_cache_read_and_creation_from_input() { + // prompt_tokens(1000) 含 cache_read(600) 与 cache_creation(300);转 Anthropic 后 + // input 应为 fresh,守恒:input(100) + cache_read(600) + cache_creation(300) == prompt(1000)。 + let input = concat!( + "data: {\"id\":\"chatcmpl_cc\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"tool-1\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"{\\\"command\\\":\\\"pwd\\\"}\"}}]}}]}\n\n", + "data: {\"id\":\"chatcmpl_cc\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":600},\"cache_creation_input_tokens\":300}}\n\n", + "data: [DONE]\n\n" + ); + + let events = collect_anthropic_events(input).await; + let message_delta = events + .iter() + .find(|event| event_type(event) == Some("message_delta")) + .expect("should emit message_delta with usage"); + + // fresh input = 1000 - 600 - 300 = 100 + assert_eq!( + message_delta + .pointer("/usage/input_tokens") + .and_then(|v| v.as_u64()), + Some(100) + ); + assert_eq!( + message_delta + .pointer("/usage/cache_read_input_tokens") + .and_then(|v| v.as_u64()), + Some(600) + ); + assert_eq!( + message_delta + .pointer("/usage/cache_creation_input_tokens") + .and_then(|v| v.as_u64()), + Some(300) + ); + } + + #[tokio::test] + async fn test_usage_chunk_clamps_input_to_zero_when_cache_exceeds_prompt() { + // prompt(100) < cache_read(80)+cache_creation(50)=130:saturating 钳到 0,防下溢。 + // 钉桩:阻止未来把 saturating_sub 误改成普通减法(debug panic / release wrap)。 + let input = concat!( + "data: {\"id\":\"chatcmpl_uf\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"tool-1\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"{\\\"command\\\":\\\"pwd\\\"}\"}}]}}]}\n\n", + "data: {\"id\":\"chatcmpl_uf\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":80},\"cache_creation_input_tokens\":50}}\n\n", + "data: [DONE]\n\n" + ); + + let events = collect_anthropic_events(input).await; + let message_delta = events + .iter() + .find(|event| event_type(event) == Some("message_delta")) + .expect("should emit message_delta with usage"); + + assert_eq!( + message_delta + .pointer("/usage/input_tokens") + .and_then(|v| v.as_u64()), + Some(0) + ); + assert_eq!( + message_delta + .pointer("/usage/cache_read_input_tokens") + .and_then(|v| v.as_u64()), + Some(80) + ); + assert_eq!( + message_delta + .pointer("/usage/cache_creation_input_tokens") + .and_then(|v| v.as_u64()), + Some(50) + ); + } + #[tokio::test] async fn test_message_delta_includes_zero_usage_when_stream_has_no_usage() { let input = concat!( diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 57dd7928b..9b0dd8f72 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -644,10 +644,31 @@ pub fn openai_to_anthropic(body: Value) -> Result { // usage — map cache tokens from OpenAI format to Anthropic format let usage = body.get("usage").cloned().unwrap_or(json!({})); + // OpenAI prompt_tokens 含缓存命中,Anthropic input_tokens 不含 → 减去 cache_read 与 + // cache_creation,使 input 成为 fresh input。本路径以 app_type="claude" 记账(calculator + // 不再扣减),若不减则缓存会被计入 input 与各 cache 桶两次。三桶互斥,恒等: + // input + cache_read + cache_creation == prompt_tokens(inclusive 上游)。 + // 与流式 build_anthropic_usage_json (#2774) 及 transform_gemini 的 saturating_sub 对称。 + // 最终 cache_read:直传字段优先于 nested;cache_creation 仅来自直传字段(OpenAI 无此概念)。 + let cached = usage + .get("cache_read_input_tokens") + .and_then(|v| v.as_u64()) + .or_else(|| { + usage + .pointer("/prompt_tokens_details/cached_tokens") + .and_then(|v| v.as_u64()) + }) + .unwrap_or(0); + let cache_creation = usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); let input_tokens = usage .get("prompt_tokens") .and_then(|v| v.as_u64()) - .unwrap_or(0) as u32; + .unwrap_or(0) + .saturating_sub(cached) + .saturating_sub(cache_creation) as u32; let output_tokens = usage .get("completion_tokens") .and_then(|v| v.as_u64()) @@ -658,19 +679,11 @@ pub fn openai_to_anthropic(body: Value) -> Result { "output_tokens": output_tokens }); - // OpenAI standard: prompt_tokens_details.cached_tokens - if let Some(cached) = usage - .pointer("/prompt_tokens_details/cached_tokens") - .and_then(|v| v.as_u64()) - { + if cached > 0 { usage_json["cache_read_input_tokens"] = json!(cached); } - // Some compatible servers return these fields directly - if let Some(v) = usage.get("cache_read_input_tokens") { - usage_json["cache_read_input_tokens"] = v.clone(); - } - if let Some(v) = usage.get("cache_creation_input_tokens") { - usage_json["cache_creation_input_tokens"] = v.clone(); + if cache_creation > 0 { + usage_json["cache_creation_input_tokens"] = json!(cache_creation); } let result = json!({ @@ -1314,7 +1327,8 @@ mod tests { }); let result = openai_to_anthropic(input).unwrap(); - assert_eq!(result["usage"]["input_tokens"], 100); + // prompt_tokens(100) 含 cached(80),转换后 input 应为 fresh = 100 - 80 = 20 + assert_eq!(result["usage"]["input_tokens"], 20); assert_eq!(result["usage"]["output_tokens"], 50); assert_eq!(result["usage"]["cache_read_input_tokens"], 80); } @@ -1338,10 +1352,38 @@ mod tests { }); let result = openai_to_anthropic(input).unwrap(); + // cache_read(60)+cache_creation(20) 均从 prompt(100) 扣除,fresh = 100 - 60 - 20 = 20 + // 守恒:input(20) + cache_read(60) + cache_creation(20) == prompt(100) + assert_eq!(result["usage"]["input_tokens"], 20); assert_eq!(result["usage"]["cache_read_input_tokens"], 60); assert_eq!(result["usage"]["cache_creation_input_tokens"], 20); } + #[test] + fn test_openai_to_anthropic_clamps_input_when_cache_exceeds_prompt() { + // prompt(100) < cache_read(60)+cache_creation(50)=110:saturating 钳到 0,防下溢。 + // 钉桩:阻止未来把 saturating_sub 误改成普通减法(debug panic / release wrap)。 + let input = json!({ + "id": "chatcmpl-uf", + "model": "gpt-4", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "x"}, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 10, + "cache_read_input_tokens": 60, + "cache_creation_input_tokens": 50 + } + }); + let result = openai_to_anthropic(input).unwrap(); + assert_eq!(result["usage"]["input_tokens"], 0); + assert_eq!(result["usage"]["cache_read_input_tokens"], 60); + assert_eq!(result["usage"]["cache_creation_input_tokens"], 50); + } + #[test] fn test_openai_to_anthropic_finish_reason_content_filter_maps_end_turn() { let input = json!({ diff --git a/src-tauri/src/proxy/providers/transform_responses.rs b/src-tauri/src/proxy/providers/transform_responses.rs index 537e0f5de..d7379cd6e 100644 --- a/src-tauri/src/proxy/providers/transform_responses.rs +++ b/src-tauri/src/proxy/providers/transform_responses.rs @@ -355,6 +355,23 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val result["cache_creation_input_tokens"] = v.clone(); } + // OpenAI/Responses 的 input(prompt_tokens/input_tokens)含缓存命中,Anthropic input_tokens 不含 + // → 减去 cache_read 与 cache_creation,使其成为 fresh input。本函数在计量意义上是 claude 专属 + // (Codex Responses 透传走 from_codex_response_*,不调用本函数),故可安全在此扣减。三桶互斥, + // 恒等:input + cache_read + cache_creation == 上游 input(inclusive)。与 build_anthropic_usage_json + // (#2774) 及 transform_gemini 的 saturating_sub 对称;一处同时覆盖非流式与流式(streaming_responses)。 + let cached = result + .get("cache_read_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let cache_creation = result + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + if cached > 0 || cache_creation > 0 { + result["input_tokens"] = json!(input.saturating_sub(cached).saturating_sub(cache_creation)); + } + result } @@ -1156,7 +1173,8 @@ mod tests { }); let result = responses_to_anthropic(input).unwrap(); - assert_eq!(result["usage"]["input_tokens"], 100); + // input_tokens(100) 含 cached(80),转换后 input 应为 fresh = 100 - 80 = 20 + assert_eq!(result["usage"]["input_tokens"], 20); assert_eq!(result["usage"]["output_tokens"], 50); assert_eq!(result["usage"]["cache_read_input_tokens"], 80); } @@ -1180,6 +1198,9 @@ mod tests { }); let result = responses_to_anthropic(input).unwrap(); + // cache_read(60)+cache_creation(20) 均从 input(100) 扣除,fresh = 100 - 60 - 20 = 20 + // 守恒:input(20) + cache_read(60) + cache_creation(20) == 上游 input(100) + assert_eq!(result["usage"]["input_tokens"], 20); assert_eq!(result["usage"]["cache_read_input_tokens"], 60); assert_eq!(result["usage"]["cache_creation_input_tokens"], 20); } @@ -1642,7 +1663,8 @@ mod tests { "cached_tokens": 80 } }))); - assert_eq!(result["input_tokens"], json!(100)); + // input_tokens(100) 含 nested cached(80),转换后 input 应为 fresh = 100 - 80 = 20 + assert_eq!(result["input_tokens"], json!(20)); assert_eq!(result["output_tokens"], json!(50)); assert_eq!(result["cache_read_input_tokens"], json!(80)); } @@ -1657,9 +1679,26 @@ mod tests { }, "cache_read_input_tokens": 100 }))); + // 直传 cache_read(100) 优先于 nested(80);input(100) - 100 = 0(fresh) + assert_eq!(result["input_tokens"], json!(0)); assert_eq!(result["cache_read_input_tokens"], json!(100)); // Direct field overrides nested } + #[test] + fn test_build_usage_clamps_input_when_cache_exceeds_input() { + // input(100) < cache_read(60)+cache_creation(50)=110:saturating 钳到 0,防下溢。 + // 钉桩:阻止未来把 saturating_sub 误改成普通减法(debug panic / release wrap)。 + let result = build_anthropic_usage_from_responses(Some(&json!({ + "input_tokens": 100, + "output_tokens": 10, + "cache_read_input_tokens": 60, + "cache_creation_input_tokens": 50 + }))); + assert_eq!(result["input_tokens"], json!(0)); + assert_eq!(result["cache_read_input_tokens"], json!(60)); + assert_eq!(result["cache_creation_input_tokens"], json!(50)); + } + #[test] fn test_build_usage_cache_tokens_without_input_output() { let result = build_anthropic_usage_from_responses(Some(&json!({ From 3390fe7ea099d8a0f0c742500fc5599d72a035f1 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 9 Jun 2026 22:11:39 +0800 Subject: [PATCH 14/66] fix(proxy): extend image rectifier to Codex /responses text-only path Codex /responses requests routed to text-only OpenAI-chat upstreams (e.g. DeepSeek deepseek-v4-flash) failed with HTTP 400 "unknown variant image_url" when images were sent: the responses->chat conversion turns input_image items into image_url blocks the model rejects. The media rectifier previously covered only the Claude adapter, so neither the proactive strip nor the reactive retry fired for Codex. - media_retry_should_trigger: accept "Codex" adapter, not just "Claude" - contains_image_blocks / replace_images: also scan responses `input` (input_image) in addition to chat `messages` - is_image_block_type: match image | image_url | input_image - is_unsupported_image_error: add "unknown variant" hint for the deserialize error - forward(): proactively run apply_media_prevention for Codex after the responses->chat conversion Proactively strips images for known text-only models (heuristic on by default) and reactively retries with images replaced on upstream image-unsupported errors. Adds tests for chat image_url, codex input_image, the reactive trigger, and the deserialize error match. --- src-tauri/src/proxy/forwarder.rs | 35 ++++- src-tauri/src/proxy/media_sanitizer.rs | 182 +++++++++++++++++++++---- 2 files changed, 188 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index d24784f7f..3a8f815b9 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -159,7 +159,7 @@ impl RequestForwarder { provider_body: &Value, error: &ProxyError, ) -> bool { - adapter_name == "Claude" + matches!(adapter_name, "Claude" | "Codex") && self.rectifier_config.enabled && self.rectifier_config.request_media_fallback && !already_retried @@ -1321,7 +1321,7 @@ impl RequestForwarder { }; // 转换请求体(如果需要) - let request_body = if codex_responses_to_chat { + let mut request_body = if codex_responses_to_chat { let mut mapped_body = mapped_body; let restored = self .codex_chat_history @@ -1359,6 +1359,10 @@ impl RequestForwarder { mapped_body }; + if matches!(app_type, AppType::Codex) { + self.apply_media_prevention(&mut request_body, provider); + } + // 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游 // 默认使用空白名单,过滤所有 _ 前缀字段 let filtered_body = prepare_upstream_request_body(request_body); @@ -3298,6 +3302,18 @@ mod tests { }) } + fn body_with_codex_input_image(model: &str) -> Value { + json!({ + "model": model, + "input": [{ + "role": "user", + "content": [ + { "type": "input_image", "image_url": "data:image/png;base64,abc" } + ] + }] + }) + } + fn image_unsupported_error() -> ProxyError { ProxyError::UpstreamError { status: 400, @@ -3385,6 +3401,21 @@ mod tests { assert!(fwd.media_retry_should_trigger("Claude", false, &body, &image_unsupported_error())); } + #[test] + fn reactive_triggers_for_codex_image_url_deserialize_errors() { + let fwd = forwarder_with_rectifier(RectifierConfig::default()); + let body = body_with_codex_input_image("deepseek-v4-flash"); + let error = ProxyError::UpstreamError { + status: 400, + body: Some( + r#"{"error":{"message":"Failed to deserialize the JSON body into the target type: messages[11]: unknown variant image_url, expected text"}}"# + .to_string(), + ), + }; + + assert!(fwd.media_retry_should_trigger("Codex", false, &body, &error)); + } + #[test] fn reactive_skipped_when_media_fallback_off() { // 关闭 request_media_fallback:上游报图片错误也不触发兜底重试。 diff --git a/src-tauri/src/proxy/media_sanitizer.rs b/src-tauri/src/proxy/media_sanitizer.rs index 509b83e28..ad30fd2fc 100644 --- a/src-tauri/src/proxy/media_sanitizer.rs +++ b/src-tauri/src/proxy/media_sanitizer.rs @@ -41,14 +41,7 @@ pub fn replace_images_for_text_only_model( } pub fn contains_image_blocks(body: &Value) -> bool { - body.get("messages") - .and_then(Value::as_array) - .is_some_and(|messages| { - messages - .iter() - .filter_map(|message| message.get("content")) - .any(content_has_image_blocks) - }) + messages_have_image_blocks(body) || responses_input_has_image_blocks(body.get("input")) } pub fn replace_image_blocks_with_marker(body: &mut Value) -> usize { @@ -95,6 +88,7 @@ pub fn is_unsupported_image_error(error: &ProxyError) -> bool { "text-only", "invalid content type", "invalid message content", + "unknown variant", "unknown content type", "unrecognized content type", "cannot process", @@ -113,51 +107,124 @@ fn content_has_image_blocks(content: &Value) -> bool { }; blocks.iter().any(|block| { - block.get("type").and_then(Value::as_str) == Some("image") + is_image_block_type(block.get("type").and_then(Value::as_str)) || block.get("content").is_some_and(content_has_image_blocks) }) } fn replace_images_in_body(body: &mut Value) -> usize { - let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { - return 0; - }; + let message_replacements = body + .get_mut("messages") + .and_then(Value::as_array_mut) + .map(|messages| { + messages + .iter_mut() + .filter_map(|message| message.get_mut("content")) + .map(replace_images_in_content) + .sum() + }) + .unwrap_or(0); - messages - .iter_mut() - .filter_map(|message| message.get_mut("content")) - .map(replace_images_in_content) - .sum() + message_replacements + + body + .get_mut("input") + .map(replace_images_in_responses_input) + .unwrap_or(0) } fn replace_images_in_content(content: &mut Value) -> usize { + replace_images_in_content_with_text_type(content, "text") +} + +fn replace_images_in_content_with_text_type(content: &mut Value, text_type: &str) -> usize { let Some(blocks) = content.as_array_mut() else { return 0; }; let mut replaced = 0usize; for block in blocks { - if block.get("type").and_then(Value::as_str) == Some("image") { - let cache_control = block.get("cache_control").cloned(); - *block = json!({ - "type": "text", - "text": UNSUPPORTED_IMAGE_MARKER - }); - if let (Some(cache_control), Some(object)) = (cache_control, block.as_object_mut()) { - object.insert("cache_control".to_string(), cache_control); - } + if is_image_block_type(block.get("type").and_then(Value::as_str)) { + replace_image_block_with_text_marker(block, text_type); replaced += 1; continue; } if let Some(nested_content) = block.get_mut("content") { - replaced += replace_images_in_content(nested_content); + replaced += replace_images_in_content_with_text_type(nested_content, text_type); } } replaced } +fn messages_have_image_blocks(body: &Value) -> bool { + body.get("messages") + .and_then(Value::as_array) + .is_some_and(|messages| { + messages + .iter() + .filter_map(|message| message.get("content")) + .any(content_has_image_blocks) + }) +} + +fn responses_input_has_image_blocks(input: Option<&Value>) -> bool { + match input { + Some(Value::Array(items)) => items.iter().any(responses_input_item_has_image_blocks), + Some(item @ Value::Object(_)) => responses_input_item_has_image_blocks(item), + _ => false, + } +} + +fn responses_input_item_has_image_blocks(item: &Value) -> bool { + if item.get("type").and_then(Value::as_str) == Some("input_image") { + return true; + } + + item.get("content").is_some_and(content_has_image_blocks) +} + +fn replace_images_in_responses_input(input: &mut Value) -> usize { + match input { + Value::Array(items) => items + .iter_mut() + .map(replace_images_in_responses_input_item) + .sum(), + Value::Object(_) => replace_images_in_responses_input_item(input), + _ => 0, + } +} + +fn replace_images_in_responses_input_item(item: &mut Value) -> usize { + let mut replaced = 0usize; + + if item.get("type").and_then(Value::as_str) == Some("input_image") { + replace_image_block_with_text_marker(item, "input_text"); + replaced += 1; + } + + if let Some(content) = item.get_mut("content") { + replaced += replace_images_in_content_with_text_type(content, "input_text"); + } + + replaced +} + +fn is_image_block_type(block_type: Option<&str>) -> bool { + matches!(block_type, Some("image" | "image_url" | "input_image")) +} + +fn replace_image_block_with_text_marker(block: &mut Value, text_type: &str) { + let cache_control = block.get("cache_control").cloned(); + *block = json!({ + "type": text_type, + "text": UNSUPPORTED_IMAGE_MARKER + }); + if let (Some(cache_control), Some(object)) = (cache_control, block.as_object_mut()) { + object.insert("cache_control".to_string(), cache_control); + } +} + fn explicit_model_image_support(provider: &Provider, model: &str) -> Option { let settings = &provider.settings_config; [ @@ -369,6 +436,54 @@ mod tests { ); } + #[test] + fn known_text_only_models_replace_chat_image_url_before_send() { + let provider = provider(json!({})); + let mut body = json!({ + "model": "deepseek-v4-flash", + "messages": [{ + "role": "user", + "content": [ + { "type": "text", "text": "look" }, + { "type": "image_url", "image_url": { "url": "data:image/png;base64,abc" } } + ] + }] + }); + + let count = replace_images_for_text_only_model(&mut body, &provider, true); + + assert_eq!(count, 1); + assert_eq!(body["messages"][0]["content"][1]["type"], "text"); + assert_eq!( + body["messages"][0]["content"][1]["text"], + UNSUPPORTED_IMAGE_MARKER + ); + } + + #[test] + fn known_text_only_models_replace_codex_input_image_before_send() { + let provider = provider(json!({})); + let mut body = json!({ + "model": "deepseek-v4-flash", + "input": [{ + "role": "user", + "content": [ + { "type": "input_text", "text": "look" }, + { "type": "input_image", "image_url": "data:image/png;base64,abc" } + ] + }] + }); + + let count = replace_images_for_text_only_model(&mut body, &provider, true); + + assert_eq!(count, 1); + assert_eq!(body["input"][0]["content"][1]["type"], "input_text"); + assert_eq!( + body["input"][0]["content"][1]["text"], + UNSUPPORTED_IMAGE_MARKER + ); + } + #[test] fn explicit_text_modalities_replace_images_before_send() { let provider = provider(json!({ @@ -654,6 +769,19 @@ mod tests { assert!(is_unsupported_image_error(&attachment_error)); } + #[test] + fn detects_chat_content_unknown_variant_image_url_errors() { + let error = ProxyError::UpstreamError { + status: 400, + body: Some( + r#"{"error":{"message":"Failed to deserialize the JSON body into the target type: messages[11]: unknown variant image_url, expected text"}}"# + .to_string(), + ), + }; + + assert!(is_unsupported_image_error(&error)); + } + #[test] fn heuristic_disabled_keeps_images_for_listed_text_only_models() { // allow_heuristic = false:内置列表不再预测性剥图,避免误判多模态模型时静默丢图。 From bc01f445142b865ca327ca0eee102011715e2095 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 10 Jun 2026 08:53:36 +0800 Subject: [PATCH 15/66] feat(usage): app-aware hero icon and neutral Codex theme - Replace the fixed Zap glyph in the usage hero with the selected app's brand icon via a new AppGlyph component, reusing APP_ICON_MAP (cloneElement scales 14px -> 20px); falls back to Zap for the "all" view. - Recolor the Codex title theme from emerald to neutral gray to match OpenAI's monochrome branding. neutral-500/10 stays visible in both light and dark modes, unlike a flat black tint. --- src/components/usage/UsageHero.tsx | 32 +++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/components/usage/UsageHero.tsx b/src/components/usage/UsageHero.tsx index a3173bf77..3ade773f1 100644 --- a/src/components/usage/UsageHero.tsx +++ b/src/components/usage/UsageHero.tsx @@ -1,8 +1,11 @@ +import { cloneElement, isValidElement } from "react"; import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { Card, CardContent } from "@/components/ui/card"; import { useUsageSummaryByApp } from "@/lib/query/usage"; import { cn } from "@/lib/utils"; +import { APP_ICON_MAP } from "@/config/appConfig"; +import type { AppId } from "@/lib/api/types"; import { Activity, ArrowDownToLine, @@ -47,8 +50,10 @@ const TITLE_THEMES: Record = { iconBg: "bg-amber-500/10", }, codex: { - accent: "text-emerald-600 dark:text-emerald-400", - iconBg: "bg-emerald-500/10", + // OpenAI/Codex 走黑白单色调;中性灰在深浅模式都能透出方块底色, + // 不像纯黑 bg-black/10 在深色背景下会糊掉。 + accent: "text-neutral-700 dark:text-neutral-300", + iconBg: "bg-neutral-500/10", }, gemini: { accent: "text-sky-600 dark:text-sky-400", @@ -130,6 +135,27 @@ function deriveCacheWriteState(appTypes: string[]): CacheWriteState { return "partial"; } +/** + * Hero 标题图标:选中具体应用时显示该应用的品牌图标,"全部"时回退到通用闪电。 + * 复用 APP_ICON_MAP(与侧边栏 / 应用切换器同一套图标),用 cloneElement 放大到 + * 与原闪电一致的 20px;品牌图标自带配色,外层方块仍按 titleTheme 主题色着色。 + */ +function AppGlyph({ + appType, + accentClass, +}: { + appType?: string; + accentClass: string; +}) { + if (appType && appType in APP_ICON_MAP) { + const base = APP_ICON_MAP[appType as AppId].icon; + if (isValidElement<{ size?: number }>(base)) { + return cloneElement(base, { size: 20 }); + } + } + return ; +} + export function UsageHero({ range, appType, @@ -217,7 +243,7 @@ export function UsageHero({ titleTheme.iconBg, )} > - +
From 1ca01bcd10c8d96d9b4b9c0cd71eddfa7d8a0159 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 10 Jun 2026 09:27:16 +0800 Subject: [PATCH 16/66] =?UTF-8?q?feat(usage):=20refresh=20model=20pricing?= =?UTF-8?q?=20seed=20=E2=80=94=20add=20Fable=205=20+=208=20models,=20fix?= =?UTF-8?q?=2028=20prices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full audit of seed_model_pricing against current official vendor pricing. New models: claude-fable-5 (10/50), grok-4.3, step-3.7-flash, mistral-medium-3.5, mistral-small-4, devstral-small-2-2512, magistral-small, qwen3.7-max, qwen3.7-plus. Price fixes (Chinese vendors standardized on official list price, CNY/~7.14): - GLM 4.6/4.7 -> Z.ai official 0.6/2.2/0.11 (were reseller/OpenRouter rates) - Grok 4.20 reasoning/non-reasoning -> 1.25/2.50 (xAI price cut) - MiMo v2.5 / v2.5-pro / v2-pro -> post-2026-05-27 rates + cache - Doubao Seed 2.0 lite corrected + cache-hit prices across the family - Kimi k2.5 output 3.00, MiniMax m2.5 input 0.15, Mistral devstral-2 output 2 - Qwen 3.5/3.6-plus + coder-plus/flash cache_read (official 20%-of-input rule) Each fix updates the seed value (fresh installs) and adds an old->new guard to repair_current_model_pricing (existing DBs; won't clobber user-edited rows). --- src-tauri/src/database/schema.rs | 331 ++++++++++++++++++++++++++++--- 1 file changed, 308 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index ce0f87087..57263be25 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -1205,6 +1205,15 @@ impl Database { /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> { let pricing_data = [ + // Claude Fable 5(Opus 之上的新档) + ( + "claude-fable-5", + "Claude Fable 5", + "10", + "50", + "1.00", + "12.50", + ), // Claude 4.8 系列 ( "claude-opus-4-8", @@ -1571,6 +1580,14 @@ impl Database { "0", ), // StepFun 系列 + ( + "step-3.7-flash", + "Step 3.7 Flash", + "0.19", + "1.13", + "0.04", + "0", + ), ( "step-3.5-flash", "Step 3.5 Flash", @@ -1602,7 +1619,7 @@ impl Database { "Doubao Seed 2.0 Pro", "0.47", "2.37", - "0", + "0.09", "0", ), ( @@ -1610,7 +1627,7 @@ impl Database { "Doubao Seed 2.0 Code", "0.47", "2.37", - "0", + "0.09", "0", ), ( @@ -1618,15 +1635,15 @@ impl Database { "Doubao Seed 2.0 Code Preview", "0.47", "2.37", - "0", + "0.09", "0", ), ( "doubao-seed-2-0-lite", "Doubao Seed 2.0 Lite", - "0.25", - "2", - "0", + "0.08", + "0.50", + "0.017", "0", ), ( @@ -1634,7 +1651,7 @@ impl Database { "Doubao Seed 2.0 Mini", "0.03", "0.31", - "0", + "0.0056", "0", ), // DeepSeek 系列 @@ -1706,7 +1723,7 @@ impl Database { "0.14", "0", ), - ("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"), + ("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"), ("kimi-k2.6", "Kimi K2.6", "0.95", "4.00", "0.16", "0"), // MiniMax 系列 ("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"), @@ -1719,7 +1736,7 @@ impl Database { "0", ), ("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"), - ("minimax-m2.5", "MiniMax M2.5", "0.12", "0.95", "0.03", "0"), + ("minimax-m2.5", "MiniMax M2.5", "0.15", "0.95", "0.03", "0"), ( "minimax-m2.5-lightning", "MiniMax M2.5 Lightning", @@ -1746,8 +1763,8 @@ impl Database { ), ("minimax-m3", "MiniMax M3", "0.60", "2.40", "0.12", "0"), // GLM (智谱) - ("glm-4.7", "GLM-4.7", "0.39", "1.75", "0.04", "0"), - ("glm-4.6", "GLM-4.6", "0.28", "1.11", "0.03", "0"), + ("glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0"), + ("glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0"), ("glm-5", "GLM-5", "1", "3.2", "0.2", "0"), ("glm-5.1", "GLM-5.1", "1.4", "4.4", "0.26", "0"), // MiMo (小米) @@ -1759,12 +1776,28 @@ impl Database { "0.009", "0", ), - ("mimo-v2-pro", "MiMo V2 Pro", "1", "3", "0", "0"), - ("mimo-v2.5", "MiMo V2.5", "0.09", "0.29", "0.009", "0"), - ("mimo-v2.5-pro", "MiMo V2.5 Pro", "1", "3", "0", "0"), + ("mimo-v2-pro", "MiMo V2 Pro", "0.435", "0.87", "0.0036", "0"), + ("mimo-v2.5", "MiMo V2.5", "0.14", "0.29", "0.0028", "0"), + ( + "mimo-v2.5-pro", + "MiMo V2.5 Pro", + "0.435", + "0.87", + "0.0036", + "0", + ), // Qwen 系列 (阿里巴巴) - ("qwen3.6-plus", "Qwen3.6 Plus", "0.325", "1.95", "0", "0"), - ("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0", "0"), + ("qwen3.7-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"), + ("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"), + ( + "qwen3.6-plus", + "Qwen3.6 Plus", + "0.325", + "1.95", + "0.065", + "0", + ), + ("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0.052", "0"), ("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"), ( "qwen3-235b-a22b", @@ -1779,7 +1812,7 @@ impl Database { "Qwen3 Coder Plus", "0.65", "3.25", - "0", + "0.13", "0", ), ( @@ -1803,7 +1836,7 @@ impl Database { "Qwen3 Coder Flash", "0.195", "0.975", - "0", + "0.039", "0", ), ( @@ -1818,19 +1851,20 @@ impl Database { ("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"), ("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"), // Grok 系列 (xAI) + ("grok-4.3", "Grok 4.3", "1.25", "2.50", "0.20", "0"), ( "grok-4.20-0309-reasoning", "Grok 4.20 Reasoning", - "2", - "6", + "1.25", + "2.50", "0.20", "0", ), ( "grok-4.20-0309-non-reasoning", "Grok 4.20", - "2", - "6", + "1.25", + "2.50", "0.20", "0", ), @@ -1863,6 +1897,38 @@ impl Database { ("grok-3", "Grok 3", "3", "15", "0.75", "0"), ("grok-3-mini", "Grok 3 Mini", "0.25", "0.50", "0.075", "0"), // Mistral 系列 + ( + "mistral-medium-3.5", + "Mistral Medium 3.5", + "1.50", + "7.50", + "0", + "0", + ), + ( + "mistral-small-4", + "Mistral Small 4", + "0.10", + "0.30", + "0.01", + "0", + ), + ( + "devstral-small-2-2512", + "Devstral Small 2", + "0.10", + "0.30", + "0.01", + "0", + ), + ( + "magistral-small", + "Magistral Small", + "0.50", + "1.50", + "0", + "0", + ), ("codestral-2508", "Codestral", "0.30", "0.90", "0.03", "0"), ( "devstral-small-1.1", @@ -1872,7 +1938,7 @@ impl Database { "0.01", "0", ), - ("devstral-2-2512", "Devstral 2", "0.40", "0.90", "0.04", "0"), + ("devstral-2-2512", "Devstral 2", "0.40", "2", "0.04", "0"), ( "devstral-medium", "Devstral Medium", @@ -1953,6 +2019,225 @@ impl Database { fn repair_current_model_pricing(conn: &Connection) -> Result<(), AppError> { let pricing_fixes = [ + // 2026-06-10 全量核价(厂商官方 list 价;CNY 按 ~7.14 折算) + // GLM 4.6/4.7:旧值是中转/OpenRouter 折扣价,统一到 Z.ai 官方(与 glm-5/5.1 一致) + ( + "glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0", "0.39", "1.75", "0.04", "0", + ), + ( + "glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0", "0.28", "1.11", "0.03", "0", + ), + // Grok 4.20:xAI 已降价 2/6 → 1.25/2.50 + ( + "grok-4.20-0309-reasoning", + "Grok 4.20 Reasoning", + "1.25", + "2.50", + "0.20", + "0", + "2", + "6", + "0.20", + "0", + ), + ( + "grok-4.20-0309-non-reasoning", + "Grok 4.20", + "1.25", + "2.50", + "0.20", + "0", + "2", + "6", + "0.20", + "0", + ), + // Kimi K2.5 官方 output 3.00 + ( + "kimi-k2.5", + "Kimi K2.5", + "0.60", + "3.00", + "0.10", + "0", + "0.60", + "2.50", + "0.10", + "0", + ), + // MiniMax M2.5 input 0.15 + ( + "minimax-m2.5", + "MiniMax M2.5", + "0.15", + "0.95", + "0.03", + "0", + "0.12", + "0.95", + "0.03", + "0", + ), + // Mistral Devstral 2 output 0.90 → 2(与同表 devstral-medium 一致) + ( + "devstral-2-2512", + "Devstral 2", + "0.40", + "2", + "0.04", + "0", + "0.40", + "0.90", + "0.04", + "0", + ), + // Doubao Seed 2.0:lite 旧价贵 3-4 倍 + 全系补 cache 命中价 + ( + "doubao-seed-2-0-lite", + "Doubao Seed 2.0 Lite", + "0.08", + "0.50", + "0.017", + "0", + "0.25", + "2", + "0", + "0", + ), + ( + "doubao-seed-2-0-pro", + "Doubao Seed 2.0 Pro", + "0.47", + "2.37", + "0.09", + "0", + "0.47", + "2.37", + "0", + "0", + ), + ( + "doubao-seed-2-0-code", + "Doubao Seed 2.0 Code", + "0.47", + "2.37", + "0.09", + "0", + "0.47", + "2.37", + "0", + "0", + ), + ( + "doubao-seed-2-0-code-preview-latest", + "Doubao Seed 2.0 Code Preview", + "0.47", + "2.37", + "0.09", + "0", + "0.47", + "2.37", + "0", + "0", + ), + ( + "doubao-seed-2-0-mini", + "Doubao Seed 2.0 Mini", + "0.03", + "0.31", + "0.0056", + "0", + "0.03", + "0.31", + "0", + "0", + ), + // MiMo:5/27 永久降价,旧值是旧价 + ( + "mimo-v2-pro", + "MiMo V2 Pro", + "0.435", + "0.87", + "0.0036", + "0", + "1", + "3", + "0", + "0", + ), + ( + "mimo-v2.5", + "MiMo V2.5", + "0.14", + "0.29", + "0.0028", + "0", + "0.09", + "0.29", + "0.009", + "0", + ), + ( + "mimo-v2.5-pro", + "MiMo V2.5 Pro", + "0.435", + "0.87", + "0.0036", + "0", + "1", + "3", + "0", + "0", + ), + // Qwen:官方"隐式缓存 = 输入 20%"补 cache 命中价 + ( + "qwen3.6-plus", + "Qwen3.6 Plus", + "0.325", + "1.95", + "0.065", + "0", + "0.325", + "1.95", + "0", + "0", + ), + ( + "qwen3.5-plus", + "Qwen3.5 Plus", + "0.26", + "1.56", + "0.052", + "0", + "0.26", + "1.56", + "0", + "0", + ), + ( + "qwen3-coder-plus", + "Qwen3 Coder Plus", + "0.65", + "3.25", + "0.13", + "0", + "0.65", + "3.25", + "0", + "0", + ), + ( + "qwen3-coder-flash", + "Qwen3 Coder Flash", + "0.195", + "0.975", + "0.039", + "0", + "0.195", + "0.975", + "0", + "0", + ), ( "deepseek-v4-flash", "DeepSeek V4 Flash", From 65d6929993b735ed9ca7e2838ae8dcbd9e89516e Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 10 Jun 2026 09:46:41 +0800 Subject: [PATCH 17/66] fix(coding-plan): classify Zhipu quota windows by unit field instead of reset-time order (#3036) The Zhipu quota API returns two TOKENS_LIMIT entries whose identity was inferred by sorting nextResetTime ascending (nearest = five_hour). In the last hours of each weekly cycle the weekly window resets sooner than the current 5-hour session window, so the two buckets were swapped exactly when users check their weekly quota most. Classify by the explicit unit field instead (3 = hour window -> five_hour, 6 = week window -> weekly_limit; same shape on bigmodel.cn and api.z.ai, weekly observed with number 7 and 1 so only unit is matched), falling back to the old reset-time heuristic when the field is missing. --- src-tauri/src/services/coding_plan.rs | 167 +++++++++++++++++++++++--- 1 file changed, 149 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index 39c3309f2..bb547f207 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -190,14 +190,46 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { // ── 智谱 GLM ──────────────────────────────────────────────── +/// 智谱 TOKENS_LIMIT 条目按 `unit` 字段的显式窗口分类。 +enum ZhipuWindow { + FiveHour, + Weekly, +} + +/// 按 `unit` 字段判定 TOKENS_LIMIT 条目所属窗口。 +/// +/// 实测形态(bigmodel.cn 与 z.ai 共用同一后端,字段一致): +/// - `unit: 3, number: 5` → 5 小时滚动窗口(老/新套餐均有) +/// - `unit: 6, number: 7` 与 `unit: 6, number: 1` → 每周窗口(两种取值都被 +/// 实测过,故只锚定 `unit`、不绑 `number`) +/// +/// `unit` 缺失或值不认识时返回 None,由调用方走重置时间启发式兜底。 +fn classify_zhipu_window(item: &serde_json::Value) -> Option { + match item.get("unit").and_then(|v| v.as_i64()) { + Some(3) => Some(ZhipuWindow::FiveHour), + Some(6) => Some(ZhipuWindow::Weekly), + _ => None, + } +} + /// 把智谱 `data` 里的 `limits[]` 解析成 tier 列表。 /// -/// 双桶响应中,5 小时桶在 0% 等状态下可能没有 `nextResetTime`; -/// 这类无 reset 条目应优先归为五小时桶。其余条目按 `nextResetTime` 升序。 +/// 分类优先级: +/// 1. 显式字段:`unit` 标识窗口类型(见 [`classify_zhipu_window`])。不能按 +/// `nextResetTime` 排序代替——周期末尾每周窗口会比 5 小时窗口更早重置 +/// (issue #3036),时间排序在该场景必然把两桶标反。 +/// 2. 兜底启发式(`unit` 缺失或不识别):无 `nextResetTime` 的条目优先归 +/// five_hour(5 小时桶在 0% 等状态下可能没有 reset),其余按 reset 升序 +/// 依次填入仍空缺的槽位。 +/// /// 老套餐(2026-02-12 前订阅)只回 1 条 /// `TOKENS_LIMIT`,自然降级为仅展示 `five_hour`;新套餐回 2 条。 fn parse_zhipu_token_tiers(data: &serde_json::Value) -> Vec { - let mut token_limits: Vec<(Option, f64, Option)> = Vec::new(); + type Entry = (Option, f64, Option); + let mut five_hour: Option = None; + let mut weekly: Option = None; + let mut unclassified: Vec = Vec::new(); + if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) { for limit_item in limits { let limit_type = limit_item @@ -214,29 +246,38 @@ fn parse_zhipu_token_tiers(data: &serde_json::Value) -> Vec { .unwrap_or(0.0); let reset_ms = limit_item.get("nextResetTime").and_then(|v| v.as_i64()); let reset_iso = reset_ms.and_then(millis_to_iso8601); - token_limits.push((reset_ms, percentage, reset_iso)); + let entry = (reset_ms, percentage, reset_iso); + match classify_zhipu_window(limit_item) { + Some(ZhipuWindow::FiveHour) if five_hour.is_none() => five_hour = Some(entry), + Some(ZhipuWindow::Weekly) if weekly.is_none() => weekly = Some(entry), + _ => unclassified.push(entry), + } } } - token_limits.sort_by_key(|(reset, _, _)| (reset.is_some(), reset.unwrap_or(i64::MIN))); - token_limits - .into_iter() - .enumerate() - .filter_map(|(idx, (_, percentage, resets_at))| { - let name = match idx { - 0 => TIER_FIVE_HOUR, - 1 => TIER_WEEKLY_LIMIT, - _ => return None, // 智谱当前最多两条 TOKENS_LIMIT,多余的忽略 - }; - Some(QuotaTier { + unclassified.sort_by_key(|(reset, _, _)| (reset.is_some(), reset.unwrap_or(i64::MIN))); + for entry in unclassified { + if five_hour.is_none() { + five_hour = Some(entry); + } else if weekly.is_none() { + weekly = Some(entry); + } + // 智谱当前最多两条 TOKENS_LIMIT,多余的忽略 + } + + let mut tiers = Vec::new(); + for (name, slot) in [(TIER_FIVE_HOUR, five_hour), (TIER_WEEKLY_LIMIT, weekly)] { + if let Some((_, percentage, resets_at)) = slot { + tiers.push(QuotaTier { name: name.to_string(), utilization: percentage, resets_at, used_value_usd: None, max_value_usd: None, - }) - }) - .collect() + }); + } + } + tiers } /// Resolve the Zhipu quota endpoint from the user's configured `base_url`. @@ -779,6 +820,96 @@ mod tests { assert_eq!(tiers[1].utilization, 150.0); } + #[test] + fn zhipu_unit_field_overrides_reset_order_when_weekly_resets_sooner() { + // 真实案例(issue #3036,2026-06-10 再次复现):每周周期末尾,周桶比 + // 5 小时桶更早重置。官网真实值:5h 用 1%(约 5h 后重置)、每周用 42% + // (约 1h 后重置)。旧逻辑按 reset 升序必然标反,unit 字段须优先。 + let data = json!({ + "limits": [ + { "type": "TOKENS_LIMIT", "unit": 6, "number": 7, "percentage": 42.0, "nextResetTime": 1_000_003_600_000_i64 }, + { "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 1.0, "nextResetTime": 1_000_018_000_000_i64 } + ] + }); + let tiers = parse_zhipu_token_tiers(&data); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].name, TIER_FIVE_HOUR); + assert_eq!(tiers[0].utilization, 1.0); + assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); + assert_eq!(tiers[1].utilization, 42.0); + } + + #[test] + fn zhipu_weekly_unit_six_number_one_variant() { + // z.ai 也观测过 (unit:6, number:1) 表示每周窗口(按"1 周"计), + // 分类只看 unit,number 取值不影响。 + let data = json!({ + "limits": [ + { "type": "TOKENS_LIMIT", "unit": 6, "number": 1, "percentage": 30.0, "nextResetTime": 1_000_000_000_000_i64 }, + { "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 10.0, "nextResetTime": 2_000_000_000_000_i64 } + ] + }); + let tiers = parse_zhipu_token_tiers(&data); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].name, TIER_FIVE_HOUR); + assert_eq!(tiers[0].utilization, 10.0); + assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); + assert_eq!(tiers[1].utilization, 30.0); + } + + #[test] + fn zhipu_partial_unit_fields_fill_remaining_slot() { + // 只有周桶带 unit 时,缺 unit 的另一条应填入剩下的 five_hour 槽位, + // 即便它的 reset 更晚——显式分类结果不受时间排序干扰。 + let data = json!({ + "limits": [ + { "type": "TOKENS_LIMIT", "unit": 6, "number": 7, "percentage": 42.0, "nextResetTime": 1_000_000_000_000_i64 }, + { "type": "TOKENS_LIMIT", "percentage": 1.0, "nextResetTime": 2_000_000_000_000_i64 } + ] + }); + let tiers = parse_zhipu_token_tiers(&data); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].name, TIER_FIVE_HOUR); + assert_eq!(tiers[0].utilization, 1.0); + assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); + assert_eq!(tiers[1].utilization, 42.0); + } + + #[test] + fn zhipu_unknown_unit_values_fall_back_to_reset_order() { + // 未识别的 unit 枚举值不猜语义,整体回落旧的重置时间启发式。 + let data = json!({ + "limits": [ + { "type": "TOKENS_LIMIT", "unit": 9, "percentage": 44.0, "nextResetTime": 1_000_000_000_000_i64 }, + { "type": "TOKENS_LIMIT", "unit": 9, "percentage": 53.0, "nextResetTime": 2_000_000_000_000_i64 } + ] + }); + let tiers = parse_zhipu_token_tiers(&data); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].name, TIER_FIVE_HOUR); + assert_eq!(tiers[0].utilization, 44.0); + assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); + assert_eq!(tiers[1].utilization, 53.0); + } + + #[test] + fn zhipu_duplicate_unit_classification_fills_other_slot() { + // 防御性:两条都标成 5 小时窗(上游异常)时,第一条占 five_hour, + // 第二条降级走兜底填入 weekly,保证不丢数据也不 panic。 + let data = json!({ + "limits": [ + { "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 10.0, "nextResetTime": 1_000_000_000_000_i64 }, + { "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 20.0, "nextResetTime": 2_000_000_000_000_i64 } + ] + }); + let tiers = parse_zhipu_token_tiers(&data); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].name, TIER_FIVE_HOUR); + assert_eq!(tiers[0].utilization, 10.0); + assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); + assert_eq!(tiers[1].utilization, 20.0); + } + #[test] fn zhipu_more_than_two_token_limits_keeps_first_two() { // 防御性:智谱当前最多两条 TOKENS_LIMIT,若上游意外增加第三条应被丢弃,避免命名空缺。 From 42828566831b43d556ea183490ef409632f3ece3 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 10 Jun 2026 11:56:43 +0800 Subject: [PATCH 18/66] feat(usage): persist pricing basis and takeover dimensions in storage (schema v11) - proxy_request_logs: add pricing_model column recording the basis actually used at write time (NULL = pre-v11 rows, '' = unpriced error rows) - cost backfill recomputes strictly by the persisted basis; the request_model fallback now only applies to placeholder models, so real-but-unpriced takeover rows stay at zero cost until pricing is added instead of being permanently frozen at the alias's price - backfill_missing_usage_costs_for_model can locate rows by pricing_model - usage_daily_rollups: rebuild with request_model + pricing_model in the primary key so the alias-to-real-model mapping and the pricing basis survive the 30-day prune; legacy rows migrate with '' - rollup_and_prune backfills costs before pruning: prune is irreversible and used to run before the startup backfill, permanently booking then-unpriced rows as zero - get_model_stats groups by the effective pricing model (COALESCE(NULLIF(pricing_model,''), model)) so costs aggregate under the model whose prices produced them; response-mode behavior unchanged --- src-tauri/src/database/dao/usage_rollup.rs | 161 ++++++++++++++++++- src-tauri/src/database/mod.rs | 2 +- src-tauri/src/database/schema.rs | 72 ++++++++- src-tauri/src/database/tests.rs | 81 ++++++++++ src-tauri/src/services/usage_stats.rs | 174 +++++++++++++++++++-- 5 files changed, 472 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/database/dao/usage_rollup.rs b/src-tauri/src/database/dao/usage_rollup.rs index d4b7b1958..3c2bc8994 100644 --- a/src-tauri/src/database/dao/usage_rollup.rs +++ b/src-tauri/src/database/dao/usage_rollup.rs @@ -75,6 +75,15 @@ impl Database { return Ok(0); } + // 剪枝是不可逆的:明细一旦汇总删除,0 成本行就永远失去按 pricing_model + // 补价重算的机会(启动序列里 seed 定价先于 rollup、但启动回填在 rollup + // 之后;周期任务同理)。所以剪枝前先尽力回填一次。失败仅告警不阻断—— + // 否则一行损坏的定价数据会永久卡死日志清理。 + // 注意必须在 SAVEPOINT 之外调用:回填内部自己开顶层事务。 + if let Err(e) = Self::backfill_missing_usage_costs_on_conn(&conn, None) { + log::warn!("Pre-prune cost backfill failed, pruning anyway: {e}"); + } + // Use a savepoint for atomicity conn.execute("SAVEPOINT rollup_prune;", []) .map_err(|e| AppError::Database(e.to_string()))?; @@ -106,15 +115,18 @@ impl Database { fn do_rollup_and_prune(conn: &rusqlite::Connection, cutoff: i64) -> Result { // Aggregate old logs, merging with any pre-existing rollup rows via LEFT JOIN. let effective_filter = effective_usage_log_filter("l"); + // request_model 维度保留路由接管的「客户端别名 → 真实模型」映射, + // pricing_model 维度保留写入时的计价基准(request 计价模式下与 model 分叉); + // 明细行的这两列可能为 NULL(历史/手工数据),归一为 ''。 let aggregation_sql = format!( "INSERT OR REPLACE INTO usage_daily_rollups - (date, app_type, provider_id, model, + (date, app_type, provider_id, model, request_model, pricing_model, request_count, success_count, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms) SELECT - d, a, p, m, + d, a, p, m, rm, pm, COALESCE(old.request_count, 0) + new_req, COALESCE(old.success_count, 0) + new_succ, COALESCE(old.input_tokens, 0) + new_in, @@ -131,6 +143,8 @@ impl Database { SELECT date(l.created_at, 'unixepoch', 'localtime') as d, l.app_type as a, l.provider_id as p, l.model as m, + COALESCE(l.request_model, '') as rm, + COALESCE(l.pricing_model, '') as pm, COUNT(*) as new_req, SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END) as new_succ, COALESCE(SUM(l.input_tokens), 0) as new_in, @@ -141,11 +155,12 @@ impl Database { COALESCE(AVG(l.latency_ms), 0) as new_lat FROM proxy_request_logs l WHERE l.created_at < ?1 AND {effective_filter} - GROUP BY d, a, p, m + GROUP BY d, a, p, m, rm, pm ) agg LEFT JOIN usage_daily_rollups old ON old.date = agg.d AND old.app_type = agg.a - AND old.provider_id = agg.p AND old.model = agg.m" + AND old.provider_id = agg.p AND old.model = agg.m + AND old.request_model = agg.rm AND old.pricing_model = agg.pm" ); conn.execute(&aggregation_sql, [cutoff]) @@ -325,6 +340,144 @@ mod tests { Ok(()) } + #[test] + fn test_rollup_preserves_request_model_dimension() -> Result<(), AppError> { + let db = Database::memory()?; + let now = chrono::Utc::now().timestamp(); + let old_ts = now - 40 * 86400; + + { + let conn = crate::database::lock_conn!(db.conn); + // 路由接管行:model 是真实上游模型,request_model 是客户端别名。 + // 同 model 下两个不同别名必须各自成行,prune 后映射关系仍可审计。 + for (i, request_model) in [ + ("a", "claude-sonnet-4-6"), + ("b", "claude-sonnet-4-6"), + ("c", "claude-haiku-4-5"), + ] { + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?1, 'p1', 'claude', 'kimi-k2', ?2, 100, 50, '0.01', 100, 200, ?3)", + rusqlite::params![format!("takeover-{i}"), request_model, old_ts], + )?; + } + } + + let deleted = db.rollup_and_prune(30)?; + assert_eq!(deleted, 3); + + let conn = crate::database::lock_conn!(db.conn); + let mut stmt = conn.prepare( + "SELECT request_model, request_count FROM usage_daily_rollups + WHERE model = 'kimi-k2' ORDER BY request_model", + )?; + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })? + .collect::, _>>()?; + + assert_eq!( + rows, + vec![ + ("claude-haiku-4-5".to_string(), 1), + ("claude-sonnet-4-6".to_string(), 2), + ] + ); + Ok(()) + } + + #[test] + fn test_rollup_preserves_pricing_model_dimension() -> Result<(), AppError> { + let db = Database::memory()?; + let now = chrono::Utc::now().timestamp(); + let old_ts = now - 40 * 86400; + + { + let conn = crate::database::lock_conn!(db.conn); + // request 计价模式下 pricing_model 与 model 分叉,必须各自成行 + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, pricing_model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES ('pm-a', 'p1', 'claude', 'kimi-k2', 'claude-sonnet-4-6', 'kimi-k2', + 100, 50, '0.01', 100, 200, ?1)", + rusqlite::params![old_ts], + )?; + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, pricing_model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES ('pm-b', 'p1', 'claude', 'kimi-k2', 'claude-sonnet-4-6', 'claude-sonnet-4-6', + 100, 50, '0.30', 100, 200, ?1)", + rusqlite::params![old_ts], + )?; + } + + let deleted = db.rollup_and_prune(30)?; + assert_eq!(deleted, 2); + + let conn = crate::database::lock_conn!(db.conn); + let mut stmt = conn.prepare( + "SELECT pricing_model, total_cost_usd FROM usage_daily_rollups + WHERE model = 'kimi-k2' ORDER BY pricing_model", + )?; + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? + .collect::, _>>()?; + + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].0, "claude-sonnet-4-6"); + assert_eq!(rows[1].0, "kimi-k2"); + Ok(()) + } + + #[test] + fn test_rollup_backfills_costs_before_pruning() -> Result<(), AppError> { + let db = Database::memory()?; + let now = chrono::Utc::now().timestamp(); + let old_ts = now - 40 * 86400; + + { + let conn = crate::database::lock_conn!(db.conn); + // >30 天的 0 成本行:pricing_model(gpt-5.5)在 seed 定价表中有价。 + // 剪枝是不可逆的,rollup 必须先回填再汇总,否则按 0 永久入账。 + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, pricing_model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES ('prune-backfill', 'p1', 'codex', 'gpt-5.5', 'gpt-5.5', 'gpt-5.5', + 1000000, 0, '0', 100, 200, ?1)", + rusqlite::params![old_ts], + )?; + } + + let deleted = db.rollup_and_prune(30)?; + assert_eq!(deleted, 1); + + let conn = crate::database::lock_conn!(db.conn); + let total_cost: f64 = conn.query_row( + "SELECT CAST(total_cost_usd AS REAL) FROM usage_daily_rollups + WHERE model = 'gpt-5.5'", + [], + |row| row.get(0), + )?; + // gpt-5.5 input $5/M × 1M tokens,回填后再汇总 + assert!( + (total_cost - 5.0).abs() < 1e-6, + "expected backfilled cost 5.0, got {total_cost}" + ); + Ok(()) + } + #[test] fn test_rollup_noop_when_no_old_data() -> Result<(), AppError> { let db = Database::memory()?; diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index de972e2a5..888b86768 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -49,7 +49,7 @@ use std::sync::Mutex; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 10; +pub(crate) const SCHEMA_VERSION: i32 = 11; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 57263be25..242860fe4 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -181,9 +181,12 @@ impl Database { )", []).map_err(|e| AppError::Database(e.to_string()))?; // 10. Proxy Request Logs 表 + // pricing_model = 写入时实际用于计价的模型名(pricing_model_source 解析结果), + // 回填按它重算;NULL 表示 v11 之前的历史行,'' 表示未计价的错误行。 conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs ( request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL, request_model TEXT, + pricing_model TEXT, input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0, input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0', @@ -255,12 +258,17 @@ impl Database { .map_err(|e| AppError::Database(e.to_string()))?; // 17. Usage Daily Rollups 表 (日聚合统计) + // request_model 保留路由接管的「客户端别名 → 真实模型」映射维度, + // pricing_model 保留写入时的计价基准(request 计价模式下与 model 分叉), + // 否则明细被 prune 后接管计费不可审计;历史行迁移时填 ''(未知)。 conn.execute( "CREATE TABLE IF NOT EXISTS usage_daily_rollups ( date TEXT NOT NULL, app_type TEXT NOT NULL, provider_id TEXT NOT NULL, model TEXT NOT NULL, + request_model TEXT NOT NULL DEFAULT '', + pricing_model TEXT NOT NULL DEFAULT '', request_count INTEGER NOT NULL DEFAULT 0, success_count INTEGER NOT NULL DEFAULT 0, input_tokens INTEGER NOT NULL DEFAULT 0, @@ -269,7 +277,7 @@ impl Database { cache_creation_tokens INTEGER NOT NULL DEFAULT 0, total_cost_usd TEXT NOT NULL DEFAULT '0', avg_latency_ms INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (date, app_type, provider_id, model) + PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model) )", [], ) @@ -431,6 +439,11 @@ impl Database { Self::migrate_v9_to_v10(conn)?; Self::set_user_version(conn, 10)?; } + 10 => { + log::info!("迁移数据库从 v10 到 v11(usage_daily_rollups 保留 request_model 维度)"); + Self::migrate_v10_to_v11(conn)?; + Self::set_user_version(conn, 11)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -1200,6 +1213,63 @@ impl Database { Ok(()) } + /// v10 -> v11:usage_daily_rollups 增加 request_model 维度(进入主键), + /// proxy_request_logs 增加 pricing_model 列(写入时的计价基准,回填依据)。 + /// + /// 路由接管下 model(真实上游模型)≠ request_model(客户端别名), + /// 旧 rollup 只按 model 聚合,明细 prune 后映射关系永久丢失、计费不可审计。 + /// SQLite 改主键必须重建表;历史行的 request_model 已不可知,填 ''。 + fn migrate_v10_to_v11(conn: &Connection) -> Result<(), AppError> { + // proxy_request_logs.pricing_model:NULL = v11 前的历史行(回填走 + // model → 占位符回退 request_model 的旧逻辑),'' = 未计价的错误行 + if Self::table_exists(conn, "proxy_request_logs")? { + Self::add_column_if_missing(conn, "proxy_request_logs", "pricing_model", "TEXT")?; + } + + if !Self::table_exists(conn, "usage_daily_rollups")? { + log::info!("v10 -> v11:usage_daily_rollups 不存在,跳过重建"); + return Ok(()); + } + + conn.execute_batch( + "ALTER TABLE usage_daily_rollups RENAME TO usage_daily_rollups_v10; + CREATE TABLE usage_daily_rollups ( + date TEXT NOT NULL, + app_type TEXT NOT NULL, + provider_id TEXT NOT NULL, + model TEXT NOT NULL, + request_model TEXT NOT NULL DEFAULT '', + pricing_model TEXT NOT NULL DEFAULT '', + request_count INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + total_cost_usd TEXT NOT NULL DEFAULT '0', + avg_latency_ms INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model) + ); + INSERT INTO usage_daily_rollups + (date, app_type, provider_id, model, request_model, pricing_model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms) + SELECT date, app_type, provider_id, model, '', '', + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + FROM usage_daily_rollups_v10; + DROP TABLE usage_daily_rollups_v10;", + ) + .map_err(|e| { + AppError::Database(format!("v10 -> v11 重建 usage_daily_rollups 失败: {e}")) + })?; + + log::info!( + "v10 -> v11 迁移完成:usage_daily_rollups 已保留 request_model/pricing_model 维度" + ); + Ok(()) + } + /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 3695210e5..c979ee731 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -345,6 +345,87 @@ fn schema_migration_v4_adds_pricing_model_columns() { ); } +#[test] +fn migration_v10_to_v11_rebuilds_rollups_with_request_model_dimension() { + let conn = Connection::open_in_memory().expect("open memory db"); + + // 模拟 v10 形状的 rollup 表(主键不含 request_model)+ 一行历史聚合数据, + // 以及 v10 形状的明细表(无 pricing_model 列) + conn.execute_batch( + r#" + CREATE TABLE proxy_request_logs ( + request_id TEXT PRIMARY KEY, + model TEXT NOT NULL, + request_model TEXT + ); + CREATE TABLE usage_daily_rollups ( + date TEXT NOT NULL, + app_type TEXT NOT NULL, + provider_id TEXT NOT NULL, + model TEXT NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + total_cost_usd TEXT NOT NULL DEFAULT '0', + avg_latency_ms INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, app_type, provider_id, model) + ); + INSERT INTO usage_daily_rollups + (date, app_type, provider_id, model, request_count, success_count, + input_tokens, output_tokens, total_cost_usd, avg_latency_ms) + VALUES ('2026-05-01', 'claude', 'p1', 'kimi-k2', 7, 7, 1000, 500, '0.07', 120); + "#, + ) + .expect("seed v10 rollup table"); + + Database::set_user_version(&conn, 10).expect("set user_version=10"); + Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations"); + + // 新列存在且 NOT NULL DEFAULT '' + let request_model = get_column_info(&conn, "usage_daily_rollups", "request_model"); + assert_eq!(request_model.r#type, "TEXT"); + assert_eq!(request_model.notnull, 1); + let rollup_pricing_model = get_column_info(&conn, "usage_daily_rollups", "pricing_model"); + assert_eq!(rollup_pricing_model.r#type, "TEXT"); + assert_eq!(rollup_pricing_model.notnull, 1); + + // 明细表补上 pricing_model 列(可空,历史行 NULL) + let pricing_model = get_column_info(&conn, "proxy_request_logs", "pricing_model"); + assert_eq!(pricing_model.r#type, "TEXT"); + assert_eq!(pricing_model.notnull, 0); + + // 历史行保留,request_model 填 ''(未知) + let (rm, count, input, cost): (String, i64, i64, String) = conn + .query_row( + "SELECT request_model, request_count, input_tokens, total_cost_usd + FROM usage_daily_rollups WHERE model = 'kimi-k2'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .expect("migrated row"); + assert_eq!(rm, ""); + assert_eq!(count, 7); + assert_eq!(input, 1000); + assert_eq!(cost, "0.07"); + + // 主键包含 request_model:同 model 不同别名可共存 + conn.execute( + "INSERT INTO usage_daily_rollups + (date, app_type, provider_id, model, request_model, request_count) + VALUES ('2026-05-01', 'claude', 'p1', 'kimi-k2', 'claude-sonnet-4-6', 1)", + [], + ) + .expect("insert row with same model but different request_model"); + + assert_eq!( + Database::get_user_version(&conn).expect("version after migration"), + SCHEMA_VERSION + ); +} + #[test] fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() { let conn = Connection::open_in_memory().expect("open memory db"); diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index b3f2c0290..6002881b7 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -149,17 +149,20 @@ pub struct RequestLogDetail { pub created_at: i64, #[serde(skip_serializing_if = "Option::is_none")] pub data_source: Option, + /// 写入时实际用于计价的模型名。None = v11 前的历史行,"" = 未计价的错误行。 + #[serde(skip_serializing_if = "Option::is_none")] + pub pricing_model: Option, } -/// 把 24 列的查询结果映射为 `RequestLogDetail`。 +/// 把 25 列的查询结果映射为 `RequestLogDetail`。 /// -/// 调用方的 SELECT **必须**按以下顺序返回 24 列: +/// 调用方的 SELECT **必须**按以下顺序返回 25 列: /// `request_id, provider_id, provider_name, app_type, model, request_model, /// cost_multiplier, input_tokens, output_tokens, cache_read_tokens, /// cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd, /// cache_creation_cost_usd, total_cost_usd, is_streaming, latency_ms, /// first_token_ms, duration_ms, status_code, error_message, created_at, -/// data_source` +/// data_source, pricing_model` /// /// 不需要 provider_name 时(如 backfill)SELECT `NULL AS provider_name` 占位即可。 fn row_to_request_log_detail(row: &rusqlite::Row<'_>) -> rusqlite::Result { @@ -190,6 +193,7 @@ fn row_to_request_log_detail(row: &rusqlite::Row<'_>) -> rusqlite::Result, ) -> Result { @@ -1480,7 +1489,7 @@ impl Database { input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, is_streaming, latency_ms, first_token_ms, duration_ms, status_code, error_message, created_at, - data_source + data_source, pricing_model FROM proxy_request_logs WHERE CAST(total_cost_usd AS REAL) <= 0 AND (input_tokens > 0 OR output_tokens > 0 @@ -1489,7 +1498,9 @@ impl Database { let mut logs = { match only_model_id { Some(model) => { - let sql = format!("{BASE_SQL} AND (model = ?1 OR request_model = ?1)"); + let sql = format!( + "{BASE_SQL} AND (model = ?1 OR request_model = ?1 OR pricing_model = ?1)" + ); let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map([model], row_to_request_log_detail)?; rows.collect::, _>>()? @@ -1647,10 +1658,32 @@ impl Database { cache: &mut HashMap, log: &RequestLogDetail, ) -> Result, AppError> { + // 写入时的计价基准已落库(v11+):回填只按它重算,找不到就保持 0 成本 + // 等补价。不能换用 model/request_model 猜——路由接管 + request 计价模式下 + // 三者可能各不相同(model=上游回显、request_model=客户端别名、 + // pricing_model=实际出站模型),换基准会按错误价格永久固化。 + // 占位符("" = 未计价错误行 / "unknown")视同缺失,走历史行逻辑。 + if let Some(pricing_model) = log + .pricing_model + .as_deref() + .filter(|pm| !is_placeholder_pricing_model(pm)) + { + return Self::get_model_pricing_cached(conn, cache, pricing_model); + } + if let Some(pricing) = Self::get_model_pricing_cached(conn, cache, &log.model)? { return Ok(Some(pricing)); } + // 仅当 model 列是占位符(解析失败留下的 ""/"unknown" 等)时才回退到 + // request_model 定价。model 是真实模型名但缺定价时必须保持 0 成本等待 + // 补价:路由接管下 request_model 是客户端别名(如 claude-sonnet-4-6), + // 按别名回填会把真实上游模型的 tokens 按错误价格永久固化(行一旦有成本 + // 就不再进入回填范围)。 + if !is_placeholder_pricing_model(&log.model) { + return Ok(None); + } + let Some(request_model) = log.request_model.as_deref() else { return Ok(None); }; @@ -2190,6 +2223,123 @@ mod tests { Ok(()) } + #[test] + fn test_backfill_skips_request_model_fallback_for_real_unpriced_model() -> Result<(), AppError> + { + let db = Database::memory()?; + + { + let conn = lock_conn!(db.conn); + // 路由接管场景:model 是上游回显的真实模型(缺定价),request_model + // 是客户端别名(有定价)。回填不得按别名定价,必须保持 0 成本等待补价。 + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, + total_cost_usd, latency_ms, status_code, created_at, data_source + ) VALUES ( + 'takeover-unpriced-model', 'provider-1', 'claude', + 'takeover-real-model-unpriced', 'claude-sonnet-4-6', + 1000000, 0, 0, 0, + '0', '0', '0', '0', + '0', 100, 200, 1000, 'proxy' + )", + [], + )?; + } + + // request_model(claude-sonnet-4-6)有定价,但 model 是真实模型名:不得回退 + assert_eq!(db.backfill_missing_usage_costs()?, 0); + + { + let conn = lock_conn!(db.conn); + let total_cost: String = conn.query_row( + "SELECT total_cost_usd + FROM proxy_request_logs WHERE request_id = 'takeover-unpriced-model'", + [], + |row| row.get(0), + )?; + assert_eq!(total_cost, "0"); + + // 补上真实模型定价后,回填必须按真实模型价格修复(0 成本行未被污染固化) + conn.execute( + "INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million) + VALUES ('takeover-real-model-unpriced', 'Takeover Real Model', '0.6', '2.5')", + [], + )?; + } + + assert_eq!(db.backfill_missing_usage_costs()?, 1); + + let conn = lock_conn!(db.conn); + let total_cost: String = conn.query_row( + "SELECT total_cost_usd + FROM proxy_request_logs WHERE request_id = 'takeover-unpriced-model'", + [], + |row| row.get(0), + )?; + assert_eq!(total_cost, "0.600000"); + + Ok(()) + } + + #[test] + fn test_backfill_uses_persisted_pricing_model() -> Result<(), AppError> { + let db = Database::memory()?; + + { + let conn = lock_conn!(db.conn); + // request 计价模式 + 接管:写入时锚定出站模型 kimi-k2-novel(当时缺价), + // 但上游回显了别名 → model/request_model 都是 claude-sonnet-4-6(有定价)。 + // 回填必须按落库的 pricing_model 重算,不得换用 model 列的别名价格。 + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, pricing_model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, + total_cost_usd, latency_ms, status_code, created_at, data_source + ) VALUES ( + 'persisted-pricing-model', 'provider-1', 'claude', + 'claude-sonnet-4-6', 'claude-sonnet-4-6', 'kimi-k2-novel', + 1000000, 0, 0, 0, + '0', '0', '0', '0', + '0', 100, 200, 1000, 'proxy' + )", + [], + )?; + } + + // pricing_model(kimi-k2-novel)缺价:不得回退到 model 列的别名价格 + assert_eq!(db.backfill_missing_usage_costs()?, 0); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million) + VALUES ('kimi-k2-novel', 'Kimi K2 Novel', '0.6', '2.5')", + [], + )?; + } + + // 按 pricing_model 也能定位到该行(model/request_model 都不是 kimi-k2-novel) + assert_eq!( + db.backfill_missing_usage_costs_for_model("kimi-k2-novel")?, + 1 + ); + + let conn = lock_conn!(db.conn); + let total_cost: String = conn.query_row( + "SELECT total_cost_usd + FROM proxy_request_logs WHERE request_id = 'persisted-pricing-model'", + [], + |row| row.get(0), + )?; + assert_eq!(total_cost, "0.600000"); + + Ok(()) + } + #[test] fn test_backfill_missing_usage_costs_keeps_claude_fresh_input() -> Result<(), AppError> { let db = Database::memory()?; From feea81e5bb63168c65eceb62f0a9d3a5b5eaef91 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 10 Jun 2026 11:56:54 +0800 Subject: [PATCH 19/66] fix(proxy): bill route-takeover traffic by the real upstream model The model mapped for takeover (env mapping, Claude Desktop routes, Copilot normalization, Codex chat override) was discarded inside the forwarder, so usage attribution depended entirely on the upstream echoing it back. When the upstream omitted the model or mirrored the client alias, kimi/glm tokens were recorded and priced as claude-* (roughly 5-25x overstatement). - capture the final outbound model in forward(), return it via ForwardResult, and store it on the request context - attribution fallback order is now: upstream echo (empty string treated as missing) -> outbound model -> client-requested model - 'request' pricing mode anchors to the outbound model instead of the pre-mapping client alias; unchanged when no mapping applies - persist the resolved pricing_model on every usage row - Claude Desktop rows now log app_type "claude-desktop" on streaming and transform paths too (was hardcoded "claude", silently dropping desktop provider pricing overrides and splitting the cost basis by the stream flag); its global pricing defaults inherit the claude config since proxy_config only allows claude/codex/gemini rows --- src-tauri/src/proxy/forwarder.rs | 41 +++++- src-tauri/src/proxy/handler_config.rs | 38 +++--- src-tauri/src/proxy/handler_context.rs | 6 + src-tauri/src/proxy/handlers.rs | 76 +++++++++-- src-tauri/src/proxy/response_processor.rs | 155 +++++++++++++++++++--- src-tauri/src/proxy/usage/logger.rs | 51 ++++--- 6 files changed, 305 insertions(+), 62 deletions(-) diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 3a8f815b9..3c2ee5068 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -38,6 +38,11 @@ pub struct ForwardResult { pub response: ProxyResponse, pub provider: Provider, pub claude_api_format: Option, + /// 实际发往上游的模型名(路由接管/模型映射后的真值)。 + /// + /// usage 归因不能依赖 ctx.request_model(映射前的客户端别名):上游响应 + /// 缺失 model 或回显别名时,接管流量会被记成 claude-* 并按其定价计费。 + pub outbound_model: Option, /// 活跃连接 RAII guard:随响应一起流转到 response_processor / handle_claude_transform, /// 最终被 move 进流式 body future(或非流式响应作用域),覆盖整个响应生命周期。 pub(crate) connection_guard: Option, @@ -463,7 +468,7 @@ impl RequestForwarder { ) .await { - Ok((response, claude_api_format)) => { + Ok((response, claude_api_format, outbound_model)) => { // 成功:普通闭合熔断状态异步记录,避免阻塞流式首包返回; // HalfOpen 探测仍同步等待,保证 permit 与熔断状态及时释放。 self.record_success_result(&provider.id, app_type_str, used_half_open_permit) @@ -511,6 +516,7 @@ impl RequestForwarder { response, provider: provider.clone(), claude_api_format, + outbound_model, connection_guard: None, }); } @@ -561,7 +567,7 @@ impl RequestForwarder { ) .await { - Ok((response, claude_api_format)) => { + Ok((response, claude_api_format, outbound_model)) => { log::info!( "[{app_type_str}] [Media] Unsupported-image retry succeeded" ); @@ -613,6 +619,7 @@ impl RequestForwarder { response, provider: provider.clone(), claude_api_format, + outbound_model, connection_guard: None, }); } @@ -706,7 +713,7 @@ impl RequestForwarder { ) .await { - Ok((response, claude_api_format)) => { + Ok((response, claude_api_format, outbound_model)) => { log::info!("[{app_type_str}] [RECT-002] 整流重试成功"); self.record_success_result( &provider.id, @@ -761,6 +768,7 @@ impl RequestForwarder { response, provider: provider.clone(), claude_api_format, + outbound_model, connection_guard: None, }); } @@ -871,7 +879,7 @@ impl RequestForwarder { ) .await { - Ok((response, claude_api_format)) => { + Ok((response, claude_api_format, outbound_model)) => { log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功"); self.record_success_result( &provider.id, @@ -920,6 +928,7 @@ impl RequestForwarder { response, provider: provider.clone(), claude_api_format, + outbound_model, connection_guard: None, }); } @@ -1077,6 +1086,9 @@ impl RequestForwarder { } /// 转发单个请求(使用适配器) + /// + /// 成功时返回 `(response, claude_api_format, outbound_model)`,其中 + /// `outbound_model` 是最终发往上游的模型名(所有映射/改写之后)。 #[allow(clippy::too_many_arguments)] async fn forward( &self, @@ -1088,7 +1100,7 @@ impl RequestForwarder { headers: &axum::http::HeaderMap, extensions: &Extensions, adapter: &dyn ProviderAdapter, - ) -> Result<(ProxyResponse, Option), ProxyError> { + ) -> Result<(ProxyResponse, Option, Option), ProxyError> { // 使用适配器提取 base_url let mut base_url = adapter.extract_base_url(provider)?; @@ -1320,6 +1332,15 @@ impl RequestForwarder { adapter.build_url(&base_url, &effective_endpoint) }; + // 记录映射后的出站模型名(此时 mapped_body 已完成接管映射 / [1m] 剥离 / + // Copilot 归一化)。格式转换后若 body 仍带 model 字段会在下方刷新覆盖; + // gemini_native 等模型在 URL 中的格式则保留此处的转换前真值。 + let mut outbound_model = mapped_body + .get("model") + .and_then(|m| m.as_str()) + .filter(|m| !m.is_empty()) + .map(str::to_string); + // 转换请求体(如果需要) let mut request_body = if codex_responses_to_chat { let mut mapped_body = mapped_body; @@ -1366,6 +1387,14 @@ impl RequestForwarder { // 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游 // 默认使用空白名单,过滤所有 _ 前缀字段 let filtered_body = prepare_upstream_request_body(request_body); + // 出站 body 定稿后刷新真值(覆盖 Codex chat 上游模型覆写、转换层模型改写) + if let Some(m) = filtered_body + .get("model") + .and_then(|m| m.as_str()) + .filter(|m| !m.is_empty()) + { + outbound_model = Some(m.to_string()); + } log_prompt_cache_trace( app_type, provider, @@ -1878,7 +1907,7 @@ impl RequestForwarder { let response = self .prepare_success_response_for_failover(response, request_is_streaming) .await?; - Ok((response, resolved_claude_api_format)) + Ok((response, resolved_claude_api_format, outbound_model)) } else { let status_code = status.as_u16(); let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok(); diff --git a/src-tauri/src/proxy/handler_config.rs b/src-tauri/src/proxy/handler_config.rs index 14ddaab70..2e3855a67 100644 --- a/src-tauri/src/proxy/handler_config.rs +++ b/src-tauri/src/proxy/handler_config.rs @@ -60,37 +60,40 @@ fn gemini_stream_usage_event_filter(data: &str) -> bool { // ============================================================================ /// Claude 流式响应模型提取(优先使用 usage.model) -fn claude_model_extractor(events: &[Value], request_model: &str) -> String { +/// +/// 空字符串模型名视为缺失(转换层对无回显上游会合成 model:""), +/// 落到 fallback_model(映射后的出站模型或客户端请求模型)。 +fn claude_model_extractor(events: &[Value], fallback_model: &str) -> String { // 首先尝试从解析的 usage 中获取模型 if let Some(usage) = TokenUsage::from_claude_stream_events(events) { - if let Some(model) = usage.model { + if let Some(model) = usage.model.filter(|m| !m.is_empty()) { return model; } } - request_model.to_string() + fallback_model.to_string() } /// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model) -fn openai_model_extractor(events: &[Value], request_model: &str) -> String { +fn openai_model_extractor(events: &[Value], fallback_model: &str) -> String { // 首先尝试从解析的 usage 中获取模型 if let Some(usage) = TokenUsage::from_openai_stream_events(events) { - if let Some(model) = usage.model { + if let Some(model) = usage.model.filter(|m| !m.is_empty()) { return model; } } // 回退:从事件中直接提取 events .iter() - .find_map(|e| e.get("model")?.as_str()) - .unwrap_or(request_model) + .find_map(|e| e.get("model")?.as_str().filter(|m| !m.is_empty())) + .unwrap_or(fallback_model) .to_string() } /// Codex 智能流式响应模型提取(自动检测格式) -fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String { +fn codex_auto_model_extractor(events: &[Value], fallback_model: &str) -> String { // 首先尝试从解析的 usage 中获取模型 if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) { - if let Some(model) = usage.model { + if let Some(model) = usage.model.filter(|m| !m.is_empty()) { return model; } } @@ -99,28 +102,33 @@ fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String { .iter() .find_map(|e| { if e.get("type")?.as_str()? == "response.completed" { - e.get("response")?.get("model")?.as_str() + e.get("response")? + .get("model")? + .as_str() + .filter(|m| !m.is_empty()) } else { None } }) .or_else(|| { // 再回退:从 OpenAI 格式事件中提取 - events.iter().find_map(|e| e.get("model")?.as_str()) + events + .iter() + .find_map(|e| e.get("model")?.as_str().filter(|m| !m.is_empty())) }) - .unwrap_or(request_model) + .unwrap_or(fallback_model) .to_string() } /// Gemini 流式响应模型提取(优先使用 usage.model) -fn gemini_model_extractor(events: &[Value], request_model: &str) -> String { +fn gemini_model_extractor(events: &[Value], fallback_model: &str) -> String { // 首先尝试从解析的 usage 中获取模型 if let Some(usage) = TokenUsage::from_gemini_stream_chunks(events) { - if let Some(model) = usage.model { + if let Some(model) = usage.model.filter(|m| !m.is_empty()) { return model; } } - request_model.to_string() + fallback_model.to_string() } // ============================================================================ diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index b1662733f..29be37d7d 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -48,6 +48,11 @@ pub struct RequestContext { pub current_provider_id: String, /// 请求中的模型名称 pub request_model: String, + /// 实际发往上游的模型名(路由接管/模型映射后的真值,forward 成功后回填)。 + /// + /// usage 归因的兜底顺序:上游响应回显 → outbound_model → request_model。 + /// 不能直接用 request_model 兜底:接管场景下它是映射前的客户端别名。 + pub outbound_model: Option, /// 日志标签(如 "Claude"、"Codex"、"Gemini") pub tag: &'static str, /// 应用类型字符串(如 "claude"、"codex"、"gemini") @@ -159,6 +164,7 @@ impl RequestContext { providers, current_provider_id, request_model, + outbound_model: None, tag, app_type_str, app_type, diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 3c5685711..9560d849a 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -207,6 +207,7 @@ async fn handle_messages_for_app( }; let connection_guard = result.connection_guard.take(); + ctx.outbound_model = result.outbound_model.take(); ctx.provider = result.provider; let api_format = result .claude_api_format @@ -334,29 +335,44 @@ async fn handle_claude_transform( let state = state.clone(); let provider_id = ctx.provider.id.clone(); let request_model = ctx.request_model.clone(); + // 上游/转换层未回显模型时,优先用映射后的出站模型兜底(路由接管真值), + // 其次才是客户端请求别名。空字符串视为缺失(转换器对无回显上游会合成 "")。 + let fallback_model = ctx + .outbound_model + .clone() + .unwrap_or_else(|| ctx.request_model.clone()); let status_code = status.as_u16(); let start_time = ctx.start_time; let session_id = ctx.session_id.clone(); + // 用 ctx 的 app_type:Claude Desktop 网关也走此转换路径,硬编码 + // "claude" 会把 claude-desktop 的行错记到 claude 名下 + let app_type_str = ctx.app_type_str; Some(SseUsageCollector::new( start_time, Some(claude_stream_usage_event_filter), move |events, first_token_ms| { if let Some(usage) = TokenUsage::from_claude_stream_events(&events) { - let model = usage.model.clone().unwrap_or(request_model.clone()); + let model = usage + .model + .clone() + .filter(|m| !m.is_empty()) + .unwrap_or_else(|| fallback_model.clone()); let latency_ms = start_time.elapsed().as_millis() as u64; let state = state.clone(); let provider_id = provider_id.clone(); let session_id = session_id.clone(); let request_model = request_model.clone(); + let outbound_model = fallback_model.clone(); tokio::spawn(async move { log_usage( &state, &provider_id, - "claude", + app_type_str, &model, &request_model, + &outbound_model, usage, latency_ms, first_token_ms, @@ -442,25 +458,35 @@ async fn handle_claude_transform( // 记录使用量 if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) { + // 转换后的响应缺失/合成空 model 时,回退到映射后的出站模型(接管真值), + // 再回退到客户端请求别名 let model = anthropic_response .get("model") .and_then(|m| m.as_str()) - .unwrap_or("unknown"); + .filter(|m| !m.is_empty()) + .map(str::to_string) + .or_else(|| ctx.outbound_model.clone()) + .unwrap_or_else(|| ctx.request_model.clone()); let latency_ms = ctx.latency_ms(); let request_model = ctx.request_model.clone(); + let outbound_model = ctx + .outbound_model + .clone() + .unwrap_or_else(|| ctx.request_model.clone()); + let app_type_str = ctx.app_type_str; tokio::spawn({ let state = state.clone(); let provider_id = ctx.provider.id.clone(); - let model = model.to_string(); let session_id = ctx.session_id.clone(); async move { log_usage( &state, &provider_id, - "claude", + app_type_str, &model, &request_model, + &outbound_model, usage, latency_ms, None, @@ -563,6 +589,7 @@ pub async fn handle_chat_completions( }; let connection_guard = result.connection_guard.take(); + ctx.outbound_model = result.outbound_model.take(); ctx.provider = result.provider; let response = result.response; @@ -628,6 +655,7 @@ pub async fn handle_responses( }; let connection_guard = result.connection_guard.take(); + ctx.outbound_model = result.outbound_model.take(); ctx.provider = result.provider; let response = result.response; @@ -705,6 +733,7 @@ pub async fn handle_responses_compact( }; let connection_guard = result.connection_guard.take(); + ctx.outbound_model = result.outbound_model.take(); ctx.provider = result.provider; let response = result.response; @@ -756,6 +785,12 @@ async fn handle_codex_chat_to_responses_transform( let state = state.clone(); let provider_id = ctx.provider.id.clone(); let request_model = ctx.request_model.clone(); + // 接管/模型覆写场景的归因兜底:出站真值优先于客户端请求别名 + let fallback_model = ctx + .outbound_model + .clone() + .unwrap_or_else(|| ctx.request_model.clone()); + let app_type_str = ctx.app_type_str; let start_time = ctx.start_time; let session_id = ctx.session_id.clone(); @@ -774,21 +809,27 @@ async fn handle_codex_chat_to_responses_transform( log::debug!("[Codex] 流式响应 usage 全 0 或缺失,跳过消费记录"); return; } - let model = usage.model.clone().unwrap_or_else(|| request_model.clone()); + let model = usage + .model + .clone() + .filter(|m| !m.is_empty()) + .unwrap_or_else(|| fallback_model.clone()); let latency_ms = start_time.elapsed().as_millis() as u64; let state = state.clone(); let provider_id = provider_id.clone(); let request_model = request_model.clone(); + let outbound_model = fallback_model.clone(); let session_id = session_id.clone(); tokio::spawn(async move { log_usage( &state, &provider_id, - "codex", + app_type_str, &model, &request_model, + &outbound_model, usage, latency_ms, first_token_ms, @@ -863,21 +904,29 @@ async fn handle_codex_chat_to_responses_transform( let model = responses_response .get("model") .and_then(|m| m.as_str()) - .unwrap_or(&ctx.request_model); + .filter(|m| !m.is_empty()) + .map(str::to_string) + .or_else(|| ctx.outbound_model.clone()) + .unwrap_or_else(|| ctx.request_model.clone()); let request_model = ctx.request_model.clone(); + let outbound_model = ctx + .outbound_model + .clone() + .unwrap_or_else(|| ctx.request_model.clone()); + let app_type_str = ctx.app_type_str; tokio::spawn({ let state = state.clone(); let provider_id = ctx.provider.id.clone(); - let model = model.to_string(); let session_id = ctx.session_id.clone(); let latency_ms = ctx.latency_ms(); async move { log_usage( &state, &provider_id, - "codex", + app_type_str, &model, &request_model, + &outbound_model, usage, latency_ms, None, @@ -1242,6 +1291,7 @@ pub async fn handle_gemini( }; let connection_guard = result.connection_guard.take(); + ctx.outbound_model = result.outbound_model.take(); ctx.provider = result.provider; let response = result.response; @@ -1370,6 +1420,9 @@ fn log_forward_error( } /// 记录请求使用量 +/// +/// `outbound_model` 是「按请求计价」模式的锚点:实际发往上游的模型 +/// (路由接管映射后的真值,无映射时等于 request_model)。 #[allow(clippy::too_many_arguments)] async fn log_usage( state: &ProxyState, @@ -1377,6 +1430,7 @@ async fn log_usage( app_type: &str, model: &str, request_model: &str, + outbound_model: &str, usage: TokenUsage, latency_ms: u64, first_token_ms: Option, @@ -1395,7 +1449,7 @@ async fn log_usage( let (multiplier, pricing_model_source) = logger.resolve_pricing_config(provider_id, app_type).await; let pricing_model = if pricing_model_source == PRICING_SOURCE_REQUEST { - request_model + outbound_model } else { model }; diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 6ac5e80fa..ae8fde260 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -270,14 +270,21 @@ pub async fn handle_non_streaming( if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { // 解析使用量 if let Some(usage) = (parser_config.response_parser)(&json_value) { - // 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型 - let model = if let Some(ref m) = usage.model { - m.clone() - } else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) { - m.to_string() - } else { - ctx.request_model.clone() - }; + // 归因优先级:usage 解析出的模型 → 响应 model 字段 → 映射后的出站 + // 模型(路由接管真值)→ 客户端请求模型。空字符串视为缺失。 + let model = usage + .model + .clone() + .filter(|m| !m.is_empty()) + .or_else(|| { + json_value + .get("model") + .and_then(|m| m.as_str()) + .filter(|m| !m.is_empty()) + .map(str::to_string) + }) + .or_else(|| ctx.outbound_model.clone()) + .unwrap_or_else(|| ctx.request_model.clone()); spawn_log_usage( state, @@ -292,8 +299,10 @@ pub async fn handle_non_streaming( let model = json_value .get("model") .and_then(|m| m.as_str()) - .unwrap_or(&ctx.request_model) - .to_string(); + .filter(|m| !m.is_empty()) + .map(str::to_string) + .or_else(|| ctx.outbound_model.clone()) + .unwrap_or_else(|| ctx.request_model.clone()); spawn_log_usage( state, ctx, @@ -318,7 +327,7 @@ pub async fn handle_non_streaming( state, ctx, TokenUsage::default(), - &ctx.request_model, + ctx.outbound_model.as_deref().unwrap_or(&ctx.request_model), &ctx.request_model, status.as_u16(), false, @@ -500,7 +509,16 @@ fn create_usage_collector( let state = state.clone(); let provider_id = ctx.provider.id.clone(); let request_model = ctx.request_model.clone(); - let app_type_str = parser_config.app_type_str; + // 流式事件缺失模型名时的归因兜底:映射后的出站模型(路由接管真值)优先, + // 其次才是客户端请求别名 + let fallback_model = ctx + .outbound_model + .clone() + .unwrap_or_else(|| ctx.request_model.clone()); + // 用 ctx 的 app_type 而不是 parser_config 的:Claude Desktop 流式透传复用 + // CLAUDE_PARSER_CONFIG(app_type_str="claude"),按 parser_config 记账会把 + // claude-desktop 的行错记到 claude 名下,导致供应商计价覆盖解析不到。 + let app_type_str = ctx.app_type_str; let tag = ctx.tag; let start_time = ctx.start_time; let stream_parser = parser_config.stream_parser; @@ -512,13 +530,14 @@ fn create_usage_collector( parser_config.stream_event_filter, move |events, first_token_ms| { if let Some(usage) = stream_parser(&events) { - let model = model_extractor(&events, &request_model); + let model = model_extractor(&events, &fallback_model); let latency_ms = start_time.elapsed().as_millis() as u64; let state = state.clone(); let provider_id = provider_id.clone(); let session_id = session_id.clone(); let request_model = request_model.clone(); + let outbound_model = fallback_model.clone(); tokio::spawn(async move { log_usage_internal( @@ -527,6 +546,7 @@ fn create_usage_collector( app_type_str, &model, &request_model, + &outbound_model, usage, latency_ms, first_token_ms, @@ -537,12 +557,13 @@ fn create_usage_collector( .await; }); } else { - let model = model_extractor(&events, &request_model); + let model = model_extractor(&events, &fallback_model); let latency_ms = start_time.elapsed().as_millis() as u64; let state = state.clone(); let provider_id = provider_id.clone(); let session_id = session_id.clone(); let request_model = request_model.clone(); + let outbound_model = fallback_model.clone(); tokio::spawn(async move { log_usage_internal( @@ -551,6 +572,7 @@ fn create_usage_collector( app_type_str, &model, &request_model, + &outbound_model, TokenUsage::default(), latency_ms, first_token_ms, @@ -588,6 +610,11 @@ fn spawn_log_usage( let app_type_str = ctx.app_type_str.to_string(); let model = model.to_string(); let request_model = request_model.to_string(); + // 「按请求计价」模式的锚点:映射后的出站模型,无映射时等于 request_model + let outbound_model = ctx + .outbound_model + .clone() + .unwrap_or_else(|| ctx.request_model.clone()); let latency_ms = ctx.latency_ms(); let session_id = ctx.session_id.clone(); @@ -598,6 +625,7 @@ fn spawn_log_usage( &app_type_str, &model, &request_model, + &outbound_model, usage, latency_ms, None, @@ -618,6 +646,11 @@ pub(crate) fn usage_logging_enabled(state: &ProxyState) -> bool { } /// 内部使用量记录函数 +/// +/// `outbound_model` 是「按请求计价」模式的锚点:实际发往上游的模型 +/// (路由接管映射后的真值,无映射时等于 request_model)。该模式的语义是 +/// 「按代理发出的请求计价、不信任上游回显」,接管场景下发出的请求模型是 +/// 映射后的 Y 而非客户端别名 X,按 X 计价会用错定价表行。 #[allow(clippy::too_many_arguments)] async fn log_usage_internal( state: &ProxyState, @@ -625,6 +658,7 @@ async fn log_usage_internal( app_type: &str, model: &str, request_model: &str, + outbound_model: &str, usage: TokenUsage, latency_ms: u64, first_token_ms: Option, @@ -638,7 +672,7 @@ async fn log_usage_internal( let (multiplier, pricing_model_source) = logger.resolve_pricing_config(provider_id, app_type).await; let pricing_model = if pricing_model_source == PRICING_SOURCE_REQUEST { - request_model + outbound_model } else { model }; @@ -1015,6 +1049,7 @@ mod tests { app_type, "resp-model", "req-model", + "req-model", usage, 10, None, @@ -1047,6 +1082,95 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_request_pricing_mode_anchors_to_outbound_model() -> Result<(), AppError> { + let db = Arc::new(Database::memory()?); + let app_type = "claude"; + + db.set_pricing_model_source(app_type, "request").await?; + seed_pricing(&db)?; + { + let conn = crate::database::lock_conn!(db.conn); + conn.execute( + "INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million) + VALUES ('outbound-model', 'Outbound Model', '4.0', '0')", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + } + + insert_provider(&db, "provider-3", app_type, ProviderMeta::default())?; + + let state = build_state(db.clone()); + let usage = TokenUsage { + input_tokens: 1_000_000, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + model: None, + message_id: None, + }; + + // 路由接管场景:客户端请求 req-model($2/M),代理实际发出 outbound-model + // ($4/M),上游回显 resp-model。「按请求计价」必须锚定实际发出的模型。 + log_usage_internal( + &state, + "provider-3", + app_type, + "resp-model", + "req-model", + "outbound-model", + usage, + 10, + None, + false, + 200, + None, + ) + .await; + + let conn = crate::database::lock_conn!(db.conn); + let (model, request_model, total_cost): (String, String, String) = conn + .query_row( + "SELECT model, request_model, total_cost_usd + FROM proxy_request_logs WHERE provider_id = ?1", + ["provider-3"], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // model / request_model 列不受计价锚点影响 + assert_eq!(model, "resp-model"); + assert_eq!(request_model, "req-model"); + // 按 outbound-model($4/M)计价,而不是 req-model($2/M)或 resp-model($1/M) + assert_eq!( + Decimal::from_str(&total_cost).unwrap(), + Decimal::from_str("4").unwrap() + ); + Ok(()) + } + + #[tokio::test] + async fn test_claude_desktop_inherits_claude_global_defaults() -> Result<(), AppError> { + use crate::proxy::usage::logger::UsageLogger; + + let db = Arc::new(Database::memory()?); + + // 全局计费配置只有 claude/codex/gemini 三行;claude-desktop 的 + // 全局默认必须继承 claude,而不是静默落回工厂默认(1 / response) + db.set_default_cost_multiplier("claude", "1.5").await?; + db.set_pricing_model_source("claude", "request").await?; + + let logger = UsageLogger::new(&db); + let (multiplier, source) = logger + .resolve_pricing_config("nonexistent-provider", "claude-desktop") + .await; + + assert_eq!(multiplier, Decimal::from_str("1.5").unwrap()); + assert_eq!(source, "request"); + Ok(()) + } + #[tokio::test] async fn test_log_usage_falls_back_to_global_defaults() -> Result<(), AppError> { let db = Arc::new(Database::memory()?); @@ -1075,6 +1199,7 @@ mod tests { app_type, "resp-model", "req-model", + "req-model", usage, 10, None, diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs index 603c2bf17..baa352670 100644 --- a/src-tauri/src/proxy/usage/logger.rs +++ b/src-tauri/src/proxy/usage/logger.rs @@ -16,6 +16,11 @@ pub struct RequestLog { pub app_type: String, pub model: String, pub request_model: String, + /// 写入时实际用于计价的模型名(pricing_model_source 解析后的结果)。 + /// 落库供回填使用:缺价行补价后必须按写入时的基准重算,而不是 + /// 用 model/request_model 猜——路由接管下三者可能各不相同。 + /// 错误行(未计价)为空字符串。 + pub pricing_model: String, pub usage: TokenUsage, pub cost: Option, pub latency_ms: u64, @@ -68,18 +73,19 @@ impl<'a> UsageLogger<'a> { conn.execute( "INSERT OR REPLACE INTO proxy_request_logs ( - request_id, provider_id, app_type, model, request_model, + request_id, provider_id, app_type, model, request_model, pricing_model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, latency_ms, first_token_ms, status_code, error_message, session_id, provider_type, is_streaming, cost_multiplier, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", rusqlite::params![ log.request_id, log.provider_id, log.app_type, log.model, log.request_model, + log.pricing_model, log.usage.input_tokens, log.usage.output_tokens, log.usage.cache_read_tokens, @@ -129,6 +135,8 @@ impl<'a> UsageLogger<'a> { app_type, model, request_model, + // 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行) + pricing_model: String::new(), usage: TokenUsage::default(), cost: None, latency_ms, @@ -168,6 +176,8 @@ impl<'a> UsageLogger<'a> { app_type, model, request_model, + // 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行) + pricing_model: String::new(), usage: TokenUsage::default(), cost: None, latency_ms, @@ -203,13 +213,22 @@ impl<'a> UsageLogger<'a> { provider_id: &str, app_type: &str, ) -> (Decimal, String) { - let default_multiplier_raw = match self.db.get_default_cost_multiplier(app_type).await { - Ok(value) => value, - Err(e) => { - log::warn!("[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}"); - "1".to_string() - } + // Claude Desktop 网关没有独立的全局计费配置(proxy_config 的 CHECK 仅 + // 允许 claude/codex/gemini,前端也只暴露三项),全局默认继承 claude; + // 供应商级 meta 覆盖仍按 claude-desktop 查找(providers 表按该 app_type 存)。 + let default_app_type = if app_type == "claude-desktop" { + "claude" + } else { + app_type }; + let default_multiplier_raw = + match self.db.get_default_cost_multiplier(default_app_type).await { + Ok(value) => value, + Err(e) => { + log::warn!("[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}"); + "1".to_string() + } + }; let default_multiplier = match Decimal::from_str(&default_multiplier_raw) { Ok(value) => value, Err(e) => { @@ -220,13 +239,14 @@ impl<'a> UsageLogger<'a> { } }; - let default_pricing_source_raw = match self.db.get_pricing_model_source(app_type).await { - Ok(value) => value, - Err(e) => { - log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}"); - PRICING_SOURCE_RESPONSE.to_string() - } - }; + let default_pricing_source_raw = + match self.db.get_pricing_model_source(default_app_type).await { + Ok(value) => value, + Err(e) => { + log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}"); + PRICING_SOURCE_RESPONSE.to_string() + } + }; let default_pricing_source = if default_pricing_source_raw == PRICING_SOURCE_RESPONSE || default_pricing_source_raw == PRICING_SOURCE_REQUEST { @@ -325,6 +345,7 @@ impl<'a> UsageLogger<'a> { app_type, model, request_model, + pricing_model, usage, cost, latency_ms, From e8b07cb2a50103b7730d62ee8ab056ded012b720 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 10 Jun 2026 11:57:06 +0800 Subject: [PATCH 20/66] feat(usage): claude-desktop filter and pricing-model audit display - add claude-desktop to AppType/KNOWN_APP_TYPES and the dashboard app filter; it was hidden because its rows looked like pure failure noise, which was the app_type attribution bug fixed on the backend - request detail panel now shows the requested model and the pricing model when they differ from the response model, making route-takeover bills auditable from the UI - locale keys added for zh/en/ja/zh-TW --- src/components/usage/RequestDetailPanel.tsx | 22 +++++++++++++++++++++ src/components/usage/UsageHero.tsx | 5 +++++ src/i18n/locales/en.json | 2 ++ src/i18n/locales/ja.json | 2 ++ src/i18n/locales/zh-TW.json | 2 ++ src/i18n/locales/zh.json | 2 ++ src/types/usage.ts | 21 +++++++++++++++----- 7 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/components/usage/RequestDetailPanel.tsx b/src/components/usage/RequestDetailPanel.tsx index f39547198..9cbb80bf8 100644 --- a/src/components/usage/RequestDetailPanel.tsx +++ b/src/components/usage/RequestDetailPanel.tsx @@ -111,6 +111,28 @@ export function RequestDetailPanel({ {t("usage.model", "模型")}
{request.model}
+ {request.requestModel && + request.requestModel !== request.model && ( + <> +
+ {t("usage.requestModel", "请求模型")} +
+
+ {request.requestModel} +
+ + )} + {request.pricingModel && + request.pricingModel !== request.model && ( + <> +
+ {t("usage.pricingModel", "计价模型")} +
+
+ {request.pricingModel} +
+ + )}
diff --git a/src/components/usage/UsageHero.tsx b/src/components/usage/UsageHero.tsx index 3ade773f1..e99db9e1f 100644 --- a/src/components/usage/UsageHero.tsx +++ b/src/components/usage/UsageHero.tsx @@ -49,6 +49,11 @@ const TITLE_THEMES: Record = { accent: "text-amber-600 dark:text-amber-400", iconBg: "bg-amber-500/10", }, + "claude-desktop": { + // 与 Claude Code 同属 Anthropic 品牌,用更深的 orange 区分 + accent: "text-orange-600 dark:text-orange-400", + iconBg: "bg-orange-500/10", + }, codex: { // OpenAI/Codex 走黑白单色调;中性灰在深浅模式都能透出方块底色, // 不像纯黑 bg-black/10 在深色背景下会糊掉。 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ad461bc75..f29569c34 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1373,6 +1373,7 @@ "appFilter": { "all": "All", "claude": "Claude Code", + "claude-desktop": "Claude Desktop", "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode" @@ -1403,6 +1404,7 @@ "modelPricingDesc": "Configure token costs for each model", "noPricingData": "No pricing data. Click \"Add\" to add model pricing configuration.", "model": "Model", + "pricingModel": "Pricing Model", "displayName": "Display Name", "inputCost": "Input Cost", "outputCost": "Output Cost", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0abf87721..858067d78 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1373,6 +1373,7 @@ "appFilter": { "all": "すべて", "claude": "Claude Code", + "claude-desktop": "Claude Desktop", "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode" @@ -1403,6 +1404,7 @@ "modelPricingDesc": "各モデルのトークンコストを設定", "noPricingData": "料金データがありません。「追加」をクリックしてモデル料金を設定してください。", "model": "モデル", + "pricingModel": "課金モデル", "displayName": "表示名", "inputCost": "入力コスト", "outputCost": "出力コスト", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 4b866ed53..5dfe8fd15 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1318,6 +1318,7 @@ "appFilter": { "all": "全部", "claude": "Claude Code", + "claude-desktop": "Claude Desktop", "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode" @@ -1348,6 +1349,7 @@ "modelPricingDesc": "設定各模型的 Token 成本", "noPricingData": "暫無定價資料。點擊「新增」加入模型定價設定。", "model": "模型", + "pricingModel": "計價模型", "displayName": "顯示名稱", "inputCost": "輸入成本", "outputCost": "輸出成本", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 675b2cd85..59b6be3fd 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1373,6 +1373,7 @@ "appFilter": { "all": "全部", "claude": "Claude Code", + "claude-desktop": "Claude Desktop", "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode" @@ -1403,6 +1404,7 @@ "modelPricingDesc": "配置各模型的 Token 成本", "noPricingData": "暂无定价数据。点击\"新增\"添加模型定价配置。", "model": "模型", + "pricingModel": "计价模型", "displayName": "显示名称", "inputCost": "输入成本", "outputCost": "输出成本", diff --git a/src/types/usage.ts b/src/types/usage.ts index efb885088..cde1958b5 100644 --- a/src/types/usage.ts +++ b/src/types/usage.ts @@ -14,6 +14,8 @@ export interface RequestLog { appType: string; model: string; requestModel?: string; + /** 写入时实际用于计价的模型名;路由接管 + request 计价模式下可能与 model 不同 */ + pricingModel?: string; costMultiplier: string; inputTokens: number; outputTokens: number; @@ -141,17 +143,26 @@ export interface UsageRangeSelection { /** * App types whose token usage is reliably collected by the proxy. * - * The proxy has handlers for `claude-desktop` too, but in practice those - * requests overwhelmingly fail (500/503) and contribute near-zero tokens, so - * it is hidden from the Dashboard. `opencode` / `openclaw` / `hermes` have - * no proxy handler at all — they appear only as managed apps elsewhere. + * `claude-desktop` was previously hidden because its rows looked like pure + * failure noise — that was an accounting bug: streaming/transform usage of + * the Desktop gateway was logged under app_type "claude", leaving only + * edge-case rows under "claude-desktop". The backend now attributes all + * Desktop traffic to "claude-desktop", so it is a first-class filter option. + * `opencode` / `openclaw` / `hermes` have no proxy handler at all — they + * appear only as managed apps elsewhere. */ -export type AppType = "claude" | "codex" | "gemini" | "opencode"; +export type AppType = + | "claude" + | "claude-desktop" + | "codex" + | "gemini" + | "opencode"; export type AppTypeFilter = "all" | AppType; export const KNOWN_APP_TYPES: ReadonlyArray = [ "claude", + "claude-desktop", "codex", "gemini", "opencode", From ff706e9e96d6c675ab4fc922b636143fa3707ade Mon Sep 17 00:00:00 2001 From: RoromoriYuzu <2391978156@qq.com> Date: Sun, 26 Apr 2026 02:40:32 +0800 Subject: [PATCH 21/66] feat: add provider user agent override --- src-tauri/src/provider.rs | 3 ++ src-tauri/src/proxy/forwarder.rs | 29 ++++++++++++++++ .../providers/forms/ClaudeFormFields.tsx | 31 ++++++++++++++++- .../providers/forms/CodexFormFields.tsx | 33 +++++++++++++++++++ .../providers/forms/ProviderForm.tsx | 12 +++++++ src/types.ts | 2 ++ 6 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 160ae8be9..39cffc498 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -460,6 +460,9 @@ pub struct ProviderMeta { /// Codex Responses -> Chat Completions reasoning capability metadata. #[serde(rename = "codexChatReasoning", skip_serializing_if = "Option::is_none")] pub codex_chat_reasoning: Option, + /// Custom User-Agent for local proxy routing. + #[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")] + pub custom_user_agent: Option, /// 累加模式应用中,该 provider 是否已写入 live config。 /// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。 #[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")] diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 3c2ee5068..92dc7480f 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -1537,6 +1537,15 @@ impl RequestForwarder { Vec::new() }; + let custom_user_agent = provider + .meta + .as_ref() + .and_then(|meta| meta.custom_user_agent.as_deref()) + .map(str::trim) + .filter(|ua| !ua.is_empty()) + .filter(|_| !is_copilot) + .and_then(|ua| http::HeaderValue::from_str(ua).ok()); + // --- Copilot 优化器:动态 header 注入 --- if let Some((ref classification, ref det_request_id, ref interaction_id)) = copilot_optimization @@ -1631,6 +1640,7 @@ impl RequestForwarder { let mut ordered_headers = http::HeaderMap::new(); let mut saw_auth = false; let mut saw_accept_encoding = false; + let mut saw_user_agent = false; let mut saw_anthropic_beta = false; let mut saw_anthropic_version = false; @@ -1711,6 +1721,19 @@ impl RequestForwarder { continue; } + // --- user-agent: provider-level override for local proxy routing --- + if !is_copilot && key_str.eq_ignore_ascii_case("user-agent") { + if !saw_user_agent { + saw_user_agent = true; + if let Some(ref ua) = custom_user_agent { + ordered_headers.append(http::header::USER_AGENT, ua.clone()); + } else { + ordered_headers.append(key.clone(), value.clone()); + } + } + continue; + } + // --- anthropic-beta — 用重建值替换(确保含 claude-code 标记) --- if key_str.eq_ignore_ascii_case("anthropic-beta") { if !saw_anthropic_beta { @@ -1760,6 +1783,12 @@ impl RequestForwarder { ); } + if !saw_user_agent { + if let Some(ref ua) = custom_user_agent { + ordered_headers.append(http::header::USER_AGENT, ua.clone()); + } + } + // 如果原始请求中没有 anthropic-beta 且有值需要添加,追加 if !saw_anthropic_beta { if let Some(ref beta_val) = anthropic_beta_value { diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index d0f1d749b..7625c5347 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -138,6 +138,10 @@ interface ClaudeFormFieldsProps { // Full URL mode isFullUrl: boolean; onFullUrlChange: (value: boolean) => void; + + // Local proxy User-Agent override + customUserAgent: string; + onCustomUserAgentChange: (value: string) => void; } export function ClaudeFormFields({ @@ -190,6 +194,8 @@ export function ClaudeFormFields({ onApiKeyFieldChange, isFullUrl, onFullUrlChange, + customUserAgent, + onCustomUserAgentChange, }: ClaudeFormFieldsProps) { const { t } = useTranslation(); const hasAnyAdvancedValue = !!( @@ -665,7 +671,30 @@ export function ClaudeFormFields({ /> )} - {/* 高级选项(API 格式 + 认证字段 + 模型映射) */} + {category !== "official" && ( +
+ + {t("providerForm.customUserAgent", { + defaultValue: "自定义 User-Agent", + })} + + onCustomUserAgentChange(e.target.value)} + placeholder="Mozilla/5.0 ..." + autoComplete="off" + /> +

+ {t("providerForm.customUserAgentHint", { + defaultValue: + "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。", + })} +

+
+ )} + {shouldShowModelSelector && ( diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index a01cf4b24..680ec0878 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -72,6 +72,10 @@ interface CodexFormFieldsProps { // Speed Test Endpoints speedTestEndpoints: EndpointCandidate[]; + + // Local proxy User-Agent override + customUserAgent: string; + onCustomUserAgentChange: (value: string) => void; } type CodexCatalogRow = CodexCatalogModel & { rowId: string }; @@ -128,6 +132,8 @@ export function CodexFormFields({ catalogModels = [], onCatalogModelsChange, speedTestEndpoints, + customUserAgent, + onCustomUserAgentChange, }: CodexFormFieldsProps) { const { t } = useTranslation(); @@ -582,6 +588,33 @@ export function CodexFormFields({ onCustomEndpointsChange={onCustomEndpointsChange} /> )} + + {category !== "official" && ( +
+ + onCustomUserAgentChange(e.target.value)} + placeholder="Mozilla/5.0 ..." + autoComplete="off" + /> +

+ {t("providerForm.customUserAgentHint", { + defaultValue: + "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。", + })} +

+
+ )} ); } diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 191b067ee..6908bed07 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -355,6 +355,7 @@ function ProviderFormFull({ ), }); setCodexChatReasoning(initialData?.meta?.codexChatReasoning ?? {}); + setCustomUserAgent(initialData?.meta?.customUserAgent ?? ""); }, [appId, initialData, supportsFullUrl]); const defaultValues: ProviderFormData = useMemo( @@ -509,6 +510,9 @@ function ProviderFormFull({ useState( () => initialData?.meta?.codexChatReasoning ?? {}, ); + const [customUserAgent, setCustomUserAgent] = useState( + () => initialData?.meta?.customUserAgent ?? "", + ); const { codexAuth, @@ -1386,6 +1390,10 @@ function ProviderFormFull({ localCodexApiFormat === "openai_chat" ? normalizeCodexChatReasoningForSave(codexChatReasoning) : undefined, + customUserAgent: + appId === "claude" || appId === "codex" + ? customUserAgent.trim() || undefined + : undefined, testConfig: testConfig.enabled ? testConfig : undefined, costMultiplier: pricingConfig.enabled ? pricingConfig.costMultiplier @@ -2009,6 +2017,8 @@ function ProviderFormFull({ onApiKeyFieldChange={handleApiKeyFieldChange} isFullUrl={localIsFullUrl} onFullUrlChange={setLocalIsFullUrl} + customUserAgent={customUserAgent} + onCustomUserAgentChange={setCustomUserAgent} /> )} @@ -2041,6 +2051,8 @@ function ProviderFormFull({ catalogModels={codexCatalogModels} onCatalogModelsChange={setCodexCatalogModels} speedTestEndpoints={speedTestEndpoints} + customUserAgent={customUserAgent} + onCustomUserAgentChange={setCustomUserAgent} /> )} diff --git a/src/types.ts b/src/types.ts index 71b547653..a09238110 100644 --- a/src/types.ts +++ b/src/types.ts @@ -219,6 +219,8 @@ export interface ProviderMeta { codexFastMode?: boolean; // Codex Responses -> Chat Completions reasoning capability metadata codexChatReasoning?: CodexChatReasoning; + // Custom User-Agent for local proxy routing. Only applied by the local proxy. + customUserAgent?: string; // 供应商类型(用于识别 Copilot 等特殊供应商) providerType?: string; // GitHub Copilot 关联账号 ID(旧字段,保留兼容读取) From 25983f34209cfd6da36229f3b1be13652c281f69 Mon Sep 17 00:00:00 2001 From: RoromoriYuzu <2391978156@qq.com> Date: Mon, 27 Apr 2026 22:21:51 +0800 Subject: [PATCH 22/66] fix: omit customUserAgent when provider category is official Stale custom UA values from non-official presets were persisted even after switching to an official preset, silently altering request headers. --- src/components/providers/forms/ProviderForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 6908bed07..8bafb5d3c 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -1391,7 +1391,7 @@ function ProviderFormFull({ ? normalizeCodexChatReasoningForSave(codexChatReasoning) : undefined, customUserAgent: - appId === "claude" || appId === "codex" + (appId === "claude" || appId === "codex") && category !== "official" ? customUserAgent.trim() || undefined : undefined, testConfig: testConfig.enabled ? testConfig : undefined, From 8b925c2f2f53c53de0ef6babb69026f8e99877a0 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 10 Jun 2026 16:39:12 +0800 Subject: [PATCH 23/66] feat(proxy): honor custom User-Agent across stream check and model fetch Extract a shared `parse_custom_user_agent` helper in provider.rs returning `Result>`, and reuse it in the forwarder, stream check, and model fetch paths so detection, forwarding, and model listing all apply the same provider-level User-Agent. Previously only the forwarder honored it, so stream check could fail (or model listing 403) on UA-gated upstreams that the proxy itself handled fine. - stream_check injects the provider's custom UA on the claude/codex paths and still skips the GitHub Copilot fingerprint UA. - model_fetch service + command and the model-fetch.ts wrapper thread an optional UA through to GET /v1/models. - runtime callers silently ignore invalid values via `.ok().flatten()` (no save-time block, so deeplink imports stay lenient). --- src-tauri/src/commands/model_fetch.rs | 6 ++ src-tauri/src/provider.rs | 30 ++++++++++ src-tauri/src/proxy/forwarder.rs | 19 +++--- src-tauri/src/services/model_fetch.rs | 15 +++-- src-tauri/src/services/stream_check.rs | 83 ++++++++++++++++++++++++-- src/lib/api/model-fetch.ts | 2 + 6 files changed, 138 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/commands/model_fetch.rs b/src-tauri/src/commands/model_fetch.rs index 7064e965b..c211bd651 100644 --- a/src-tauri/src/commands/model_fetch.rs +++ b/src-tauri/src/commands/model_fetch.rs @@ -14,12 +14,18 @@ pub async fn fetch_models_for_config( api_key: String, is_full_url: Option, models_url: Option, + custom_user_agent: Option, ) -> Result, String> { + // 与转发 / 检测路径共用 parse_custom_user_agent:非法 UA 静默忽略(不阻断取模型)。 + let user_agent = crate::provider::parse_custom_user_agent(custom_user_agent.as_deref()) + .ok() + .flatten(); model_fetch::fetch_models( &base_url, &api_key, is_full_url.unwrap_or(false), models_url.as_deref(), + user_agent, ) .await } diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 39cffc498..c99758ebe 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -1,3 +1,4 @@ +use http::header::{HeaderValue, InvalidHeaderValue}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -477,6 +478,30 @@ pub struct ProviderMeta { pub github_account_id: Option, } +/// 解析 Provider 级自定义 User-Agent 字符串(单一真理来源)。 +/// +/// 转发(forwarder)、流式检测(stream_check)、获取模型列表(model_fetch)三条路径 +/// 共用同一口径,避免出现"某条路径用了 UA、另一条没用 / 报错"的不一致。 +/// +/// 合法性由 `http::HeaderValue::from_str` 按**字节**判定(`b >= 32 && b != 127 || b == '\t'`), +/// 与前端 `src/lib/userAgent.ts::isValidUserAgentHeader` 严格一致: +/// - `Ok(None)`:未设置或纯空白(trim 后为空)。 +/// - `Ok(Some(hv))`:合法。制表符、可见 ASCII(0x20–0x7E)、以及任意非 ASCII 字符 +/// (UTF-8 字节均 ≥ 0x80)都合法。 +/// - `Err(_)`:仅含控制字符时——除 `\t` 外的 0x00–0x1F(含换行)与 0x7F(DEL)。 +/// +/// 非法值的处理:三条运行时路径**均静默忽略**(`.ok().flatten()`,绝不让某条路径报错而 +/// 另一条放行);前端在输入框处给出非阻断提示。当前**不在保存时阻断**——deeplink 导入等 +/// 非表单路径应宽容,运行时静默忽略即为安全网。 +pub fn parse_custom_user_agent( + raw: Option<&str>, +) -> Result, InvalidHeaderValue> { + match raw.map(str::trim).filter(|s| !s.is_empty()) { + Some(ua) => HeaderValue::from_str(ua).map(Some), + None => Ok(None), + } +} + impl ProviderMeta { /// Codex OAuth FAST mode 是否启用。默认关闭,因为 `service_tier="priority"` /// 会按更高速率消耗 ChatGPT 订阅配额,用户需显式开启以换取更低延迟。 @@ -484,6 +509,11 @@ impl ProviderMeta { self.codex_fast_mode.unwrap_or(false) } + /// 经校验的 Provider 级自定义 User-Agent。见 [`parse_custom_user_agent`]。 + pub fn custom_user_agent_header(&self) -> Result, InvalidHeaderValue> { + parse_custom_user_agent(self.custom_user_agent.as_deref()) + } + /// 解析指定托管认证供应商绑定的账号 ID。 /// /// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。 diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 92dc7480f..e83e2f68e 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -1537,14 +1537,17 @@ impl RequestForwarder { Vec::new() }; - let custom_user_agent = provider - .meta - .as_ref() - .and_then(|meta| meta.custom_user_agent.as_deref()) - .map(str::trim) - .filter(|ua| !ua.is_empty()) - .filter(|_| !is_copilot) - .and_then(|ua| http::HeaderValue::from_str(ua).ok()); + // 自定义 User-Agent:与 stream_check / model_fetch 共用 parse_custom_user_agent, + // 运行时静默忽略非法值(前端在输入处给非阻断提示,不在保存时阻断)。 + // Copilot 指纹 UA 不可覆盖。 + let custom_user_agent = if is_copilot { + None + } else { + provider + .meta + .as_ref() + .and_then(|meta| meta.custom_user_agent_header().ok().flatten()) + }; // --- Copilot 优化器:动态 header 注入 --- if let Some((ref classification, ref det_request_id, ref interaction_id)) = diff --git a/src-tauri/src/services/model_fetch.rs b/src-tauri/src/services/model_fetch.rs index b495c9fb9..9a89303b5 100644 --- a/src-tauri/src/services/model_fetch.rs +++ b/src-tauri/src/services/model_fetch.rs @@ -4,6 +4,7 @@ //! 主要面向第三方聚合站(硅基流动、OpenRouter 等),以及把 Anthropic //! 协议挂在兼容子路径上的官方供应商(DeepSeek、Kimi、智谱 GLM 等)。 +use reqwest::header::{HeaderValue, USER_AGENT}; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -55,6 +56,7 @@ pub async fn fetch_models( api_key: &str, is_full_url: bool, models_url_override: Option<&str>, + user_agent: Option, ) -> Result, String> { if api_key.is_empty() { return Err("API Key is required to fetch models".to_string()); @@ -66,13 +68,16 @@ pub async fn fetch_models( for url in &candidates { log::debug!("[ModelFetch] Trying endpoint: {url}"); - let response = match client + let mut request = client .get(url) .header("Authorization", format!("Bearer {api_key}")) - .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS)) - .send() - .await - { + .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS)); + // 自定义 User-Agent:部分 /models 端点同样有 UA 白名单(如 Kimi Coding Plan), + // 与转发 / 检测路径共用同一 UA,避免"代理可用但取模型失败"。 + if let Some(ua) = &user_agent { + request = request.header(USER_AGENT, ua.clone()); + } + let response = match request.send().await { Ok(r) => r, Err(e) => { return Err(format!("Request failed: {e}")); diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 665d8c0d2..c7a1a0bb2 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -293,6 +293,20 @@ impl StreamCheckService { )) } + /// Provider 级自定义 User-Agent(`meta.customUserAgent`),经 `parse_custom_user_agent` 校验。 + /// + /// 与 forwarder 转发路径(`RequestForwarder::forward`)、model_fetch 共用单一口径:trim、 + /// 空串视为未设置、**非法值静默忽略**(返回 `None`,不报错)。Stream Check 必须复用同一个 + /// UA 去探测,否则会与真实流量用不同的 User-Agent(例如 Kimi Coding Plan 的 UA 白名单), + /// 导致"检测失败但代理可用"或反之的分歧——非法 UA 时尤甚(转发静默丢弃、检测却会因 + /// reqwest 非法头在 `.send()` 报错)。 + fn custom_user_agent(provider: &Provider) -> Option { + provider + .meta + .as_ref() + .and_then(|meta| meta.custom_user_agent_header().ok().flatten()) + } + /// Claude 流式检查 /// /// 根据供应商的 api_format 选择请求格式: @@ -489,6 +503,14 @@ impl StreamCheckService { } } + // Provider 级自定义 User-Agent(meta.customUserAgent)覆盖默认 UA,与 forwarder + // 转发路径口径一致;Copilot 指纹 UA 不可被覆盖。 + if !is_github_copilot { + if let Some(ua) = Self::custom_user_agent(provider) { + request_builder = request_builder.header("user-agent", ua); + } + } + let response = request_builder .timeout(timeout) .json(&body) @@ -549,6 +571,15 @@ impl StreamCheckService { let os_name = Self::get_os_name(); let arch_name = Self::get_arch_name(); + // Provider 级自定义 User-Agent(meta.customUserAgent)覆盖默认 codex UA,与 forwarder + // 转发路径口径一致——否则 Stream Check 会用与真实流量不同的 UA 探测(如 Kimi UA 白名单)。 + let user_agent = Self::custom_user_agent(provider).unwrap_or_else(|| { + reqwest::header::HeaderValue::from_str(&format!( + "codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal" + )) + .unwrap_or_else(|_| reqwest::header::HeaderValue::from_static("codex_cli_rs/0.80.0")) + }); + let mut body = if uses_chat { // Chat Completions 请求体(与 transform_codex_chat::responses_to_chat_completions 对齐) json!({ @@ -586,10 +617,7 @@ impl StreamCheckService { .header("content-type", "application/json") .header("accept", "text/event-stream") .header("accept-encoding", "identity") - .header( - "user-agent", - format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"), - ) + .header("user-agent", user_agent.clone()) .header("originator", "codex_cli_rs") .timeout(timeout) .json(&body) @@ -1630,6 +1658,53 @@ mod tests { assert!(!StreamCheckService::additive_app_uses_auth_header(&p)); } + #[test] + fn test_custom_user_agent_trims_and_filters_empty() { + use crate::provider::ProviderMeta; + + let mut p = make_provider(serde_json::json!({ + "baseUrl": "https://api.kimi.com/coding", + "apiKey": "k", + })); + + // 未设置 meta → None + assert!(StreamCheckService::custom_user_agent(&p).is_none()); + + // 带首尾空格的 UA → 去空格后返回合法 HeaderValue + p.meta = Some(ProviderMeta { + custom_user_agent: Some(" claude-cli/2.1.161 ".to_string()), + ..Default::default() + }); + assert_eq!( + StreamCheckService::custom_user_agent(&p) + .unwrap() + .to_str() + .unwrap(), + "claude-cli/2.1.161" + ); + + // 纯空白 → 视为未设置(与 forwarder 路径口径一致) + p.meta = Some(ProviderMeta { + custom_user_agent: Some(" ".to_string()), + ..Default::default() + }); + assert!(StreamCheckService::custom_user_agent(&p).is_none()); + + // 非 ASCII 字符其实合法(UTF-8 字节均 ≥ 0x80,HeaderValue 按字节放行)→ 应返回 Some + p.meta = Some(ProviderMeta { + custom_user_agent: Some("claude-cli/2.1.161 \u{4e2d}".to_string()), + ..Default::default() + }); + assert!(StreamCheckService::custom_user_agent(&p).is_some()); + + // 含控制字符(内嵌换行)才非法 → None(静默忽略,与 forwarder 一致,不让 Stream Check 报错) + p.meta = Some(ProviderMeta { + custom_user_agent: Some("claude-cli/2.1.161\nX".to_string()), + ..Default::default() + }); + assert!(StreamCheckService::custom_user_agent(&p).is_none()); + } + #[test] fn test_resolve_opencode_base_url_explicit_wins() { let p = make_provider(serde_json::json!({ diff --git a/src/lib/api/model-fetch.ts b/src/lib/api/model-fetch.ts index 4f346972d..6b6d89f3d 100644 --- a/src/lib/api/model-fetch.ts +++ b/src/lib/api/model-fetch.ts @@ -18,12 +18,14 @@ export async function fetchModelsForConfig( apiKey: string, isFullUrl?: boolean, modelsUrl?: string, + customUserAgent?: string, ): Promise { return invoke("fetch_models_for_config", { baseUrl, apiKey, isFullUrl, modelsUrl, + customUserAgent, }); } From 596019505f13c3db8f74369da7979f6f658d8f98 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 10 Jun 2026 16:39:26 +0800 Subject: [PATCH 24/66] feat(provider-form): custom User-Agent presets dropdown in advanced settings Polish the provider-level User-Agent override UI on the Claude and Codex forms. - Add a shared CustomUserAgentField (label + input + preset dropdown + live validation) so both forms stay in sync. - Provide curated UA presets (Claude Code / Kilo Code families that pass coding-plan UA whitelists per #3671); the first is Claude Code's real `claude-cli/x (external, cli)` format. Whitelists gate on the name prefix, not the version, so static values stay valid across upgrades. - Expose presets via a dropdown to the right of the input (z-[200] so it renders above the dialog layers) instead of inline chips. - Move the field into the existing advanced/reasoning collapsibles. - userAgent.ts mirrors the backend byte rule (reject only control chars; non-ASCII is allowed) for a non-blocking inline hint. - i18n for all four locales (zh/en/ja/zh-TW). --- .../providers/forms/ClaudeFormFields.tsx | 38 +++----- .../providers/forms/CodexFormFields.tsx | 46 ++++----- .../providers/forms/CustomUserAgentField.tsx | 96 +++++++++++++++++++ src/config/userAgentPresets.ts | 20 ++++ src/i18n/locales/en.json | 4 + src/i18n/locales/ja.json | 4 + src/i18n/locales/zh-TW.json | 4 + src/i18n/locales/zh.json | 4 + src/lib/userAgent.ts | 16 ++++ tests/components/ClaudeFormFields.test.tsx | 2 + tests/lib/userAgent.test.ts | 34 +++++++ 11 files changed, 212 insertions(+), 56 deletions(-) create mode 100644 src/components/providers/forms/CustomUserAgentField.tsx create mode 100644 src/config/userAgentPresets.ts create mode 100644 src/lib/userAgent.ts create mode 100644 tests/lib/userAgent.test.ts diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 7625c5347..fadd026ce 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -47,6 +47,7 @@ import { showFetchModelsError, type FetchedModel, } from "@/lib/api/model-fetch"; +import { CustomUserAgentField } from "./CustomUserAgentField"; import type { ProviderCategory, ClaudeApiFormat, @@ -204,7 +205,8 @@ export function ClaudeFormFields({ defaultSonnetModel || defaultOpusModel || apiFormat !== "anthropic" || - apiKeyField !== "ANTHROPIC_AUTH_TOKEN" + apiKeyField !== "ANTHROPIC_AUTH_TOKEN" || + customUserAgent ); const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue); @@ -257,7 +259,7 @@ export function ClaudeFormFields({ const modelsUrl = matchedPreset?.modelsUrl; setIsFetchingModels(true); - fetchModelsForConfig(baseUrl, apiKey, isFullUrl, modelsUrl) + fetchModelsForConfig(baseUrl, apiKey, isFullUrl, modelsUrl, customUserAgent) .then((models) => { setFetchedModels(models); showModelFetchResult(models.length); @@ -267,7 +269,7 @@ export function ClaudeFormFields({ showFetchModelsError(err, t); }) .finally(() => setIsFetchingModels(false)); - }, [baseUrl, apiKey, isFullUrl, showModelFetchResult, t]); + }, [baseUrl, apiKey, isFullUrl, customUserAgent, showModelFetchResult, t]); const handleFetchCopilotModels = useCallback(() => { if (!isCopilotAuthenticated) { @@ -671,30 +673,6 @@ export function ClaudeFormFields({ /> )} - {category !== "official" && ( -
- - {t("providerForm.customUserAgent", { - defaultValue: "自定义 User-Agent", - })} - - onCustomUserAgentChange(e.target.value)} - placeholder="Mozilla/5.0 ..." - autoComplete="off" - /> -

- {t("providerForm.customUserAgentHint", { - defaultValue: - "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。", - })} -

-
- )} - {shouldShowModelSelector && ( @@ -962,6 +940,12 @@ export function ClaudeFormFields({ })}

+ + )} diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 680ec0878..ac1101d9b 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -25,6 +25,7 @@ import { showFetchModelsError, type FetchedModel, } from "@/lib/api/model-fetch"; +import { CustomUserAgentField } from "./CustomUserAgentField"; import type { CodexApiFormat, CodexCatalogModel, @@ -222,7 +223,13 @@ export function CodexFormFields({ return; } setIsFetchingModels(true); - fetchModelsForConfig(codexBaseUrl, codexApiKey, isFullUrl) + fetchModelsForConfig( + codexBaseUrl, + codexApiKey, + isFullUrl, + undefined, + customUserAgent, + ) .then((models) => { setFetchedModels(models); if (models.length === 0) { @@ -238,7 +245,7 @@ export function CodexFormFields({ showFetchModelsError(err, t); }) .finally(() => setIsFetchingModels(false)); - }, [codexBaseUrl, codexApiKey, isFullUrl, t]); + }, [codexBaseUrl, codexApiKey, isFullUrl, customUserAgent, t]); const handleAddCatalogRow = useCallback(() => { if (!onCatalogModelsChange) return; @@ -436,6 +443,14 @@ export function CodexFormFields({ })} />
+ +
+ +
)} @@ -588,33 +603,6 @@ export function CodexFormFields({ onCustomEndpointsChange={onCustomEndpointsChange} /> )} - - {category !== "official" && ( -
- - onCustomUserAgentChange(e.target.value)} - placeholder="Mozilla/5.0 ..." - autoComplete="off" - /> -

- {t("providerForm.customUserAgentHint", { - defaultValue: - "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。", - })} -

-
- )} ); } diff --git a/src/components/providers/forms/CustomUserAgentField.tsx b/src/components/providers/forms/CustomUserAgentField.tsx new file mode 100644 index 000000000..dc7151649 --- /dev/null +++ b/src/components/providers/forms/CustomUserAgentField.tsx @@ -0,0 +1,96 @@ +import { useTranslation } from "react-i18next"; +import { ChevronDown } from "lucide-react"; +import { FormLabel } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { isValidUserAgentHeader } from "@/lib/userAgent"; +import { USER_AGENT_PRESETS } from "@/config/userAgentPresets"; + +interface CustomUserAgentFieldProps { + /** 输入框的 id(用于 label htmlFor);两个表单需传入各自唯一值。 */ + id: string; + value: string; + onChange: (value: string) => void; +} + +/** + * 供应商级自定义 User-Agent 字段(Claude / Codex 表单共用)。 + * + * 含标签 + 输入框 + 右侧预设下拉菜单 + 实时合法性提示。校验口径与后端 + * `parse_custom_user_agent` 一致(见 `@/lib/userAgent`),非法时给非阻断红字提示 + * (运行时仍会静默忽略)。 + */ +export function CustomUserAgentField({ + id, + value, + onChange, +}: CustomUserAgentFieldProps) { + const { t } = useTranslation(); + const valid = isValidUserAgentHeader(value); + + return ( +
+ + {t("providerForm.customUserAgent", { + defaultValue: "自定义 User-Agent", + })} + +
+ onChange(e.target.value)} + placeholder="Mozilla/5.0 ..." + autoComplete="off" + className="flex-1" + /> + + + + + + {USER_AGENT_PRESETS.map((preset) => ( + onChange(preset)} + className="font-mono text-xs" + > + {preset} + + ))} + + +
+ {valid ? ( +

+ {t("providerForm.customUserAgentHint", { + defaultValue: + "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。", + })} +

+ ) : ( +

+ {t("providerForm.customUserAgentInvalid", { + defaultValue: + "User-Agent 不能包含控制字符(如换行符),否则将被忽略。", + })} +

+ )} +
+ ); +} diff --git a/src/config/userAgentPresets.ts b/src/config/userAgentPresets.ts new file mode 100644 index 000000000..5e7f6f1f4 --- /dev/null +++ b/src/config/userAgentPresets.ts @@ -0,0 +1,20 @@ +/** + * 自定义 User-Agent 预设。 + * + * 取值来自 PR #3671 对 Kimi Coding Plan(api.kimi.com/coding)UA 白名单的 curl 实测: + * `claude-cli/*`、`claude-code/*`、`Kilo-Code/*` 可通过;`codex-cli`、`kimi-cli` 会被 403。 + * 白名单只校验 UA 名称前缀、不看版本号,因此用静态值即可,版本不会因 Claude Code 升级而失效。 + * + * 第一条是官方 Claude Code CLI 实际发送的完整格式(参见 `stream_check.rs` 里检测用的 + * `claude-cli/2.1.2 (external, cli)`),最贴近真实客户端、最稳过严格的 UA 校验;其余为简短变体。 + * + * 这些预设主要用于"非白名单 Coding Agent(Codex/Gemini/Hermes/OpenClaw 等)想接入受 UA + * 限制的上游"的场景——把转发请求伪装成已在白名单内的客户端。是否使用由用户显式选择。 + */ +export const USER_AGENT_PRESETS: readonly string[] = [ + "claude-cli/2.1.161 (external, cli)", + "claude-cli/2.1.161", + "claude-code/1.0.0", + "claude-code/0.1.0", + "Kilo-Code/1.0", +]; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f29569c34..f2ad7d2fb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1043,6 +1043,10 @@ "anthropicSmallFastModel": "Fast Model", "apiFormat": "API Format", "apiFormatHint": "Select the input format for the provider's API", + "customUserAgent": "Custom User-Agent", + "customUserAgentHint": "Only takes effect when local routing/proxy takeover is enabled; replaces the User-Agent in requests forwarded to the provider API.", + "customUserAgentInvalid": "User-Agent must not contain control characters (e.g. line breaks); otherwise it will be ignored.", + "customUserAgentPresets": "Presets", "fullUrlLabel": "Full URL", "fullUrlEnabled": "Full URL Mode", "fullUrlDisabled": "Mark as Full URL", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 858067d78..eed2d090e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1043,6 +1043,10 @@ "anthropicSmallFastModel": "高速モデル", "apiFormat": "API フォーマット", "apiFormatHint": "プロバイダー API の入力フォーマットを選択", + "customUserAgent": "カスタム User-Agent", + "customUserAgentHint": "ローカルルーティング/プロキシ引き継ぎが有効な場合にのみ適用され、プロバイダー API へ転送するリクエストの User-Agent を置き換えます。", + "customUserAgentInvalid": "User-Agent に制御文字(改行など)を含めることはできません。含まれている場合は無視されます。", + "customUserAgentPresets": "プリセット", "fullUrlLabel": "フル URL", "fullUrlEnabled": "フル URL モード", "fullUrlDisabled": "フル URL として設定", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 5dfe8fd15..6191fa536 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1014,6 +1014,10 @@ "anthropicSmallFastModel": "快速模型", "apiFormat": "API 格式", "apiFormatHint": "選擇供應商 API 的輸入格式", + "customUserAgent": "自訂 User-Agent", + "customUserAgentHint": "僅在開啟本地路由/代理接管後生效,會取代轉發至供應商 API 請求中的 User-Agent。", + "customUserAgentInvalid": "User-Agent 不能包含控制字元(如換行字元),否則將被忽略。", + "customUserAgentPresets": "預設", "fullUrlLabel": "完整 URL", "fullUrlEnabled": "完整 URL 模式", "fullUrlDisabled": "標記為完整 URL", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 59b6be3fd..6ffbea292 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1043,6 +1043,10 @@ "anthropicSmallFastModel": "快速模型", "apiFormat": "API 格式", "apiFormatHint": "选择供应商 API 的输入格式", + "customUserAgent": "自定义 User-Agent", + "customUserAgentHint": "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。", + "customUserAgentInvalid": "User-Agent 不能包含控制字符(如换行符),否则将被忽略。", + "customUserAgentPresets": "预设", "fullUrlLabel": "完整 URL", "fullUrlEnabled": "完整 URL 模式", "fullUrlDisabled": "标记为完整 URL", diff --git a/src/lib/userAgent.ts b/src/lib/userAgent.ts new file mode 100644 index 000000000..5daa9049b --- /dev/null +++ b/src/lib/userAgent.ts @@ -0,0 +1,16 @@ +/** + * 自定义 User-Agent 合法性校验。 + * + * 与后端 `parse_custom_user_agent`(基于 `http::HeaderValue::from_str`)口径严格一致: + * HeaderValue 按**字节**判定合法性,规则为 `b >= 32 && b != 127 || b == '\t'`。也就是说: + * - 制表符(\t)、可见 ASCII(0x20–0x7E)、以及任意非 ASCII 字符(UTF-8 字节均 ≥ 0x80)都合法; + * - 仅控制字符非法:除 \t 外的 0x00–0x1F(含换行)与 0x7F(DEL)。 + * + * 空串(trim 后为空)视为"未设置",合法。 + */ +export function isValidUserAgentHeader(value: string): boolean { + const trimmed = value.trim(); + if (trimmed === "") return true; + // eslint-disable-next-line no-control-regex + return !/[\x00-\x08\x0a-\x1f\x7f]/.test(trimmed); +} diff --git a/tests/components/ClaudeFormFields.test.tsx b/tests/components/ClaudeFormFields.test.tsx index e18a15ca9..336339260 100644 --- a/tests/components/ClaudeFormFields.test.tsx +++ b/tests/components/ClaudeFormFields.test.tsx @@ -91,6 +91,8 @@ const renderCopilotForm = (overrides: Partial = {}) => { onApiKeyFieldChange: vi.fn(), isFullUrl: false, onFullUrlChange: vi.fn(), + customUserAgent: "", + onCustomUserAgentChange: vi.fn(), ...overrides, }; diff --git a/tests/lib/userAgent.test.ts b/tests/lib/userAgent.test.ts new file mode 100644 index 000000000..47a6865af --- /dev/null +++ b/tests/lib/userAgent.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { isValidUserAgentHeader } from "@/lib/userAgent"; + +// 与后端 parse_custom_user_agent / http::HeaderValue::from_str 的字节级规则对齐: +// 合法 = b>=32 && b!=127 || b=='\t'。即仅控制字符(除 \t 外的 0x00–0x1F 与 0x7F)非法, +// 可见 ASCII 与非 ASCII 都合法。控制字符用 String.fromCharCode 构造,避免源码内嵌生字节。 +const NUL = String.fromCharCode(0); +const DEL = String.fromCharCode(0x7f); + +describe("isValidUserAgentHeader", () => { + it("treats empty / whitespace-only as valid (unset)", () => { + expect(isValidUserAgentHeader("")).toBe(true); + expect(isValidUserAgentHeader(" ")).toBe(true); + }); + + it("accepts visible ASCII (trimmed)", () => { + expect(isValidUserAgentHeader("claude-cli/2.1.161")).toBe(true); + expect(isValidUserAgentHeader(" claude-cli/2.1.161 ")).toBe(true); + }); + + it("accepts non-ASCII — matches backend HeaderValue byte rule", () => { + expect(isValidUserAgentHeader("claude-cli/1.0 中文")).toBe(true); + }); + + it("accepts internal tab", () => { + expect(isValidUserAgentHeader("claude\tcli")).toBe(true); + }); + + it("rejects control characters (newline / null / DEL)", () => { + expect(isValidUserAgentHeader("claude\ncli")).toBe(false); + expect(isValidUserAgentHeader(`claude${NUL}cli`)).toBe(false); + expect(isValidUserAgentHeader(`claude${DEL}cli`)).toBe(false); + }); +}); From e7761609123def7fda4849c7b6abf284b2a4fde5 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 10 Jun 2026 21:32:26 +0800 Subject: [PATCH 25/66] feat(provider-form): consolidate codex form into advanced options section - Fold local routing toggle, model mapping, reasoning overrides and custom User-Agent into a single collapsible advanced section, mirroring the Claude form (auto-expands when UA is set or local routing is enabled) - Custom User-Agent becomes configurable for native Responses providers; it was previously reachable only when openai_chat routing was on - Collapsed hint names local routing as the entry point for Chat Completions / non-GPT providers - Backfill all missing codexConfig keys in zh-TW locale --- .../providers/forms/CodexFormFields.tsx | 475 ++++++++++-------- src/i18n/locales/en.json | 5 +- src/i18n/locales/ja.json | 5 +- src/i18n/locales/zh-TW.json | 29 +- src/i18n/locales/zh.json | 5 +- 5 files changed, 299 insertions(+), 220 deletions(-) diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index ac1101d9b..ed29245b5 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -26,6 +26,7 @@ import { type FetchedModel, } from "@/lib/api/model-fetch"; import { CustomUserAgentField } from "./CustomUserAgentField"; +import { cn } from "@/lib/utils"; import type { CodexApiFormat, CodexCatalogModel, @@ -140,7 +141,6 @@ export function CodexFormFields({ const [fetchedModels, setFetchedModels] = useState([]); const [isFetchingModels, setIsFetchingModels] = useState(false); - const [reasoningExpanded, setReasoningExpanded] = useState(false); const needsLocalRouting = apiFormat === "openai_chat"; const canEditCatalog = Boolean(onCatalogModelsChange); const canEditReasoning = Boolean(onCodexChatReasoningChange); @@ -149,6 +149,17 @@ export function CodexFormFields({ codexChatReasoning.supportsEffort === true; const supportsEffort = codexChatReasoning.supportsEffort === true; + // needsLocalRouting 非默认值说明预设/用户动过路由配置,需要让模型映射保持可见 + const hasAnyAdvancedValue = !!customUserAgent || needsLocalRouting; + const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue); + + // 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠) + useEffect(() => { + if (hasAnyAdvancedValue) { + setAdvancedExpanded(true); + } + }, [hasAnyAdvancedValue]); + const [catalogRows, setCatalogRows] = useState(() => catalogModels.map((m) => createCatalogRow(m)), ); @@ -334,42 +345,11 @@ export function CodexFormFields({ /> )} - {shouldShowSpeedTest && ( -
-
-
- - {t("codexConfig.localRoutingToggle", { - defaultValue: "需要本地路由映射", - })} - -

- {needsLocalRouting - ? t("codexConfig.localRoutingOnHint", { - defaultValue: - "Codex 目前仅原生支持 OpenAI Responses API 与 GPT 系列模型;如果您的供应商使用 Chat Completions 协议或非 GPT 模型(如 DeepSeek、Kimi),则需要打开本开关,并在使用过程中保持本地路由开启。", - }) - : t("codexConfig.localRoutingOffHint", { - defaultValue: - "如果您的供应商不是原生 OpenAI Responses API,或者模型名不是 Codex 默认的 GPT 系列,请打开此开关。", - })} -

-
- -
-
- )} - - {needsLocalRouting && canEditReasoning && ( + {/* 高级选项 —— 本地路由映射/模型映射/思考能力/自定义 UA;预设供应商通常无需展开 */} + {category !== "official" && ( @@ -379,213 +359,282 @@ export function CodexFormFields({ size="sm" className="h-8 w-full justify-start gap-1.5 px-0 text-sm font-medium text-foreground hover:opacity-70" > - {reasoningExpanded ? ( + {advancedExpanded ? ( ) : ( )} - {t("codexConfig.reasoningSectionToggle", { - defaultValue: "思考能力(高级·通常自动识别)", + {t("providerForm.advancedOptionsToggle", { + defaultValue: "高级选项", })} - {!reasoningExpanded && ( + {!advancedExpanded && (

- {t("codexConfig.reasoningSectionHint", { + {t("codexConfig.advancedSectionHint", { defaultValue: - "预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需展开手动覆盖。", + "包含本地路由映射、模型映射、思考能力与自定义 User-Agent。供应商使用 Chat Completions 协议或非 GPT 模型时,需在此开启本地路由映射。", })}

)} -
-
- - {t("codexConfig.reasoningModeToggle", { - defaultValue: "支持思考模式", + {/* 本地路由映射开关 —— 沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换 */} + {shouldShowSpeedTest && ( +
+
+ + {t("codexConfig.localRoutingToggle", { + defaultValue: "需要本地路由映射", + })} + +

+ {needsLocalRouting + ? t("codexConfig.localRoutingOnHint", { + defaultValue: + "Codex 目前仅原生支持 OpenAI Responses API 与 GPT 系列模型;如果您的供应商使用 Chat Completions 协议或非 GPT 模型(如 DeepSeek、Kimi),则需要打开本开关,并在使用过程中保持本地路由开启。", + }) + : t("codexConfig.localRoutingOffHint", { + defaultValue: + "如果您的供应商不是原生 OpenAI Responses API,或者模型名不是 Codex 默认的 GPT 系列,请打开此开关。", + })} +

+
+ -

- {t("codexConfig.reasoningModeHint", { - defaultValue: - "上游 Chat Completions 接口支持开启或关闭 thinking 时启用。Kimi、GLM、Qwen 等通常属于这一类。", - })} -

+ />
- -
+ )} -
-
- - {t("codexConfig.reasoningEffortToggle", { - defaultValue: "支持思考等级", - })} - -

- {t("codexConfig.reasoningEffortHint", { - defaultValue: - "上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。", - })} -

+ {needsLocalRouting && canEditReasoning && ( +
+
+ + {t("codexConfig.reasoningGroupTitle", { + defaultValue: "思考能力", + })} + +

+ {t("codexConfig.reasoningSectionHint", { + defaultValue: + "预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需手动覆盖。", + })} +

+
+ +
+
+ + {t("codexConfig.reasoningModeToggle", { + defaultValue: "支持思考模式", + })} + +

+ {t("codexConfig.reasoningModeHint", { + defaultValue: + "上游 Chat Completions 接口支持开启或关闭 thinking 时启用。Kimi、GLM、Qwen 等通常属于这一类。", + })} +

+
+ +
+ +
+
+ + {t("codexConfig.reasoningEffortToggle", { + defaultValue: "支持思考等级", + })} + +

+ {t("codexConfig.reasoningEffortHint", { + defaultValue: + "上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。", + })} +

+
+ +
- -
+ )} -
+
- - - )} - {/* Codex 模型映射 —— 仅在本地路由 + 可编辑时显示 */} - {needsLocalRouting && canEditCatalog && ( -
-
-
- - {t("codexConfig.modelMappingTitle", { - defaultValue: "模型映射", - })} - - {renderCatalogActionButtons( - handleAddCatalogRow, - t("codexConfig.addCatalogModel", { - defaultValue: "添加模型", - }), - )} -
-

- {t("codexConfig.modelMappingHint", { - defaultValue: - "选择模型角色后,CC Switch 会自动生成 Codex 兼容路由;菜单显示名可以填 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。", - })} -

-
- - {catalogRows.length > 0 && ( -
- {/* 列头:md+ 显示 */} -
- - {t("codexConfig.catalogColumnDisplay", { - defaultValue: "菜单显示名", - })} - - - {t("codexConfig.catalogColumnModel", { - defaultValue: "实际请求模型", - })} - - - {t("codexConfig.catalogColumnContext", { - defaultValue: "上下文窗口", - })} - - -
- - {catalogRows.map((row, index) => ( -
- - handleUpdateCatalogRow(index, { - displayName: event.target.value, - }) - } - placeholder={t( - "codexConfig.catalogDisplayNamePlaceholder", - { - defaultValue: "例如: DeepSeek V4 Flash", - }, - )} - aria-label={t("codexConfig.catalogColumnDisplay", { - defaultValue: "菜单显示名", - })} - /> -
- - handleUpdateCatalogRow(index, { - model: event.target.value, - }) - } - placeholder={t("codexConfig.catalogModelPlaceholder", { - defaultValue: "例如: deepseek-v4-flash", + {/* 模型映射 —— 仅在本地路由 + 可编辑时显示;上方恒有 UA 字段,分隔线无需条件 */} + {needsLocalRouting && canEditCatalog && ( +
+
+
+ + {t("codexConfig.modelMappingTitle", { + defaultValue: "模型映射", })} - aria-label={t("codexConfig.catalogColumnModel", { - defaultValue: "实际请求模型", - })} - className="flex-1" - /> - {fetchedModels.length > 0 && ( - - handleUpdateCatalogRow(index, { - model: id, - displayName: row.displayName?.trim() - ? row.displayName - : id, - }) - } - /> + + {renderCatalogActionButtons( + handleAddCatalogRow, + t("codexConfig.addCatalogModel", { + defaultValue: "添加模型", + }), )}
- - handleUpdateCatalogRow(index, { - contextWindow: event.target.value.replace(/[^\d]/g, ""), - }) - } - placeholder={t("codexConfig.contextWindowPlaceholder", { - defaultValue: "例如: 128000", +

+ {t("codexConfig.modelMappingHint", { + defaultValue: + "选择模型角色后,CC Switch 会自动生成 Codex 兼容路由;菜单显示名可以填 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。", })} - aria-label={t("codexConfig.catalogColumnContext", { - defaultValue: "上下文窗口", - })} - /> - +

- ))} -
- )} -
+ + {catalogRows.length > 0 && ( +
+ {/* 列头:md+ 显示 */} +
+ + {t("codexConfig.catalogColumnDisplay", { + defaultValue: "菜单显示名", + })} + + + {t("codexConfig.catalogColumnModel", { + defaultValue: "实际请求模型", + })} + + + {t("codexConfig.catalogColumnContext", { + defaultValue: "上下文窗口", + })} + + +
+ + {catalogRows.map((row, index) => ( +
+ + handleUpdateCatalogRow(index, { + displayName: event.target.value, + }) + } + placeholder={t( + "codexConfig.catalogDisplayNamePlaceholder", + { + defaultValue: "例如: DeepSeek V4 Flash", + }, + )} + aria-label={t("codexConfig.catalogColumnDisplay", { + defaultValue: "菜单显示名", + })} + /> +
+ + handleUpdateCatalogRow(index, { + model: event.target.value, + }) + } + placeholder={t( + "codexConfig.catalogModelPlaceholder", + { + defaultValue: "例如: deepseek-v4-flash", + }, + )} + aria-label={t("codexConfig.catalogColumnModel", { + defaultValue: "实际请求模型", + })} + className="flex-1" + /> + {fetchedModels.length > 0 && ( + + handleUpdateCatalogRow(index, { + model: id, + displayName: row.displayName?.trim() + ? row.displayName + : id, + }) + } + /> + )} +
+ + handleUpdateCatalogRow(index, { + contextWindow: event.target.value.replace( + /[^\d]/g, + "", + ), + }) + } + placeholder={t( + "codexConfig.contextWindowPlaceholder", + { + defaultValue: "例如: 128000", + }, + )} + aria-label={t("codexConfig.catalogColumnContext", { + defaultValue: "上下文窗口", + })} + /> + +
+ ))} +
+ )} +
+ )} + + )} {/* 端点测速弹窗 - Codex */} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f2ad7d2fb..2a681512f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1261,8 +1261,9 @@ "reasoningModeHint": "Enable when the upstream Chat Completions API supports toggling thinking on/off. Providers like Kimi, GLM and Qwen usually fall into this category.", "reasoningEffortToggle": "Supports Reasoning Effort", "reasoningEffortHint": "Enable when the upstream supports thinking-depth control such as low/high/max. Enabling this also turns on thinking mode and converts Codex's reasoning.effort into the upstream Chat parameter.", - "reasoningSectionToggle": "Reasoning Capability (Advanced · usually auto-detected)", - "reasoningSectionHint": "Preset providers are configured automatically; custom providers are inferred from name/URL. Expand to override manually only when auto-detection is wrong." + "reasoningGroupTitle": "Reasoning Capability", + "reasoningSectionHint": "Preset providers are configured automatically; custom providers are inferred from name/URL. Override manually only when auto-detection is wrong.", + "advancedSectionHint": "Includes local routing, model mapping, reasoning overrides and custom User-Agent. Enable local routing here when your provider uses the Chat Completions protocol or non-GPT models." }, "geminiConfig": { "envFile": "Environment Variables (.env)", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index eed2d090e..814322c09 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1261,8 +1261,9 @@ "reasoningModeHint": "上流の Chat Completions API が思考(thinking)のオン/オフ切り替えに対応している場合に有効化します。Kimi、GLM、Qwen などが通常このタイプに該当します。", "reasoningEffortToggle": "思考レベルに対応", "reasoningEffortHint": "上流が low/high/max などの思考の深さの制御に対応している場合に有効化します。有効にすると思考モードも自動的にオンになり、Codex の reasoning.effort を上流の Chat パラメータに変換します。", - "reasoningSectionToggle": "思考能力(詳細・通常は自動識別)", - "reasoningSectionHint": "プリセットの供給元は自動的に設定され、カスタム供給元は名前/URL から自動推論されます。自動識別が正しくない場合のみ展開して手動で上書きしてください。" + "reasoningGroupTitle": "思考能力", + "reasoningSectionHint": "プリセットの供給元は自動的に設定され、カスタム供給元は名前/URL から自動推論されます。自動識別が正しくない場合のみ手動で上書きしてください。", + "advancedSectionHint": "ローカルルーティング、モデルマッピング、思考能力、カスタム User-Agent の設定を含みます。供給元が Chat Completions プロトコルまたは GPT 以外のモデルを使用する場合は、ここでローカルルーティングを有効にしてください。" }, "geminiConfig": { "envFile": "環境変数 (.env)", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 6191fa536..5e81514d6 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1207,7 +1207,34 @@ "modelNamePlaceholder": "例如: gpt-5-codex", "contextWindow1M": "1M 上下文視窗", "autoCompactLimit": "壓縮閾值", - "autoCompactLimitHint": "上下文 token 數達到此閾值時自動壓縮歷史" + "autoCompactLimitHint": "上下文 token 數達到此閾值時自動壓縮歷史", + "autoCompactLimitPlaceholder": "例如: 90000", + "localRoutingToggle": "需要本地路由映射", + "localRoutingOnHint": "Codex 目前僅原生支援 OpenAI Responses API 與 GPT 系列模型;如果您的供應商使用 Chat Completions 協定或非 GPT 模型(如 DeepSeek、Kimi),則需要打開本開關,並在使用過程中保持本地路由開啟。", + "localRoutingOffHint": "如果您的供應商不是原生 OpenAI Responses API,或者模型名不是 Codex 預設的 GPT 系列,請打開此開關。", + "modelMappingTitle": "模型映射", + "modelMappingHint": "產生 Codex model_catalog_json,讓 /model 指令顯示這些第三方模型名;表中條目按填寫內容原樣儲存。修改後需要重新啟動 Codex 才能重新整理模型清單。", + "addCatalogModel": "新增模型", + "catalogDisplayName": "顯示名稱", + "catalogModelId": "模型 ID", + "catalogColumnDisplay": "選單顯示名", + "catalogColumnModel": "實際請求模型", + "catalogColumnContext": "上下文視窗", + "catalogDisplayNamePlaceholder": "例如: DeepSeek V4 Flash", + "catalogModelPlaceholder": "例如: deepseek-v4-flash", + "contextWindow": "上下文視窗", + "contextWindowPlaceholder": "例如: 128000", + "upstreamModelName": "上游模型名稱", + "upstreamModelNameHint": "Chat 格式下這裡填寫真實上游模型;路由會把 Codex 的 Responses 請求轉換為 Chat Completions 並保持該模型。", + "modelMetadataAdvanced": "模型中繼資料進階選項", + "modelMetadataAdvancedHint": "用於未知模型的 Codex 中繼資料覆寫;清空欄位會從 config.toml 刪除對應設定。", + "reasoningModeToggle": "支援思考模式", + "reasoningModeHint": "上游 Chat Completions 介面支援開啟或關閉 thinking 時啟用。Kimi、GLM、Qwen 等通常屬於這一類。", + "reasoningEffortToggle": "支援思考等級", + "reasoningEffortHint": "上游支援 low/high/max 等思考深度控制時啟用。啟用後會自動啟用思考模式,並把 Codex 的 reasoning.effort 轉成上游 Chat 參數。", + "reasoningGroupTitle": "思考能力", + "reasoningSectionHint": "預設供應商已自動設定;自訂供應商會按名稱/位址自動推斷。僅當自動識別不準時才需手動覆寫。", + "advancedSectionHint": "包含本地路由映射、模型映射、思考能力與自訂 User-Agent。供應商使用 Chat Completions 協定或非 GPT 模型時,需在此開啟本地路由映射。" }, "geminiConfig": { "envFile": "環境變數 (.env)", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6ffbea292..700bccdbe 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1261,8 +1261,9 @@ "reasoningModeHint": "上游 Chat Completions 接口支持开启或关闭 thinking 时启用。Kimi、GLM、Qwen 等通常属于这一类。", "reasoningEffortToggle": "支持思考等级", "reasoningEffortHint": "上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。", - "reasoningSectionToggle": "思考能力(高级·通常自动识别)", - "reasoningSectionHint": "预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需展开手动覆盖。" + "reasoningGroupTitle": "思考能力", + "reasoningSectionHint": "预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需手动覆盖。", + "advancedSectionHint": "包含本地路由映射、模型映射、思考能力与自定义 User-Agent。供应商使用 Chat Completions 协议或非 GPT 模型时,需在此开启本地路由映射。" }, "geminiConfig": { "envFile": "环境变量 (.env)", From a6d718d0fccdff7832a9482237fc23a6a9ac806c Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 11 Jun 2026 08:29:21 +0800 Subject: [PATCH 26/66] fix(proxy): aggregate mislabeled SSE bodies in transform fallback (#2234) The Claude/Codex format-transform non-stream branch returned an opaque 422 "Failed to parse upstream response" whenever a 2xx upstream body was not valid JSON. The common case: MaaS gateways force-stream a stream:false request and return an SSE body with a non-SSE Content-Type, defeating the header-only is_sse() check. On serde failure, sniff for SSE and aggregate the chunks into a single JSON, then run the existing converter so clients still receive a valid non-stream response. - chat_sse_to_response_value: aggregate chat.completion.chunk SSE (content / reasoning / refusal / tool_calls / legacy function_call), tool_calls index-keyed via BTreeMap to avoid unbounded densification, first-wins finish_reason, message-snapshot override, completeness and error-event guards; synthesize an id when the upstream omits one - responses_sse_to_response_value: process the residual trailing block, tolerating truncation and skipping it once a completed event was seen - enrich remaining parse failures with content-type / content-encoding / body-snippet diagnostics - deflate: try zlib (RFC 9110) before raw; keep the content-encoding header for unsupported encodings - gate zero-usage rows on the Claude transform path --- src-tauri/src/proxy/handlers.rs | 1078 ++++++++++++++++++++- src-tauri/src/proxy/response_processor.rs | 66 +- 2 files changed, 1118 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 9560d849a..c399f1ba0 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -16,6 +16,7 @@ use super::{ }, handler_context::RequestContext, providers::{ + codex_chat_common::extract_reasoning_field_text, codex_chat_history::record_responses_sse_stream, get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream, streaming_codex_chat::create_responses_sse_stream_from_chat_with_context, @@ -431,10 +432,39 @@ async fn handle_claude_transform( let upstream_response: Value = if aggregate_codex_oauth_responses_sse { responses_sse_to_response_value(&body_str)? } else { - serde_json::from_slice(&body_bytes).map_err(|e| { - log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}"); - ProxyError::TransformError(format!("Failed to parse upstream response: {e}")) - })? + match serde_json::from_slice(&body_bytes) { + Ok(value) => value, + // 兜底嗅探(#2234):部分网关对 stream:false 强制返回 SSE 体,却把 + // Content-Type 标成 application/json 等,is_sse() 的 header 检查失效。 + // 此时按 SSE 聚合成单个 JSON 再走既有非流转换器,客户端仍收到 + // Anthropic JSON,非流语义不变。gemini_native 暂无聚合器,落诊断错误。 + Err(_) if body_looks_like_sse(&body_str) && api_format != "gemini_native" => { + log::warn!( + "[Claude] 上游对非流请求返回未标记的 SSE 体(api_format={api_format}),按 SSE 聚合兜底" + ); + let aggregated = if api_format == "openai_responses" { + responses_sse_to_response_value(&body_str) + } else { + chat_sse_to_response_value(&body_str) + }; + // 聚合也失败时:保留全量 body 服务端日志,并给客户端错误附带同款 + // 现场诊断(content-type/body 摘要),否则命中嗅探臂的用户只拿到 + // 裸聚合错误、丢失非嗅探臂已有的诊断增强(C7) + aggregated.map_err(|e| { + log::error!("[Claude] SSE 聚合兜底失败: {e}, body: {body_str}"); + aggregate_fallback_error(e, &response_headers, &body_str) + })? + } + Err(e) => { + log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}"); + return Err(upstream_body_parse_error( + "Failed to parse upstream response", + &e, + &response_headers, + &body_str, + )); + } + } }; // 根据 api_format 选择非流式转换器 @@ -457,7 +487,11 @@ async fn handle_claude_transform( })?; // 记录使用量 - if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) { + // 全 0 usage 不落账(对齐 Codex 流式收集器的 skip):SSE 聚合兜底救回的流 + // 在上游缺 stream_options.include_usage 时没有 usage,写入只会产生无意义空行 + if let Some(usage) = + TokenUsage::from_claude_response(&anthropic_response).filter(|u| u.has_billable_tokens()) + { // 转换后的响应缺失/合成空 model 时,回退到映射后的出站模型(接管真值), // 再回退到客户端请求别名 let model = anthropic_response @@ -877,10 +911,28 @@ async fn handle_codex_chat_to_responses_transform( let (mut response_headers, status, body_bytes) = read_decoded_body(response, ctx.tag, body_timeout).await?; let body_str = String::from_utf8_lossy(&body_bytes); - let chat_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| { - log::error!("[Codex] 解析 Chat 上游响应失败: {e}, body: {body_str}"); - ProxyError::TransformError(format!("Failed to parse upstream chat response: {e}")) - })?; + let chat_response: Value = match serde_json::from_slice(&body_bytes) { + Ok(value) => value, + // 与 Claude 侧 handle_claude_transform 对称的兜底嗅探(#2234): + // 上游对 stream:false 返回未标记 Content-Type 的 SSE 体时按 SSE 聚合。 + Err(_) if body_looks_like_sse(&body_str) => { + log::warn!("[Codex] 上游对非流请求返回未标记的 SSE 体,按 Chat SSE 聚合兜底"); + // 聚合也失败时:保留全量 body 服务端日志,并给客户端错误附带现场诊断(C7) + chat_sse_to_response_value(&body_str).map_err(|e| { + log::error!("[Codex] SSE 聚合兜底失败: {e}, body: {body_str}"); + aggregate_fallback_error(e, &response_headers, &body_str) + })? + } + Err(e) => { + log::error!("[Codex] 解析 Chat 上游响应失败: {e}, body: {body_str}"); + return Err(upstream_body_parse_error( + "Failed to parse upstream chat response", + &e, + &response_headers, + &body_str, + )); + } + }; let responses_response = transform_codex_chat::chat_completion_to_response_with_context( chat_response, &tool_context, @@ -1320,15 +1372,24 @@ fn should_use_claude_transform_streaming( /// 复用 `proxy::sse` 的 `take_sse_block`/`strip_sse_field`:`take_sse_block` 同时支持 /// `\n\n` 与 `\r\n\r\n` 两种分隔符,`strip_sse_field` 兼容带/不带空格的字段写法。 fn responses_sse_to_response_value(body: &str) -> Result { - let mut buffer = body.to_string(); + let mut buffer = body.trim_start_matches('\u{feff}').to_string(); let mut completed_response: Option = None; let mut output_items = Vec::new(); - while let Some(block) = take_sse_block(&mut buffer) { + // strict=false 用于残余尾块:截断的半截 JSON 忽略而非报错,避免破坏 + // 已聚合好的完整响应(codex_oauth 聚合路径也复用本函数) + let mut process_block = |block: &str, strict: bool| -> Result<(), ProxyError> { + // 残余尾块(strict=false)在已拿到 completed 后整体跳过——codex_oauth 聚合 + // 路径也复用本函数,已完成后再执行残余里的完整 response.failed/杂事件会把 + // 成功响应翻成 422(C8)。 + if !strict && completed_response.is_some() { + return Ok(()); + } let mut event_name = ""; let mut data_lines: Vec<&str> = Vec::new(); for line in block.lines() { + let line = line.trim_start(); if let Some(evt) = strip_sse_field(line, "event") { event_name = evt.trim(); } else if let Some(d) = strip_sse_field(line, "data") { @@ -1337,17 +1398,23 @@ fn responses_sse_to_response_value(body: &str) -> Result { } if data_lines.is_empty() { - continue; + return Ok(()); } let data_str = data_lines.join("\n"); if data_str.trim() == "[DONE]" { - continue; + return Ok(()); } - let data: Value = serde_json::from_str(&data_str).map_err(|e| { - ProxyError::TransformError(format!("Failed to parse upstream SSE event: {e}")) - })?; + let data: Value = match serde_json::from_str(&data_str) { + Ok(v) => v, + Err(_) if !strict => return Ok(()), + Err(e) => { + return Err(ProxyError::TransformError(format!( + "Failed to parse upstream SSE event: {e}" + ))) + } + }; match event_name { "response.output_item.done" => { @@ -1367,7 +1434,16 @@ fn responses_sse_to_response_value(body: &str) -> Result { } _ => {} } + Ok(()) + }; + + while let Some(block) = take_sse_block(&mut buffer) { + process_block(&block, true)?; } + // 最后一个事件后可能没有空行分隔(错标 SSE 兜底/非规范上游常见): + // 残余 buffer 当最后一块处理,否则尾部的 response.completed 会被丢掉。 + // 已完成时的跳过判定在闭包内(C8)。 + process_block(&buffer, false)?; let mut response = completed_response.ok_or_else(|| { ProxyError::TransformError("No response.completed event in upstream SSE".to_string()) @@ -1386,6 +1462,448 @@ fn responses_sse_to_response_value(body: &str) -> Result { Ok(response) } +/// 判断响应体是否"看起来像" SSE 文本(#2234 兜底嗅探)。 +/// +/// 仅在 JSON 解析已失败后调用:合法 JSON 不可能以这些前缀开头,误判面为零。 +/// 覆盖 SSE 规范的全部四种字段行;包含 ":" 是因为 OpenRouter 等会在流前发 +/// `: PROCESSING` 注释行。 +fn body_looks_like_sse(body: &str) -> bool { + let trimmed = body.trim_start_matches('\u{feff}').trim_start(); + ["data:", "event:", "id:", "retry:", ":"] + .iter() + .any(|prefix| trimmed.starts_with(prefix)) +} + +/// 构造带现场诊断的上游解析错误:附 content-type / content-encoding 与 body +/// 前缀摘要,让客户端收到的报错自带根因判别("data:"=错标 SSE、"<"=HTML +/// 拦截页、� 乱码=未解压二进制),不再依赖向用户索要服务端日志。 +fn upstream_body_parse_error( + prefix: &str, + err: &serde_json::Error, + headers: &axum::http::HeaderMap, + body: &str, +) -> ProxyError { + ProxyError::TransformError(format!( + "{prefix}: {err} {}", + body_diagnostics_suffix(headers, body) + )) +} + +/// SSE 聚合兜底失败时,给聚合器内部错误附加同款现场诊断(content-type/ +/// content-encoding/body 摘要),使命中 #2234 嗅探臂的客户端也拿到根因线索, +/// 而非仅 "No chat completion choices in upstream SSE" 这类无 header/body 的裸消息。 +fn aggregate_fallback_error( + err: ProxyError, + headers: &axum::http::HeaderMap, + body: &str, +) -> ProxyError { + let base = match &err { + ProxyError::TransformError(m) => m.clone(), + other => other.to_string(), + }; + ProxyError::TransformError(format!("{base} {}", body_diagnostics_suffix(headers, body))) +} + +/// 现场诊断后缀:content-type、content-encoding 与 body 前 120 字符摘要。 +fn body_diagnostics_suffix(headers: &axum::http::HeaderMap, body: &str) -> String { + let header_str = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + }; + format!( + "(content-type: {}; content-encoding: {}; body[..120]: '{}')", + header_str("content-type"), + header_str("content-encoding"), + body_snippet(body, 120), + ) +} + +/// 从 SSE chunk 的 error 字段提取可报告的错误消息。占位形状(空对象、空消息、 +/// false、空字符串等,常见于 OpenAI 兼容网关每 chunk 附带的 error 字段)返回 +/// None——不应据此判定整条流失败(否则会把成功流误杀成 422,C12/C2234 目标人群)。 +fn error_event_message(error: &Value) -> Option { + if let Some(msg) = error.get("message").and_then(|m| m.as_str()) { + return (!msg.is_empty()).then(|| msg.to_string()); + } + if let Some(s) = error.as_str() { + return (!s.is_empty()).then(|| s.to_string()); + } + None +} + +/// 取 body 前 `max_chars` 个字符的单行摘要:\r 丢弃、\n 折叠为字面 \n、 +/// 其余控制字符替换为 �,超长加省略号。 +fn body_snippet(body: &str, max_chars: usize) -> String { + let mut snippet = String::new(); + for c in body.chars().take(max_chars) { + match c { + '\n' => snippet.push_str("\\n"), + '\r' => {} + c if c.is_control() => snippet.push('\u{FFFD}'), + c => snippet.push(c), + } + } + if body.chars().nth(max_chars).is_some() { + snippet.push('…'); + } + snippet +} + +/// 解析单个 SSE 块的 event 名与 data 负载(多行 data 按规范以 \n 连接)。 +/// 行首允许前导空白后再匹配字段名——与 body_looks_like_sse 的 trim 宽容度对齐, +/// 否则缩进的 ` data:` 行被嗅探接受却在此静默丢失(C4)。返回 None 表示无 data 行。 +fn sse_block_parts(block: &str) -> Option<(String, String)> { + let mut event_name = String::new(); + let mut data_lines: Vec<&str> = Vec::new(); + for line in block.lines() { + let line = line.trim_start(); + if let Some(evt) = strip_sse_field(line, "event") { + event_name = evt.trim().to_string(); + } else if let Some(d) = strip_sse_field(line, "data") { + data_lines.push(d); + } + } + (!data_lines.is_empty()).then(|| (event_name, data_lines.join("\n"))) +} + +/// 把 Chat Completions 流式 SSE 聚合为单个 chat.completion JSON(#2234 兜底)。 +/// +/// 专供非流式分支使用:上游对 stream:false 返回了 SSE 体但 Content-Type 没标 +/// text/event-stream,header 检查(is_sse)失效。聚合后喂给既有非流转换器 +/// (Claude 侧 openai_to_anthropic、Codex 侧 chat_completion_to_response_with_context), +/// 客户端拿到的仍是合法 JSON,非流语义不变。 +/// 增量合并语义与 providers/streaming.rs 对齐:tool_calls 按 delta.index 定位, +/// id/name 出现即覆盖、arguments 字符串拼接;reasoning 各形态(reasoning_content / +/// reasoning / reasoning_details)经 codex_chat_common 公共提取器并入同一累加器; +/// finish_reason 首个非 null 即锁定(kimi-k2.6 会在 tool_use 后再发带 +/// finish_reason 的尾块,见 streaming.rs)。 +fn chat_sse_to_response_value(body: &str) -> Result { + // 剥 BOM:嗅探器接受 BOM 开头,但 strip_sse_field 按行首精确匹配, + // 不剥会让首个 data 行静默丢失 + let mut buffer = body.trim_start_matches('\u{feff}').to_string(); + + let mut id = Value::Null; + let mut created = Value::Null; + let mut model = Value::Null; + let mut content = String::new(); + let mut reasoning_content = String::new(); + // tool_calls 以 BTreeMap 按 index 聚合:上游可控的 index(u64)不会 densify + // 数组——旧的 `while len() <= index { push }` 写法遇到 index=4e9 会 OOM 整个 + // 进程(C1)。BTreeMap 既免去无界分配,又天然保持 index 有序输出。 + let mut tool_calls: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + let mut finish_reason = Value::Null; + let mut usage = Value::Null; + let mut saw_choice = false; + let mut saw_done = false; + + // strict=false 用于残余尾块:截断的半截 JSON 忽略而非报错,与 + // responses_sse_to_response_value 的残余处理对称(C2),否则一个被掐断的 + // 尾块会把已聚合完整的响应误杀成 422。 + let mut process_event = + |event_name: &str, data_str: &str, strict: bool| -> Result<(), ProxyError> { + let trimmed = data_str.trim(); + if trimmed == "[DONE]" { + saw_done = true; + return Ok(()); + } + if trimmed.is_empty() { + return Ok(()); + } + let chunk: Value = match serde_json::from_str(data_str) { + Ok(v) => v, + Err(_) if !strict => return Ok(()), + Err(e) => { + return Err(ProxyError::TransformError(format!( + "Failed to parse upstream SSE chunk: {e}" + ))) + } + }; + + // `event: error` 事件:错误由事件名标记,data 体未必有 error 键(直接是 + // 错误对象)。即便此前已聚合完整 choice 也要据此判失败,否则会把网关的 + // 配额/限流错误伪装成成功(C18)。 + if event_name.eq_ignore_ascii_case("error") { + let message = chunk + .get("error") + .and_then(error_event_message) + .or_else(|| error_event_message(&chunk)) + .unwrap_or_else(|| "upstream error event in SSE stream".to_string()); + return Err(ProxyError::TransformError(message)); + } + // 网关把错误作为普通 data chunk 下发({"error":{...}}):仅在 error 含 + // 可报告消息时判失败。空对象 / 空消息 / null / false 等占位形状(部分 + // OpenAI 兼容网关每 chunk 都带)不能据此误杀成功流(C12)。 + if let Some(message) = chunk + .get("error") + .filter(|e| !e.is_null()) + .and_then(error_event_message) + { + return Err(ProxyError::TransformError(message)); + } + + // 首个"有意义"的值锁定 envelope。Azure 的 content-filter 前置块带 + // ""/0 占位(streaming.rs 有同款空串守卫),不能让占位值冻结字段 + for (slot, key) in [ + (&mut id, "id"), + (&mut created, "created"), + (&mut model, "model"), + ] { + if slot.is_null() { + if let Some(v) = chunk.get(key).filter(|v| envelope_value_meaningful(v)) { + *slot = v.clone(); + } + } + } + // OpenAI 语义:usage 只在最终 chunk 非 null + if let Some(u) = chunk.get("usage").filter(|u| !u.is_null()) { + usage = u.clone(); + } + + // 代理上下文只存在单选择(n=1),仅聚合 index==0 的 choice + let Some(choice) = chunk + .get("choices") + .and_then(|c| c.as_array()) + .and_then(|arr| { + arr.iter() + .find(|ch| ch.get("index").and_then(|i| i.as_u64()).unwrap_or(0) == 0) + }) + else { + return Ok(()); + }; + + // "见过响应"的证据必须是 choice payload:metadata/usage-only chunk + + // [DONE] 的流(全程无 choice)若也算数,会绕过下方两道守卫、 + // 包装出空内容假成功 + saw_choice = true; + + // finish_reason 首个非 null 即锁定(对齐 streaming.rs 的 first-wins: + // 多 finish_reason 上游的尾块 "stop" 不能覆盖先到的 "tool_calls") + if finish_reason.is_null() { + if let Some(fr) = choice.get("finish_reason").filter(|v| !v.is_null()) { + finish_reason = fr.clone(); + } + } + // payload 选择:正常增量走 delta;但假流式中转会把完整 chat.completion + // 包成单事件(message 而非 delta),有的还附带空 delta:{}。delta 为空对象 + // 且存在 message 时改用 message 快照(覆盖此前累计的增量,防混合形态双计), + // 否则内容被静默丢弃、完成性守卫又被其 finish_reason 击穿 → 空内容假成功(C3)。 + let delta_nonempty = choice + .get("delta") + .and_then(|d| d.as_object()) + .is_some_and(|o| !o.is_empty()); + let (payload, is_full_message) = if delta_nonempty { + (choice.get("delta").unwrap(), false) + } else if let Some(message) = choice.get("message") { + (message, true) + } else if let Some(delta) = choice.get("delta") { + // 空 delta 且无 message:正常的纯 finish_reason 收尾块 + (delta, false) + } else { + return Ok(()); + }; + if is_full_message { + content.clear(); + reasoning_content.clear(); + tool_calls.clear(); + } + match payload.get("content") { + Some(Value::String(text)) => content.push_str(text), + Some(Value::Array(parts)) => { + for part in parts { + if let Some(text) = part.get("text").and_then(|t| t.as_str()) { + content.push_str(text); + } else if let Some(refusal) = part.get("refusal").and_then(|r| r.as_str()) { + content.push_str(refusal); + } + } + } + _ => {} + } + // refusal:OpenAI 官方拒绝形态(delta.refusal / message.refusal 字符串)。 + // 两个下游转换器都把 refusal 当可见内容,漏读会让拒绝响应变空消息假成功(C15)。 + if let Some(refusal) = payload.get("refusal").and_then(|r| r.as_str()) { + content.push_str(refusal); + } + // reasoning 字段穷举提取直接复用 codex_chat_common(reasoning_content > + // reasoning 字符串/对象 > reasoning_details),避免第三份手写实现漏档: + // MiMo/OpenRouter 等只发 reasoning_details 的 provider 否则会丢思考内容 + if let Some(text) = extract_reasoning_field_text(payload) { + reasoning_content.push_str(&text); + } + if let Some(deltas) = payload.get("tool_calls").and_then(|t| t.as_array()) { + for (pos, tc) in deltas.iter().enumerate() { + merge_tool_call_delta(&mut tool_calls, tc, pos); + } + } else if let Some(fc) = payload.get("function_call").filter(|v| !v.is_null()) { + // legacy function_call(2023 弃用但仍有中转回传)→ 当单个 tool_call。 + // 两个下游转换器都支持 function_call,漏读会让 finish_reason + // "function_call"→stop_reason "tool_use" 却零工具块、卡死 agent 循环(C17)。 + let synthetic = json!({ + "index": 0, + "id": fc.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "type": "function", + "function": fc, + }); + merge_tool_call_delta(&mut tool_calls, &synthetic, 0); + } + Ok(()) + }; + + while let Some(block) = take_sse_block(&mut buffer) { + if let Some((event, data)) = sse_block_parts(&block) { + process_event(&event, &data, true)?; + } + } + // 最后一个事件后可能没有空行分隔(半截流/非规范上游):残余 buffer 当最后一块 + // 处理,strict=false 容忍被掐断的尾块(C2)。 + if let Some((event, data)) = sse_block_parts(&buffer) { + process_event(&event, &data, false)?; + } + + if !saw_choice { + return Err(ProxyError::TransformError( + "No chat completion choices in upstream SSE".to_string(), + )); + } + // 完成性守卫:close-delimited 响应的中途截断在字节层不可检测,缺少 + // finish_reason 与 [DONE] 两个完成证据时按截断处理,避免把半截内容 + // 包装成"看起来成功"的响应静默返回(比 422 更难诊断的失败形态)。 + if finish_reason.is_null() && !saw_done { + return Err(ProxyError::TransformError( + "Upstream SSE stream appears truncated (no finish_reason or [DONE] marker)".to_string(), + )); + } + + // tool_calls 终结化:全空壳(index 空洞或未收到任何字段)直接丢弃(避免幽灵 + // tool_use);缺 id/name 的按原始 index 回填合成值(对齐 streaming.rs 的 + // tool_call_{idx}/unknown_tool)——空 id 会破坏 Claude 的 tool_use_id ↔ + // tool_result 回程 + let tool_calls: Vec = tool_calls + .into_iter() + .filter(|(_, tc)| { + tc["id"].as_str().is_some_and(|s| !s.is_empty()) + || tc["function"]["name"] + .as_str() + .is_some_and(|s| !s.is_empty()) + || tc["function"]["arguments"] + .as_str() + .is_some_and(|s| !s.is_empty()) + }) + .map(|(index, mut tc)| { + if tc["id"].as_str().is_none_or(str::is_empty) { + tc["id"] = json!(format!("tool_call_{index}")); + } + if tc["function"]["name"].as_str().is_none_or(str::is_empty) { + tc["function"]["name"] = json!("unknown_tool"); + } + tc + }) + .collect(); + + let mut message = serde_json::Map::new(); + message.insert("role".to_string(), json!("assistant")); + message.insert("content".to_string(), json!(content)); + if !reasoning_content.is_empty() { + message.insert("reasoning_content".to_string(), json!(reasoning_content)); + } + if !tool_calls.is_empty() { + message.insert("tool_calls".to_string(), Value::Array(tool_calls)); + } + + // 上游未回传有效 id 时合成 UUID:留 null/"" 会让下游 dedup_request_id 退化为 + // 常量 "session:" 全局碰撞,INSERT OR REPLACE 静默覆盖前序 usage 行、少计成本(C9)。 + let id = if envelope_value_meaningful(&id) { + id + } else { + json!(uuid::Uuid::new_v4().to_string()) + }; + + let mut response = json!({ + "id": id, + "object": "chat.completion", + "created": created, + "model": model, + "choices": [{ + "index": 0, + "message": Value::Object(message), + "finish_reason": finish_reason, + }], + }); + if !usage.is_null() { + response["usage"] = usage; + } + Ok(response) +} + +/// envelope 字段是否"有意义":过滤 null、空串与数值 0(含浮点 0.0——Azure +/// content-filter 前置块的占位值),避免占位值抢先冻结 id/model/created。 +fn envelope_value_meaningful(v: &Value) -> bool { + match v { + Value::Null => false, + Value::String(s) => !s.is_empty(), + Value::Number(n) => n.as_f64() != Some(0.0), + _ => true, + } +} + +/// 合并单条 tool_calls 增量到按 index 聚合的 BTreeMap:OpenAI 流式把 id/name 放 +/// 首个增量、arguments 分片下发,按 delta.index 定位目标;缺 index 时退到所在数组 +/// 中的位置(message 形态的完整 tool_calls 常不带 index,按 0 会互相覆盖)。 +fn merge_tool_call_delta( + tool_calls: &mut std::collections::BTreeMap, + delta: &Value, + fallback_index: usize, +) { + let index = delta + .get("index") + .and_then(|i| i.as_u64()) + .map(|i| i as usize) + .unwrap_or(fallback_index); + let target = tool_calls.entry(index).or_insert_with(|| { + json!({ + "id": "", + "type": "function", + "function": {"name": "", "arguments": ""} + }) + }); + if let Some(v) = delta + .get("id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + target["id"] = json!(v); + } + if let Some(func) = delta.get("function") { + if let Some(name) = func + .get("name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + target["function"]["name"] = json!(name); + } + // arguments:string 直接拼接;object/array 序列化后拼接——非流 message + // 快照常把 arguments 作对象回传(OpenAI 兼容偏差),只认 string 会丢参数 + // 致工具空输入执行(C16) + match func.get("arguments") { + Some(Value::String(args)) => { + if let Some(existing) = target["function"]["arguments"].as_str() { + target["function"]["arguments"] = json!(format!("{existing}{args}")); + } + } + Some(v @ (Value::Object(_) | Value::Array(_))) => { + let serialized = serde_json::to_string(v).unwrap_or_default(); + if let Some(existing) = target["function"]["arguments"].as_str() { + target["function"]["arguments"] = json!(format!("{existing}{serialized}")); + } + } + _ => {} + } + } +} + // ============================================================================ // 使用量记录(保留用于 Claude 转换逻辑) // ============================================================================ @@ -1479,11 +1997,535 @@ async fn log_usage( #[cfg(test)] mod tests { use super::{ - codex_proxy_error_json, responses_sse_to_response_value, - should_use_claude_transform_streaming, + body_looks_like_sse, body_snippet, chat_sse_to_response_value, codex_proxy_error_json, + responses_sse_to_response_value, should_use_claude_transform_streaming, transform, + upstream_body_parse_error, }; use crate::proxy::ProxyError; + #[test] + fn body_looks_like_sse_detects_unlabeled_sse_prefixes() { + assert!(body_looks_like_sse("data: {\"id\":\"1\"}\n\n")); + assert!(body_looks_like_sse("event: message\ndata: {}\n\n")); + // SSE 规范的另两种字段行也可能打头 + assert!(body_looks_like_sse("id: 1\ndata: {}\n\n")); + assert!(body_looks_like_sse("retry: 3000\ndata: {}\n\n")); + // OpenRouter 会在流前发注释行 + assert!(body_looks_like_sse( + ": OPENROUTER PROCESSING\n\ndata: {}\n\n" + )); + // BOM + 前导空白 + assert!(body_looks_like_sse("\u{feff}\n data: {}\n\n")); + // HTML 拦截页与普通文本不应误判为 SSE + assert!(!body_looks_like_sse("blocked")); + assert!(!body_looks_like_sse("Bad Gateway")); + assert!(!body_looks_like_sse("")); + } + + #[test] + fn upstream_body_parse_error_carries_field_diagnostics() { + let mut headers = axum::http::HeaderMap::new(); + headers.insert("content-type", "text/html".parse().unwrap()); + headers.insert("content-encoding", "gzip".parse().unwrap()); + let parse_err = serde_json::from_str::("").unwrap_err(); + + let err = upstream_body_parse_error( + "Failed to parse upstream response", + &parse_err, + &headers, + "\nblocked", + ); + + match err { + ProxyError::TransformError(msg) => { + assert!(msg.contains("content-type: text/html"), "{msg}"); + assert!(msg.contains("content-encoding: gzip"), "{msg}"); + assert!(msg.contains("\\nblocked"), "{msg}"); + } + other => panic!("expected TransformError, got {other:?}"), + } + } + + #[test] + fn upstream_body_parse_error_marks_missing_headers() { + let headers = axum::http::HeaderMap::new(); + let parse_err = serde_json::from_str::("data:").unwrap_err(); + + let err = upstream_body_parse_error("x", &parse_err, &headers, "data: oops"); + + match err { + ProxyError::TransformError(msg) => { + assert!(msg.contains("content-type: "), "{msg}"); + assert!(msg.contains("content-encoding: "), "{msg}"); + } + other => panic!("expected TransformError, got {other:?}"), + } + } + + #[test] + fn chat_sse_to_response_value_collects_reasoning_alias() { + // OpenRouter/Kimi 用 reasoning(字符串),部分网关用对象形态 + let sse = "data: {\"id\":\"c1\",\"model\":\"kimi-k2.6\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning\":\"think\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning\":{\"content\":\"ing\"},\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + + assert_eq!( + response["choices"][0]["message"]["reasoning_content"], + "thinking" + ); + assert_eq!(response["choices"][0]["message"]["content"], "ok"); + } + + #[test] + fn chat_sse_to_response_value_collects_reasoning_details() { + // MiMo/OpenRouter 等只发 reasoning_details(数组形态)的 provider, + // 经公共提取器兜底,不能丢思考内容 + let sse = "data: {\"id\":\"c1\",\"model\":\"mimo\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"think\"}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"ing\"}],\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + + assert_eq!( + response["choices"][0]["message"]["reasoning_content"], + "thinking" + ); + assert_eq!(response["choices"][0]["message"]["content"], "ok"); + } + + #[test] + fn responses_sse_to_response_value_handles_missing_trailing_blank_line() { + // 错标 SSE 兜底/非规范上游:最后的 response.completed 后没有空行分隔 + let sse = "event: response.completed\n\ +data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_tail\",\"status\":\"completed\",\"model\":\"gpt-5.4\",\"output\":[],\"usage\":{\"input_tokens\":3,\"output_tokens\":1}}}\n"; + + let response = responses_sse_to_response_value(sse).unwrap(); + + assert_eq!(response["id"], "resp_tail"); + } + + #[test] + fn responses_sse_to_response_value_ignores_truncated_trailing_block() { + // 截断的残余尾块不能破坏已聚合好的完整响应(codex_oauth 路径复用本函数) + let sse = "event: response.completed\n\ +data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ok\",\"status\":\"completed\",\"model\":\"gpt-5.4\",\"output\":[],\"usage\":{\"input_tokens\":3,\"output_tokens\":1}}}\n\ +\n\ +event: response.extra\n\ +data: {\"type\":\"resp"; + + let response = responses_sse_to_response_value(sse).unwrap(); + + assert_eq!(response["id"], "resp_ok"); + } + + #[test] + fn chat_sse_to_response_value_skips_azure_placeholder_envelope() { + // Azure content-filter 前置块带 ""/0 占位,不能冻结 envelope 字段 + let sse = "data: {\"id\":\"\",\"model\":\"\",\"created\":0,\"object\":\"\",\"choices\":[],\"prompt_filter_results\":[]}\n\n\ +data: {\"id\":\"chatcmpl-real\",\"model\":\"gpt-5.4\",\"created\":42,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + + assert_eq!(response["id"], "chatcmpl-real"); + assert_eq!(response["model"], "gpt-5.4"); + assert_eq!(response["created"], 42); + } + + #[test] + fn chat_sse_to_response_value_tolerates_null_error_field() { + // one-api 系网关每个 chunk 都带 "error": null,不能误判为上游错误 + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"error\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + + assert_eq!(response["choices"][0]["message"]["content"], "hi"); + } + + #[test] + fn chat_sse_to_response_value_first_finish_reason_wins() { + // kimi-k2.6 等会在 tool_use 后再发带 finish_reason 的尾块, + // 尾块 "stop" 不能覆盖先到的 "tool_calls"(对齐 streaming.rs first-wins) + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"f\",\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n\ +data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + + assert_eq!(response["choices"][0]["finish_reason"], "tool_calls"); + } + + #[test] + fn chat_sse_to_response_value_unwraps_message_shaped_fake_stream() { + // 假流式中转把完整 chat.completion 包成单个 SSE 事件(message 而非 delta) + let sse = "data: {\"id\":\"c1\",\"object\":\"chat.completion\",\"model\":\"m\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"full answer\"},\"finish_reason\":\"stop\"}]}\n\n\ +data: [DONE]\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + + assert_eq!(response["choices"][0]["message"]["content"], "full answer"); + assert_eq!(response["choices"][0]["finish_reason"], "stop"); + } + + #[test] + fn chat_sse_to_response_value_message_snapshot_overrides_deltas() { + // 混合形态:先发增量再发完整 message 快照时,快照覆盖增量(防双计) + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"par\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"full\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + + assert_eq!(response["choices"][0]["message"]["content"], "full"); + } + + #[test] + fn chat_sse_to_response_value_backfills_sparse_tool_call_ids() { + // index 空洞的空壳被丢弃;缺 id 的按原始 index 回填 tool_call_{idx} + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"f2\",\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + + let tool_calls = response["choices"][0]["message"]["tool_calls"] + .as_array() + .unwrap(); + assert_eq!(tool_calls.len(), 1, "index 0 的空壳应被丢弃"); + assert_eq!(tool_calls[0]["id"], "tool_call_1"); + assert_eq!(tool_calls[0]["function"]["name"], "f2"); + } + + #[test] + fn chat_sse_to_response_value_strips_bom_before_parsing() { + // 嗅探器接受 BOM,块解析也必须剥掉它,否则首个 data 行静默丢失 + let sse = "\u{feff}data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + + assert_eq!(response["choices"][0]["message"]["content"], "hi"); + } + + #[test] + fn body_snippet_sanitizes_controls_and_truncates() { + assert_eq!( + body_snippet("\r\nblocked\u{0}", 120), + "\\nblocked\u{FFFD}" + ); + let long = "a".repeat(200); + let snippet = body_snippet(&long, 120); + assert_eq!(snippet.chars().count(), 121); // 120 个字符 + 省略号 + assert!(snippet.ends_with('…')); + } + + #[test] + fn chat_sse_to_response_value_aggregates_text_finish_reason_and_usage() { + let sse = "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":123,\"model\":\"gpt-5.4\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hel\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"lo\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":2,\"total_tokens\":12}}\n\n\ +data: [DONE]\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + + assert_eq!(response["id"], "chatcmpl-1"); + assert_eq!(response["object"], "chat.completion"); + assert_eq!(response["model"], "gpt-5.4"); + assert_eq!(response["choices"][0]["message"]["role"], "assistant"); + assert_eq!(response["choices"][0]["message"]["content"], "Hello"); + assert_eq!(response["choices"][0]["finish_reason"], "stop"); + assert_eq!(response["usage"]["prompt_tokens"], 10); + } + + #[test] + fn chat_sse_to_response_value_merges_tool_call_argument_fragments() { + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\":\"}}]},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"SF\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n\ +data: [DONE]\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + + let tool_call = &response["choices"][0]["message"]["tool_calls"][0]; + assert_eq!(tool_call["id"], "call_1"); + assert_eq!(tool_call["function"]["name"], "get_weather"); + assert_eq!(tool_call["function"]["arguments"], "{\"city\":\"SF\"}"); + assert_eq!(response["choices"][0]["finish_reason"], "tool_calls"); + } + + #[test] + fn chat_sse_to_response_value_collects_reasoning_content() { + let sse = "data: {\"id\":\"c1\",\"model\":\"deepseek-r2\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"think\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"ing\",\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + + assert_eq!( + response["choices"][0]["message"]["reasoning_content"], + "thinking" + ); + assert_eq!(response["choices"][0]["message"]["content"], "ok"); + } + + #[test] + fn chat_sse_to_response_value_handles_missing_trailing_blank_line() { + // 非规范上游/半截流:最后一个事件后没有空行分隔 + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + + assert_eq!(response["choices"][0]["message"]["content"], "hi"); + } + + #[test] + fn chat_sse_to_response_value_handles_crlf_delimiters() { + // 真实 HTTP SSE 按规范使用 \r\n\r\n 分隔事件 + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":null}]}\r\n\ +\r\n\ +data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\r\n\ +\r\n\ +data: [DONE]\r\n\ +\r\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + + assert_eq!(response["choices"][0]["message"]["content"], "hi"); + assert_eq!(response["choices"][0]["finish_reason"], "stop"); + } + + #[test] + fn chat_sse_to_response_value_propagates_upstream_error_event() { + let sse = "data: {\"error\":{\"message\":\"rate limited by gateway\",\"code\":429}}\n\n"; + + let err = chat_sse_to_response_value(sse).unwrap_err(); + match err { + ProxyError::TransformError(msg) => assert!(msg.contains("rate limited by gateway")), + other => panic!("expected TransformError, got {other:?}"), + } + } + + #[test] + fn chat_sse_to_response_value_rejects_truncated_stream() { + // 只有内容增量、无 finish_reason 也无 [DONE]:close-delimited 截断不可 + // 在字节层检测,必须按截断报错而非静默返回半截内容 + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"par\"},\"finish_reason\":null}]}\n\n"; + + let err = chat_sse_to_response_value(sse).unwrap_err(); + match err { + ProxyError::TransformError(msg) => assert!(msg.contains("truncated")), + other => panic!("expected TransformError, got {other:?}"), + } + } + + #[test] + fn chat_sse_to_response_value_accepts_done_marker_without_finish_reason() { + // 非规范上游可能不发 finish_reason 但正常收尾 [DONE]:视为完成 + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":null}]}\n\n\ +data: [DONE]\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + + assert_eq!(response["choices"][0]["message"]["content"], "hi"); + assert_eq!( + response["choices"][0]["finish_reason"], + serde_json::Value::Null + ); + } + + #[test] + fn chat_sse_to_response_value_rejects_stream_without_chunks() { + let err = chat_sse_to_response_value(": keepalive\n\ndata: [DONE]\n\n").unwrap_err(); + match err { + ProxyError::TransformError(msg) => { + assert!(msg.contains("No chat completion choices")) + } + other => panic!("expected TransformError, got {other:?}"), + } + } + + #[test] + fn chat_sse_to_response_value_rejects_choiceless_stream_despite_done() { + // metadata/usage-only chunk + [DONE]、全程无 choice payload: + // 不能凭 [DONE] 包装成空内容假成功(saw_choice 必须以 choice 为证据) + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":0,\"total_tokens\":1}}\n\n\ +data: [DONE]\n\n"; + + let err = chat_sse_to_response_value(sse).unwrap_err(); + match err { + ProxyError::TransformError(msg) => { + assert!(msg.contains("No chat completion choices"), "{msg}") + } + other => panic!("expected TransformError, got {other:?}"), + } + } + + #[test] + fn chat_sse_to_response_value_huge_tool_call_index_does_not_oom() { + // C1:上游可控的巨大 index 不得 densify 数组(旧实现会 OOM 整个进程); + // BTreeMap 只占一个槽,且原始 index 用于回填合成 id + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":4000000000,\"function\":{\"name\":\"f\",\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + let tool_calls = response["choices"][0]["message"]["tool_calls"] + .as_array() + .unwrap(); + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0]["id"], "tool_call_4000000000"); + assert_eq!(tool_calls[0]["function"]["name"], "f"); + } + + #[test] + fn chat_sse_to_response_value_empty_delta_falls_back_to_message_snapshot() { + // C3:同一 choice 同时带空 delta:{} 与完整 message 快照——不能因 delta 键 + // 存在就短路到空 delta、丢掉 message 内容(finish_reason 还会击穿守卫) + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{},\"message\":{\"role\":\"assistant\",\"content\":\"full answer\"},\"finish_reason\":\"stop\"}]}\n\n\ +data: [DONE]\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + assert_eq!(response["choices"][0]["message"]["content"], "full answer"); + assert_eq!(response["choices"][0]["finish_reason"], "stop"); + } + + #[test] + fn chat_sse_to_response_value_empty_delta_scaffold_does_not_wipe_real_content() { + // C3 反向陷阱:每个 chunk 都带真内容 delta + 空 message 壳时,不能让空 + // message 触发 clear 抹掉累计内容(delta 非空则优先 delta,不走快照覆盖) + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"message\":{},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" there\"},\"message\":{},\"finish_reason\":\"stop\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + assert_eq!(response["choices"][0]["message"]["content"], "hi there"); + } + + #[test] + fn chat_sse_to_response_value_object_form_tool_arguments_preserved() { + // C16:message 快照里 arguments 作对象回传时序列化保留,不能丢成空输入 + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":{\"city\":\"SF\"}}}]},\"finish_reason\":\"tool_calls\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + let args = response["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] + .as_str() + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(args).unwrap(); + assert_eq!(parsed["city"], "SF"); + } + + #[test] + fn chat_sse_to_response_value_collects_refusal() { + // C15:delta.refusal 字符串并入可见内容,避免拒绝响应变空消息假成功 + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"refusal\":\"I can't help with that.\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + assert_eq!( + response["choices"][0]["message"]["content"], + "I can't help with that." + ); + } + + #[test] + fn chat_sse_to_response_value_maps_legacy_function_call() { + // C17:legacy function_call → 单个 tool_call,避免 finish_reason + // function_call 映射成 tool_use 却零工具块卡死 agent + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":null,\"function_call\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"SF\\\"}\"}},\"finish_reason\":\"function_call\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + let tc = &response["choices"][0]["message"]["tool_calls"][0]; + assert_eq!(tc["function"]["name"], "get_weather"); + assert_eq!(tc["function"]["arguments"], "{\"city\":\"SF\"}"); + } + + #[test] + fn chat_sse_to_response_value_event_error_fails_even_after_complete_choice() { + // C18:event:error(data 无 error 键)即便跟在完整 choice 后也判失败, + // 不能伪装成成功 + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"partial\"},\"finish_reason\":\"stop\"}]}\n\n\ +event: error\n\ +data: {\"message\":\"insufficient_user_quota\",\"code\":429}\n\n"; + + let err = chat_sse_to_response_value(sse).unwrap_err(); + match err { + ProxyError::TransformError(msg) => { + assert!(msg.contains("insufficient_user_quota"), "{msg}") + } + other => panic!("expected TransformError, got {other:?}"), + } + } + + #[test] + fn chat_sse_to_response_value_tolerates_empty_error_placeholder() { + // C12:error 为空对象 / 空消息等占位形状不得误杀成功流 + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"error\":{},\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + assert_eq!(response["choices"][0]["message"]["content"], "hi"); + } + + #[test] + fn chat_sse_to_response_value_tolerates_truncated_residual_after_complete() { + // C2:完整 finish_reason 块后尾块被掐断(半截 JSON),不能误杀已完整的聚合 + let sse = "data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n\ +data: {\"usage\":{\"prompt_to"; + + let response = chat_sse_to_response_value(sse).unwrap(); + assert_eq!(response["choices"][0]["message"]["content"], "hi"); + } + + #[test] + fn chat_sse_to_response_value_float_zero_does_not_freeze_envelope() { + // C14:浮点 0.0 占位的 created 不得冻结 envelope,真值应能覆盖 + let sse = "data: {\"id\":\"\",\"model\":\"\",\"created\":0.0,\"choices\":[]}\n\n\ +data: {\"id\":\"chatcmpl-real\",\"model\":\"m\",\"created\":42,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + assert_eq!(response["created"], 42); + assert_eq!(response["id"], "chatcmpl-real"); + } + + #[test] + fn chat_sse_to_response_value_synthesizes_id_when_absent() { + // C9:上游无 id 时合成非空唯一 id,避免下游 dedup 退化成常量碰撞覆盖 + let sse = "data: {\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let r1 = chat_sse_to_response_value(sse).unwrap(); + let r2 = chat_sse_to_response_value(sse).unwrap(); + let id1 = r1["id"].as_str().unwrap(); + let id2 = r2["id"].as_str().unwrap(); + assert!(!id1.is_empty()); + assert_ne!(id1, id2, "两次无 id 聚合应产出不同 id 以避免 dedup 碰撞"); + } + + #[test] + fn chat_sse_to_response_value_accepts_indented_data_lines() { + // C4:行首缩进的 data 行(嗅探器宽容接受)也应能被聚合,不静默丢失 + let sse = " data: {\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let response = chat_sse_to_response_value(sse).unwrap(); + assert_eq!(response["choices"][0]["message"]["content"], "hi"); + } + + #[test] + fn responses_sse_completed_then_trailing_failed_keeps_success() { + // C8:已拿到 response.completed 后,残余里的完整 response.failed 不得翻车 + // (codex_oauth 聚合路径复用本函数,此前该尾块被忽略=成功) + let sse = "event: response.completed\n\ +data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ok\",\"status\":\"completed\",\"model\":\"gpt-5.4\",\"output\":[]}}\n\n\ +event: response.failed\n\ +data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"message\":\"boom\"}}}\n"; + + let response = responses_sse_to_response_value(sse).unwrap(); + assert_eq!(response["id"], "resp_ok"); + } + + #[test] + fn aggregated_chat_sse_round_trips_through_openai_to_anthropic() { + // 全链路:错标 Content-Type 的 SSE 体 → 聚合 → 既有非流转换器 → Anthropic JSON + let sse = "data: {\"id\":\"chatcmpl-9\",\"created\":1,\"model\":\"gpt-5.4\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hi\"},\"finish_reason\":null}]}\n\n\ +data: {\"id\":\"chatcmpl-9\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":1,\"total_tokens\":5}}\n\n\ +data: [DONE]\n\n"; + + let aggregated = chat_sse_to_response_value(sse).unwrap(); + let anthropic = transform::openai_to_anthropic(aggregated).unwrap(); + + assert_eq!(anthropic["model"], "gpt-5.4"); + assert_eq!(anthropic["content"][0]["type"], "text"); + assert_eq!(anthropic["content"][0]["text"], "Hi"); + assert_eq!(anthropic["stop_reason"], "end_turn"); + } + #[test] fn codex_oauth_responses_force_streaming_even_if_client_sent_false() { assert!(should_use_claude_transform_streaming( diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index ae8fde260..59d525b1b 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -35,28 +35,41 @@ use tokio::sync::Mutex; /// 根据 content-encoding 解压响应体字节 /// /// reqwest 自动解压已禁用(为了透传 accept-encoding),需要手动解压。 -fn decompress_body(content_encoding: &str, body: &[u8]) -> Result, std::io::Error> { +/// 返回 `Ok(None)` 表示编码不受支持、原样透传——此时调用方必须保留 +/// content-encoding 头,否则下游(诊断/客户端)会把压缩字节误当明文。 +fn decompress_body(content_encoding: &str, body: &[u8]) -> Result>, std::io::Error> { match content_encoding { "gzip" | "x-gzip" => { let mut decoder = flate2::read::GzDecoder::new(body); let mut decompressed = Vec::new(); decoder.read_to_end(&mut decompressed)?; - Ok(decompressed) + Ok(Some(decompressed)) } "deflate" => { - let mut decoder = flate2::read::DeflateDecoder::new(body); + // RFC 9110: deflate 指 zlib 包裹格式;但部分上游发 raw deflate 流。 + // 先按规范尝试 zlib,失败再回退 raw —— 否则合规上游必然解压失败, + // 原始压缩字节会被 fail-open 透传给 JSON 解析(#2234 形态 C 之一)。 let mut decompressed = Vec::new(); - decoder.read_to_end(&mut decompressed)?; - Ok(decompressed) + let mut zlib = flate2::read::ZlibDecoder::new(body); + match zlib.read_to_end(&mut decompressed) { + Ok(_) => Ok(Some(decompressed)), + Err(zlib_err) => { + log::debug!("deflate 按 zlib 解压失败({zlib_err}),回退 raw deflate"); + let mut decompressed = Vec::new(); + let mut raw = flate2::read::DeflateDecoder::new(body); + raw.read_to_end(&mut decompressed)?; + Ok(Some(decompressed)) + } + } } "br" => { let mut decompressed = Vec::new(); brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?; - Ok(decompressed) + Ok(Some(decompressed)) } _ => { log::warn!("未知的 content-encoding: {content_encoding},跳过解压"); - Ok(body.to_vec()) + Ok(None) } } } @@ -150,10 +163,13 @@ pub(crate) async fn read_decoded_body( if let Some(encoding) = get_content_encoding(&headers) { log::debug!("[{tag}] 解压非流式响应: content-encoding={encoding}"); match decompress_body(&encoding, &raw_bytes) { - Ok(decompressed) => { + Ok(Some(decompressed)) => { body_bytes = Bytes::from(decompressed); decoded = true; } + // 不支持的编码:原样透传且保留 content-encoding 头, + // 让下游诊断/客户端知道这仍是压缩字节 + Ok(None) => {} Err(e) => { log::warn!("[{tag}] 解压失败 ({encoding}): {e},使用原始数据"); } @@ -862,6 +878,40 @@ mod tests { use std::sync::Arc; use tokio::sync::RwLock; + #[test] + fn decompress_body_deflate_handles_zlib_wrapped_per_rfc9110() { + // RFC 9110 规范的 deflate = zlib 包裹格式(合规上游发的就是这个) + let payload = br#"{"ok":true}"#; + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, payload).unwrap(); + let compressed = encoder.finish().unwrap(); + + let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap(); + assert_eq!(decompressed, payload); + } + + #[test] + fn decompress_body_deflate_falls_back_to_raw_stream() { + // 部分上游违规发 raw deflate 流,保持兼容 + let payload = br#"{"ok":true}"#; + let mut encoder = + flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, payload).unwrap(); + let compressed = encoder.finish().unwrap(); + + let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap(); + assert_eq!(decompressed, payload); + } + + #[test] + fn decompress_body_unknown_encoding_returns_none_to_keep_headers() { + // 未知编码必须返回 None(而非伪装成"已解码"),否则 content-encoding + // 头被剥掉,下游诊断会把压缩字节误报成明文 + let result = decompress_body("zstd", b"\x28\xb5\x2f\xfd").unwrap(); + assert!(result.is_none()); + } + #[test] fn test_strip_sse_field_accepts_optional_space() { assert_eq!( From 819c2e5dfe12cdd9cbcc69c2783f495d81c60214 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 11 Jun 2026 08:43:33 +0800 Subject: [PATCH 27/66] chore: ignore AGENTS.md alongside CLAUDE.md Suggested in #4015. --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9b145a06d..49f529856 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ release/ *.tsbuildinfo .npmrc CLAUDE.md -# AGENTS.md +AGENTS.md GEMINI.md /.claude /.codex From daa5595f362e44b50ad3c51f15ded924421b7a29 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 11 Jun 2026 11:05:32 +0800 Subject: [PATCH 28/66] feat(presets): add Unity2.ai partner provider across seven apps Add Unity2.ai, a high-performance AI API relay partner, as a preset for Claude, Codex, Gemini, OpenCode, OpenClaw, Claude Desktop, and Hermes. Each preset carries the referral signup link as apiKeyUrl. - Register the unity2 icon via iconUrls (PNG URL import) + metadata - Add partnerPromotion copy in zh/en/ja/zh-TW; backfill the missing zh-TW ccsub entry - List Unity2.ai in the sponsor section of all README locales - Codex uses the bare base URL (gateway exposes /responses at root); OpenCode/OpenClaw/Hermes use the /v1 chat-completions endpoint with gpt-5.5 as the only preset model - Trim CCSub OpenCode/OpenClaw/Hermes model lists to gpt-5.5 to match - Normalize unity2/ccsub banners to the standard 2.41 aspect ratio --- README.md | 5 ++ README_DE.md | 5 ++ README_JA.md | 5 ++ README_ZH.md | 5 ++ assets/partners/logos/ccsub.jpg | Bin 3490 -> 5024 bytes assets/partners/logos/unity2.jpg | Bin 0 -> 89912 bytes src/config/claudeDesktopProviderPresets.ts | 13 ++++ src/config/claudeProviderPresets.ts | 15 +++++ src/config/codexProviderPresets.ts | 16 +++++ src/config/geminiProviderPresets.ts | 18 +++++ src/config/hermesProviderPresets.ts | 52 +++++++-------- src/config/openclawProviderPresets.ts | 73 +++++++++++---------- src/config/opencodeProviderPresets.ts | 33 ++++++++-- src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/zh-TW.json | 4 +- src/i18n/locales/zh.json | 3 +- src/icons/extracted/index.ts | 2 + src/icons/extracted/metadata.ts | 7 ++ src/icons/extracted/unity2.png | Bin 0 -> 61909 bytes 20 files changed, 193 insertions(+), 69 deletions(-) create mode 100644 assets/partners/logos/unity2.jpg create mode 100644 src/icons/extracted/unity2.png diff --git a/README.md b/README.md index bac70be1b..8c3bcc870 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,11 @@ Register now via this lin Thanks to CCSub for sponsoring this project! CCSub is a stable, affordable AI API relay platform — your drop-in replacement for a Claude.ai subscription. One API key gives you access to Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini, and DeepSeek at roughly 30% of direct API cost, with no VPN required from anywhere in the world. Compatible with Claude Code, Codex, Cursor, Cline, Continue, Windsurf, and all major AI coding tools. Register via this link and get $5 free credit on sign-up. + +Unity2.ai +Thanks to Unity2.ai for sponsoring this project! Unity2.ai is a high-performance AI model API relay platform for individual developers, teams, and enterprises. Long trusted by leading companies in China, it serves over 30 billion tokens per day and supports high concurrency at the 5,000 RPM level. It offers balance-based billing, first top-up bonuses, bundle subscriptions, corporate invoicing, and dedicated support. Register via this link to get $2 in credits, plus another $10 for joining the official group — up to $12 in free credits! + + diff --git a/README_DE.md b/README_DE.md index bec5cf687..f7420641b 100644 --- a/README_DE.md +++ b/README_DE.md @@ -151,6 +151,11 @@ Registrieren Sie sich jetzt über diesen Link und erhalten Sie $5 Startguthaben bei der Anmeldung. + +Unity2.ai +Danke an Unity2.ai für die Unterstützung dieses Projekts! Unity2.ai ist eine leistungsstarke AI-Modell-API-Relay-Plattform für Einzelentwickler, Teams und Unternehmen. Sie wird seit Langem von führenden Unternehmen in China genutzt, verarbeitet täglich über 30 Milliarden Tokens und unterstützt hohe Parallelität auf 5.000-RPM-Niveau. Geboten werden Guthaben-Abrechnung, Ersteinzahlungsbonus, Kombi-Abonnements, Firmenrechnungen und persönliche Betreuung. Registrieren Sie sich über diesen Link und erhalten Sie $2 Guthaben, plus weitere $10 für den Beitritt zur offiziellen Gruppe — bis zu $12 Gratis-Guthaben! + + diff --git a/README_JA.md b/README_JA.md index d100d4d08..046567c6a 100644 --- a/README_JA.md +++ b/README_JA.md @@ -150,6 +150,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / CCSub のご支援に感謝します!CCSub は安定した低価格の AI API リレープラットフォームで、Claude Code 公式サブスクリプションの強力な代替です。1 つの API キーで Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek の全モデルを公式直接利用の約 1/3 のコストでご利用いただけます。VPN 不要で世界中から直接接続可能。Claude Code、Codex、Cursor、Cline、Continue、Windsurf など主要な AI コーディングツールすべてに対応しています。こちらのリンクから登録すると $5 の無料クレジットがもらえます。 + +Unity2.ai +Unity2.ai のご支援に感謝します!Unity2.ai は個人開発者・チーム・企業向けの高性能 AI モデル API リレープラットフォームです。中国の大手企業に長年利用されており、1 日 300 億トークン以上を処理し、5000 RPM クラスの高並列に対応しています。残高課金、初回チャージボーナス、組み合わせサブスクリプション、企業向け請求書発行、専任サポートを提供。こちらのリンクから登録すると $2 のクレジット、公式グループへの参加でさらに $10、最大 $12 の無料クレジットがもらえます! + + diff --git a/README_ZH.md b/README_ZH.md index 6059d7b93..984f45242 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -151,6 +151,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 感谢 CCSub 赞助本项目!CCSub 是稳定、实惠的 AI API 中转平台,是 Claude Code 官方订阅的超强平替。一个 API Key 即可调用 Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek 全系列模型,价格约为官方直连的 1/3,全球直连无需梯子。兼容 Claude Code、Codex、Cursor、Cline、Continue、Windsurf 等所有主流 AI 编程工具。通过此链接注册即送 $5 体验额度! + +Unity2.ai +感谢 Unity2.ai 赞助了本项目!Unity2.ai 是面向个人开发者、团队和企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。支持余额计费、首充赠额、组合订阅、企业开票和专属对接。通过此链接注册可领取 $2 余额,加入官方群再送 $10 余额,最高可领 $12 免费额度! + + diff --git a/assets/partners/logos/ccsub.jpg b/assets/partners/logos/ccsub.jpg index 34253552fe088ec55fdd295b4da30db5cf8bf386..92fae245216fffdfe4358c0a85f2b4e364d7a359 100644 GIT binary patch delta 4374 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 b=J(thFJa4D%ABeG%&$@-m|-kX#^nD1yo0P` delta 2920 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 JD2sdEKLCruXM+F$ diff --git a/assets/partners/logos/unity2.jpg b/assets/partners/logos/unity2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ec3b05740f55c567acb8fd24ca4373801d826488 GIT binary patch literal 89912 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 z
    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%0D$~^A4I7k0LNJ$F-j3{ljE7 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 = { aicodemirror: `AICodeMirror`, @@ -112,6 +113,7 @@ export const iconUrls: Record = { runapi: _runapi, shengsuanyun: _shengsuanyun, sudocode: _sudocode, + unity2: _unity2, }; export const iconList = [ diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index 9afa9585c..5432f576d 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -439,6 +439,13 @@ export const iconMetadata: Record = { keywords: ["hunyuan"], defaultColor: "#00A4FF", }, + unity2: { + name: "unity2", + displayName: "Unity2.ai", + category: "ai-provider", + keywords: ["unity2", "aggregator", "relay", "claude", "codex", "gateway"], + defaultColor: "#000000", + }, vercel: { name: "vercel", displayName: "vercel", diff --git a/src/icons/extracted/unity2.png b/src/icons/extracted/unity2.png new file mode 100644 index 0000000000000000000000000000000000000000..cab14fb4d2beeaad9e4962ef15e28ce999ce1fba GIT binary patch literal 61909 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,Y@{Mf@nsr+e3;Tg#%b2%#4z`K*ZObDPT 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 Q0Ql0^zM@rp(IM*p04NI(I{*Lx literal 0 HcmV?d00001 From c1aa6c391743a49e6c76d91d3ed36491c7318ec8 Mon Sep 17 00:00:00 2001 From: Peng Steam Date: Thu, 11 Jun 2026 17:14:53 +0800 Subject: [PATCH 29/66] feat(providers): add preset search and sorting (#3975) --- .../forms/ProviderPresetSelector.tsx | 233 ++++++++++- src/i18n/locales/en.json | 9 +- src/i18n/locales/ja.json | 9 +- src/i18n/locales/zh-TW.json | 9 +- src/i18n/locales/zh.json | 9 +- .../ProviderPresetSelector.test.tsx | 372 +++++++++++++++--- 6 files changed, 568 insertions(+), 73 deletions(-) diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx index aac1674dd..c24c5a87e 100644 --- a/src/components/providers/forms/ProviderPresetSelector.tsx +++ b/src/components/providers/forms/ProviderPresetSelector.tsx @@ -1,7 +1,21 @@ +import { useMemo, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { FormLabel } from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons"; -import { Zap, Star, Layers, Settings2 } from "lucide-react"; +import { ArrowUpAZ, Search, Zap, Star, Layers, Settings2 } from "lucide-react"; import type { ProviderPreset } from "@/config/claudeProviderPresets"; import type { CodexProviderPreset } from "@/config/codexProviderPresets"; import type { GeminiProviderPreset } from "@/config/geminiProviderPresets"; @@ -16,7 +30,17 @@ import { } from "@/config/universalProviderPresets"; import { ProviderIcon } from "@/components/ProviderIcon"; -type AnyPreset = +type PresetTranslator = (key: string) => unknown; + +export const PresetSortMode = { + Original: "original", + NameAsc: "nameAsc", +} as const; + +export type PresetSortMode = + (typeof PresetSortMode)[keyof typeof PresetSortMode]; + +export type AnyPreset = | ProviderPreset | CodexProviderPreset | GeminiProviderPreset @@ -25,11 +49,91 @@ type AnyPreset = | OpenClawProviderPreset | HermesProviderPreset; -type PresetEntry = { +export type PresetEntry = { id: string; preset: AnyPreset; }; +export function getPresetDisplayName( + preset: AnyPreset, + t: PresetTranslator, +): string { + return preset.nameKey ? String(t(preset.nameKey)) : preset.name; +} + +export function getPresetSearchText( + entry: PresetEntry, + presetCategoryLabels: Record, + t: PresetTranslator, +): string { + const presetCategory = entry.preset.category ?? "others"; + const categoryLabel = + presetCategoryLabels[presetCategory] ?? String(t("providerPreset.other")); + + return [ + getPresetDisplayName(entry.preset, t), + entry.preset.name, + entry.preset.websiteUrl, + categoryLabel, + ] + .join(" ") + .toLowerCase(); +} + +export function filterPresetEntries( + entries: PresetEntry[], + query: string, + presetCategoryLabels: Record, + t: PresetTranslator, +): PresetEntry[] { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return entries; + } + + return entries.filter((entry) => + getPresetSearchText(entry, presetCategoryLabels, t).includes( + normalizedQuery, + ), + ); +} + +export function sortPresetEntries( + entries: PresetEntry[], + sortMode: PresetSortMode, + t: PresetTranslator, +): PresetEntry[] { + if (sortMode === PresetSortMode.Original) { + return [...entries]; + } + + return [...entries].sort((a, b) => + getPresetDisplayName(a.preset, t).localeCompare( + getPresetDisplayName(b.preset, t), + ), + ); +} + +export interface PresetVisibilityOptions { + query: string; + sortMode: PresetSortMode; + presetCategoryLabels: Record; + t: PresetTranslator; +} + +export function getVisiblePresetEntries( + entries: PresetEntry[], + options: PresetVisibilityOptions, +): PresetEntry[] { + const { query, sortMode, presetCategoryLabels, t } = options; + + return sortPresetEntries( + filterPresetEntries(entries, query, presetCategoryLabels, t), + sortMode, + t, + ); +} + interface ProviderPresetSelectorProps { selectedPresetId: string | null; presetEntries: PresetEntry[]; @@ -50,8 +154,24 @@ export function ProviderPresetSelector({ category, }: ProviderPresetSelectorProps) { const { t } = useTranslation(); + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [sortMode, setSortMode] = useState( + PresetSortMode.Original, + ); - const getCategoryHint = (): React.ReactNode => { + const visiblePresetEntries = useMemo( + () => + getVisiblePresetEntries(presetEntries, { + query: searchQuery, + sortMode, + presetCategoryLabels, + t, + }), + [presetEntries, presetCategoryLabels, searchQuery, sortMode, t], + ); + + const getCategoryHint = (): ReactNode => { switch (category) { case "official": return t("providerForm.officialHint", { @@ -85,6 +205,14 @@ export function ProviderPresetSelector({ } }; + const toggleSortMode = () => { + setSortMode((current) => + current === PresetSortMode.Original + ? PresetSortMode.NameAsc + : PresetSortMode.Original, + ); + }; + const renderPresetIcon = (preset: AnyPreset) => { const iconType = preset.theme?.icon; if (!iconType) return null; @@ -130,7 +258,88 @@ export function ProviderPresetSelector({ return (

    - {t("providerPreset.label")} +
    + {t("providerPreset.label")} + +
    + + + + + + + + + {t("providerPreset.searchTooltip", { + defaultValue: "Search presets", + })} + + + + setSearchQuery(event.target.value)} + placeholder={t("providerPreset.searchPlaceholder", { + defaultValue: "Search presets...", + })} + aria-label={t("providerPreset.searchAriaLabel", { + defaultValue: "Search provider presets", + })} + autoFocus + /> + + + + + + + + + {sortMode === PresetSortMode.NameAsc + ? t("providerPreset.sortOriginalTooltip", { + defaultValue: "Restore original order", + }) + : t("providerPreset.sortNameAscTooltip", { + defaultValue: "Sort A-Z", + })} + + +
    +
    +
    - ))} + {APP_FILTER_OPTIONS.map((type) => { + const label = t(`usage.appFilter.${type}`); + return ( + + ); + })}
    From a75f47957614097f52c3abb7995e668cf2b4fa92 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 11 Jun 2026 16:14:20 +0800 Subject: [PATCH 37/66] feat(usage): lift provider/model filters to dashboard-wide scope The provider/model filters only lived inside the request-log table, so there was no way to see "how much did app X spend on source Y" across the whole dashboard. Promote them to the top bar next to the app filter, applying globally to the hero summary, trend chart, request logs, and both stats tabs. Backend: the five stats queries (summary, summary-by-app, trends, provider stats, model stats) accept optional provider_name/model filters, applied to both the detail and daily-rollup branches (the rollup PK already carries provider_id/model/pricing_model). Sources match by exact display name via provider_name_coalesce, so session placeholder rows like "Claude (Session)" are selectable; models match by effective pricing model (pricing_model falling back to model), the same grouping key the model-stats tab uses. Request-log filtering switches from LIKE to these exact semantics. Frontend: two truncating dropdowns list only sources/models that have data in the current range, with the model list cascading from the selected source. Dynamic option values are prefix-encoded so a source literally named "all" cannot collide with the sentinel, query keys fall back to null instead of "all", and the option queries follow the dashboard refresh interval (otherwise their default 30s polling drags same-key stats queries along even with refresh disabled). The request-log filter bar keeps only the log-specific status-code select. Labels read "sources" rather than "providers" because direct-connect session buckets sit alongside real providers. i18n updated across zh/en/ja/zh-TW. --- src-tauri/src/commands/usage.rs | 57 +- src-tauri/src/services/usage_stats.rs | 548 +++++++++++++++++--- src/components/usage/ModelStatsTable.tsx | 14 +- src/components/usage/ProviderStatsTable.tsx | 14 +- src/components/usage/RequestLogTable.tsx | 154 +----- src/components/usage/UsageDashboard.tsx | 139 ++++- src/components/usage/UsageHero.tsx | 14 +- src/components/usage/UsageTrendChart.tsx | 14 +- src/i18n/locales/en.json | 7 +- src/i18n/locales/ja.json | 7 +- src/i18n/locales/zh-TW.json | 7 +- src/i18n/locales/zh.json | 7 +- src/lib/api/usage.ts | 49 +- src/lib/query/usage.ts | 107 +++- src/types/usage.ts | 14 + 15 files changed, 871 insertions(+), 281 deletions(-) diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 0a178159c..d968bbdc5 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -14,10 +14,16 @@ pub fn get_usage_summary( start_date: Option, end_date: Option, app_type: Option, + provider_name: Option, + model: Option, ) -> Result { - state - .db - .get_usage_summary(start_date, end_date, app_type.as_deref()) + state.db.get_usage_summary( + start_date, + end_date, + app_type.as_deref(), + provider_name.as_deref(), + model.as_deref(), + ) } /// 获取按 app_type 拆分的使用量汇总 @@ -26,8 +32,15 @@ pub fn get_usage_summary_by_app( state: State<'_, AppState>, start_date: Option, end_date: Option, + provider_name: Option, + model: Option, ) -> Result, AppError> { - state.db.get_usage_summary_by_app(start_date, end_date) + state.db.get_usage_summary_by_app( + start_date, + end_date, + provider_name.as_deref(), + model.as_deref(), + ) } /// 获取每日趋势 @@ -37,10 +50,16 @@ pub fn get_usage_trends( start_date: Option, end_date: Option, app_type: Option, + provider_name: Option, + model: Option, ) -> Result, AppError> { - state - .db - .get_daily_trends(start_date, end_date, app_type.as_deref()) + state.db.get_daily_trends( + start_date, + end_date, + app_type.as_deref(), + provider_name.as_deref(), + model.as_deref(), + ) } /// 获取 Provider 统计 @@ -50,10 +69,16 @@ pub fn get_provider_stats( start_date: Option, end_date: Option, app_type: Option, + provider_name: Option, + model: Option, ) -> Result, AppError> { - state - .db - .get_provider_stats(start_date, end_date, app_type.as_deref()) + state.db.get_provider_stats( + start_date, + end_date, + app_type.as_deref(), + provider_name.as_deref(), + model.as_deref(), + ) } /// 获取模型统计 @@ -63,10 +88,16 @@ pub fn get_model_stats( start_date: Option, end_date: Option, app_type: Option, + provider_name: Option, + model: Option, ) -> Result, AppError> { - state - .db - .get_model_stats(start_date, end_date, app_type.as_deref()) + state.db.get_model_stats( + start_date, + end_date, + app_type.as_deref(), + provider_name.as_deref(), + model.as_deref(), + ) } /// 获取请求日志列表 diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index a624408b1..ea175f297 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -242,6 +242,52 @@ fn folded_app_type_sql(column: &str) -> String { format!("CASE WHEN {column} = 'claude-desktop' THEN 'claude' ELSE {column} END") } +/// SQL 片段:把日志/汇总行 LEFT JOIN 到 providers 表以取得供应商名称。 +/// `proxy_request_logs` 与 `usage_daily_rollups` 的 (provider_id, app_type) +/// 形状相同,两者皆可作为 `log_alias`。providers 主键即 (id, app_type), +/// 连接至多 1:1,不会放大行数。 +fn providers_join(log_alias: &str, provider_alias: &str) -> String { + format!( + "LEFT JOIN providers {provider_alias} \ + ON {log_alias}.provider_id = {provider_alias}.id \ + AND {log_alias}.app_type = {provider_alias}.app_type" + ) +} + +/// SQL 标量表达式:行的「有效计价模型」—— pricing_model 非空优先,NULL/'' 回落 +/// model。这是 `get_model_stats` 的分组键,也是 Dashboard 模型筛选的匹配口径: +/// 筛选值来自模型统计列表,两边必须用同一表达式才能选得中。 +fn effective_model_sql(alias: &str) -> String { + format!("COALESCE(NULLIF({alias}.pricing_model, ''), {alias}.model)") +} + +/// 把 Dashboard 顶部的 Provider/模型筛选追加到查询条件。 +/// +/// Provider 按展示名精确匹配(复用 [`provider_name_coalesce`],会话占位行的 +/// 可读名如 "Claude (Session)" 也能选中);模型按 [`effective_model_sql`] 匹配。 +/// 注意:传入 `provider_name` 时调用方必须把 [`providers_join`] 拼进 FROM, +/// 否则 `{provider_alias}.name` 无法解析。 +fn push_provider_model_filters( + conditions: &mut Vec, + params: &mut Vec>, + log_alias: &str, + provider_alias: &str, + provider_name: Option<&str>, + model: Option<&str>, +) { + if let Some(name) = provider_name { + conditions.push(format!( + "{} = ?", + provider_name_coalesce(log_alias, provider_alias) + )); + params.push(Box::new(name.to_string())); + } + if let Some(m) = model { + conditions.push(format!("{} = ?", effective_model_sql(log_alias))); + params.push(Box::new(m.to_string())); + } +} + pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String { let data_source = data_source_expr(log_alias); let proxy_data_source = data_source_expr("proxy_dedup"); @@ -461,6 +507,8 @@ impl Database { start_date: Option, end_date: Option, app_type: Option<&str>, + provider_name: Option<&str>, + model: Option<&str>, ) -> Result { let conn = lock_conn!(self.conn); @@ -480,12 +528,25 @@ impl Database { conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type"))); params_vec.push(Box::new(at.to_string())); } + push_provider_model_filters( + &mut conditions, + &mut params_vec, + "l", + "p", + provider_name, + model, + ); let where_clause = if conditions.is_empty() { String::new() } else { format!("WHERE {}", conditions.join(" AND ")) }; + let detail_join = if provider_name.is_some() { + providers_join("l", "p") + } else { + String::new() + }; // Only include rolled-up rows for full local days that are fully covered by the range. let mut rollup_conditions: Vec = Vec::new(); @@ -495,22 +556,35 @@ impl Database { push_rollup_date_filters( &mut rollup_conditions, &mut rollup_params, - "date", + "r.date", &rollup_bounds, ); if let Some(at) = app_type { - rollup_conditions.push(format!("{} = ?", folded_app_type_sql("app_type"))); + rollup_conditions.push(format!("{} = ?", folded_app_type_sql("r.app_type"))); rollup_params.push(Box::new(at.to_string())); } + push_provider_model_filters( + &mut rollup_conditions, + &mut rollup_params, + "r", + "p2", + provider_name, + model, + ); let rollup_where = if rollup_conditions.is_empty() { String::new() } else { format!("WHERE {}", rollup_conditions.join(" AND ")) }; + let rollup_join = if provider_name.is_some() { + providers_join("r", "p2") + } else { + String::new() + }; let fresh_input_detail = fresh_input_sql("l"); - let fresh_input_rollup = fresh_input_sql(""); + let fresh_input_rollup = fresh_input_sql("r"); let sql = format!( "SELECT COALESCE(d.total_requests, 0) + COALESCE(r.total_requests, 0), @@ -529,16 +603,16 @@ impl Database { COALESCE(SUM(l.cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(l.cache_read_tokens), 0) as total_cache_read_tokens, COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count - FROM proxy_request_logs l {where_clause}) d, + FROM proxy_request_logs l {detail_join} {where_clause}) d, (SELECT - COALESCE(SUM(request_count), 0) as total_requests, - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM(r.request_count), 0) as total_requests, + COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0) as total_cost, COALESCE(SUM({fresh_input_rollup}), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, - COALESCE(SUM(success_count), 0) as success_count - FROM usage_daily_rollups {rollup_where}) r" + COALESCE(SUM(r.output_tokens), 0) as total_output_tokens, + COALESCE(SUM(r.cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(r.cache_read_tokens), 0) as total_cache_read_tokens, + COALESCE(SUM(r.success_count), 0) as success_count + FROM usage_daily_rollups r {rollup_join} {rollup_where}) r" ); // Combine params: detail params first, then rollup params @@ -593,6 +667,8 @@ impl Database { &self, start_date: Option, end_date: Option, + provider_name: Option<&str>, + model: Option<&str>, ) -> Result, AppError> { let conn = lock_conn!(self.conn); @@ -606,7 +682,20 @@ impl Database { detail_conditions.push("l.created_at <= ?".to_string()); detail_params.push(Box::new(end)); } + push_provider_model_filters( + &mut detail_conditions, + &mut detail_params, + "l", + "p", + provider_name, + model, + ); let detail_where = format!("WHERE {}", detail_conditions.join(" AND ")); + let detail_join = if provider_name.is_some() { + providers_join("l", "p") + } else { + String::new() + }; let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?; let mut rollup_conditions: Vec = Vec::new(); @@ -614,20 +703,33 @@ impl Database { push_rollup_date_filters( &mut rollup_conditions, &mut rollup_params, - "date", + "r.date", &rollup_bounds, ); + push_provider_model_filters( + &mut rollup_conditions, + &mut rollup_params, + "r", + "p2", + provider_name, + model, + ); let rollup_where = if rollup_conditions.is_empty() { String::new() } else { format!("WHERE {}", rollup_conditions.join(" AND ")) }; + let rollup_join = if provider_name.is_some() { + providers_join("r", "p2") + } else { + String::new() + }; let fresh_input_detail = fresh_input_sql("l"); - let fresh_input_rollup = fresh_input_sql(""); + let fresh_input_rollup = fresh_input_sql("r"); // 折叠 claude-desktop → claude:内层投影成同一桶名,外层 GROUP BY 自然合并。 let detail_app_type = folded_app_type_sql("l.app_type"); - let rollup_app_type = folded_app_type_sql("app_type"); + let rollup_app_type = folded_app_type_sql("r.app_type"); let sql = format!( "SELECT app_type, @@ -647,19 +749,19 @@ impl Database { COALESCE(SUM(l.cache_creation_tokens), 0) as cache_create_t, COALESCE(SUM(l.cache_read_tokens), 0) as cache_read_t, COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count - FROM proxy_request_logs l {detail_where} + FROM proxy_request_logs l {detail_join} {detail_where} GROUP BY l.app_type UNION ALL SELECT {rollup_app_type} as app_type, - COALESCE(SUM(request_count), 0), - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0), + COALESCE(SUM(r.request_count), 0), + COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0), COALESCE(SUM({fresh_input_rollup}), 0), - COALESCE(SUM(output_tokens), 0), - COALESCE(SUM(cache_creation_tokens), 0), - COALESCE(SUM(cache_read_tokens), 0), - COALESCE(SUM(success_count), 0) - FROM usage_daily_rollups {rollup_where} - GROUP BY app_type + COALESCE(SUM(r.output_tokens), 0), + COALESCE(SUM(r.cache_creation_tokens), 0), + COALESCE(SUM(r.cache_read_tokens), 0), + COALESCE(SUM(r.success_count), 0) + FROM usage_daily_rollups r {rollup_join} {rollup_where} + GROUP BY r.app_type ) GROUP BY app_type" ); @@ -729,6 +831,8 @@ impl Database { start_date: Option, end_date: Option, app_type: Option<&str>, + provider_name: Option<&str>, + model: Option<&str>, ) -> Result, AppError> { let conn = lock_conn!(self.conn); @@ -752,8 +856,27 @@ impl Database { bucket_count = 1; } - let app_type_filter = if app_type.is_some() { - format!("AND {} = ?4", folded_app_type_sql("l.app_type")) + let mut extra_conditions: Vec = Vec::new(); + let mut extra_params: Vec> = Vec::new(); + if let Some(at) = app_type { + extra_conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type"))); + extra_params.push(Box::new(at.to_string())); + } + push_provider_model_filters( + &mut extra_conditions, + &mut extra_params, + "l", + "p", + provider_name, + model, + ); + let extra_filter = extra_conditions + .iter() + .map(|c| format!("AND {c}")) + .collect::>() + .join(" "); + let detail_join = if provider_name.is_some() { + providers_join("l", "p") } else { String::new() }; @@ -770,9 +893,9 @@ impl Database { COALESCE(SUM(l.output_tokens), 0) as total_output_tokens, COALESCE(SUM(l.cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(l.cache_read_tokens), 0) as total_cache_read_tokens - FROM proxy_request_logs l + FROM proxy_request_logs l {detail_join} WHERE l.created_at >= ?1 AND l.created_at <= ?2 - AND {effective_filter} {app_type_filter} + AND {effective_filter} {extra_filter} GROUP BY bucket_idx ORDER BY bucket_idx ASC" ); @@ -796,11 +919,15 @@ impl Database { let mut map: HashMap = HashMap::new(); - let rows = if let Some(at) = app_type { - stmt.query_map(params![start_ts, end_ts, bucket_seconds, at], row_mapper)? - } else { - stmt.query_map(params![start_ts, end_ts, bucket_seconds], row_mapper)? - }; + let mut all_params: Vec> = vec![ + Box::new(start_ts), + Box::new(end_ts), + Box::new(bucket_seconds), + ]; + all_params.extend(extra_params); + let param_refs: Vec<&dyn rusqlite::ToSql> = + all_params.iter().map(|p| p.as_ref()).collect(); + let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?; for row in rows { let (mut bucket_idx, stat) = row?; if bucket_idx < 0 { @@ -842,8 +969,27 @@ impl Database { let end_day = local_datetime_from_timestamp(end_ts)?.date_naive(); let bucket_count = (end_day.signed_duration_since(start_day).num_days() + 1) as usize; - let app_type_filter = if app_type.is_some() { - format!("AND {} = ?3", folded_app_type_sql("l.app_type")) + let mut extra_conditions: Vec = Vec::new(); + let mut extra_params: Vec> = Vec::new(); + if let Some(at) = app_type { + extra_conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type"))); + extra_params.push(Box::new(at.to_string())); + } + push_provider_model_filters( + &mut extra_conditions, + &mut extra_params, + "l", + "p", + provider_name, + model, + ); + let extra_filter = extra_conditions + .iter() + .map(|c| format!("AND {c}")) + .collect::>() + .join(" "); + let detail_join = if provider_name.is_some() { + providers_join("l", "p") } else { String::new() }; @@ -860,9 +1006,9 @@ impl Database { COALESCE(SUM(l.output_tokens), 0) as total_output_tokens, COALESCE(SUM(l.cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(l.cache_read_tokens), 0) as total_cache_read_tokens - FROM proxy_request_logs l + FROM proxy_request_logs l {detail_join} WHERE l.created_at >= ?1 AND l.created_at <= ?2 - AND {effective_filter} {app_type_filter} + AND {effective_filter} {extra_filter} GROUP BY bucket_date ORDER BY bucket_date ASC" ); @@ -885,11 +1031,12 @@ impl Database { }; let mut map: HashMap = HashMap::new(); - let detail_rows = if let Some(at) = app_type { - detail_stmt.query_map(params![start_ts, end_ts, at], detail_row_mapper)? - } else { - detail_stmt.query_map(params![start_ts, end_ts], detail_row_mapper)? - }; + let mut detail_all_params: Vec> = + vec![Box::new(start_ts), Box::new(end_ts)]; + detail_all_params.extend(extra_params); + let detail_param_refs: Vec<&dyn rusqlite::ToSql> = + detail_all_params.iter().map(|p| p.as_ref()).collect(); + let detail_rows = detail_stmt.query_map(detail_param_refs.as_slice(), detail_row_mapper)?; for row in detail_rows { let (bucket_date, stat) = row?; @@ -904,35 +1051,48 @@ impl Database { push_rollup_date_filters( &mut rollup_conditions, &mut rollup_params, - "date", + "r.date", &rollup_bounds, ); if let Some(at) = app_type { - rollup_conditions.push(format!("{} = ?", folded_app_type_sql("app_type"))); + rollup_conditions.push(format!("{} = ?", folded_app_type_sql("r.app_type"))); rollup_params.push(Box::new(at.to_string())); } + push_provider_model_filters( + &mut rollup_conditions, + &mut rollup_params, + "r", + "p2", + provider_name, + model, + ); let rollup_where = if rollup_conditions.is_empty() { String::new() } else { format!("WHERE {}", rollup_conditions.join(" AND ")) }; + let rollup_join = if provider_name.is_some() { + providers_join("r", "p2") + } else { + String::new() + }; - let fresh_input_rollup = fresh_input_sql(""); + let fresh_input_rollup = fresh_input_sql("r"); let rollup_sql = format!( "SELECT - date, - COALESCE(SUM(request_count), 0), - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0), - COALESCE(SUM({fresh_input_rollup} + output_tokens), 0), + r.date, + COALESCE(SUM(r.request_count), 0), + COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0), + COALESCE(SUM({fresh_input_rollup} + r.output_tokens), 0), COALESCE(SUM({fresh_input_rollup}), 0), - COALESCE(SUM(output_tokens), 0), - COALESCE(SUM(cache_creation_tokens), 0), - COALESCE(SUM(cache_read_tokens), 0) - FROM usage_daily_rollups + COALESCE(SUM(r.output_tokens), 0), + COALESCE(SUM(r.cache_creation_tokens), 0), + COALESCE(SUM(r.cache_read_tokens), 0) + FROM usage_daily_rollups r {rollup_join} {rollup_where} - GROUP BY date - ORDER BY date ASC" + GROUP BY r.date + ORDER BY r.date ASC" ); let mut rollup_stmt = conn.prepare(&rollup_sql)?; @@ -1011,6 +1171,8 @@ impl Database { start_date: Option, end_date: Option, app_type: Option<&str>, + provider_name: Option<&str>, + model: Option<&str>, ) -> Result, AppError> { let conn = lock_conn!(self.conn); @@ -1028,6 +1190,14 @@ impl Database { detail_conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type"))); detail_params.push(Box::new(at.to_string())); } + push_provider_model_filters( + &mut detail_conditions, + &mut detail_params, + "l", + "p", + provider_name, + model, + ); let detail_where = if detail_conditions.is_empty() { String::new() } else { @@ -1047,6 +1217,14 @@ impl Database { rollup_conditions.push(format!("{} = ?", folded_app_type_sql("r.app_type"))); rollup_params.push(Box::new(at.to_string())); } + push_provider_model_filters( + &mut rollup_conditions, + &mut rollup_params, + "r", + "p2", + provider_name, + model, + ); let rollup_where = if rollup_conditions.is_empty() { String::new() } else { @@ -1137,6 +1315,8 @@ impl Database { start_date: Option, end_date: Option, app_type: Option<&str>, + provider_name: Option<&str>, + model: Option<&str>, ) -> Result, AppError> { let conn = lock_conn!(self.conn); @@ -1154,11 +1334,24 @@ impl Database { detail_conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type"))); detail_params.push(Box::new(at.to_string())); } + push_provider_model_filters( + &mut detail_conditions, + &mut detail_params, + "l", + "p", + provider_name, + model, + ); let detail_where = if detail_conditions.is_empty() { String::new() } else { format!("WHERE {}", detail_conditions.join(" AND ")) }; + let detail_join = if provider_name.is_some() { + providers_join("l", "p") + } else { + String::new() + }; let mut rollup_conditions = Vec::new(); let mut rollup_params: Vec> = Vec::new(); @@ -1173,11 +1366,24 @@ impl Database { rollup_conditions.push(format!("{} = ?", folded_app_type_sql("r.app_type"))); rollup_params.push(Box::new(at.to_string())); } + push_provider_model_filters( + &mut rollup_conditions, + &mut rollup_params, + "r", + "p2", + provider_name, + model, + ); let rollup_where = if rollup_conditions.is_empty() { String::new() } else { format!("WHERE {}", rollup_conditions.join(" AND ")) }; + let rollup_join = if provider_name.is_some() { + providers_join("r", "p2") + } else { + String::new() + }; // UNION detail logs + rollup data // @@ -1187,6 +1393,8 @@ impl Database { // 基准名下,而不是上游回显/客户端别名名下。 let fresh_input_detail = fresh_input_sql("l"); let fresh_input_rollup = fresh_input_sql("r"); + let detail_model = effective_model_sql("l"); + let rollup_model = effective_model_sql("r"); let sql = format!( "SELECT model, @@ -1194,21 +1402,23 @@ impl Database { SUM(total_tokens) as total_tokens, SUM(total_cost) as total_cost FROM ( - SELECT COALESCE(NULLIF(l.pricing_model, ''), l.model) as model, + SELECT {detail_model} as model, COUNT(*) as request_count, COALESCE(SUM({fresh_input_detail} + l.output_tokens), 0) as total_tokens, COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost FROM proxy_request_logs l + {detail_join} {detail_where} - GROUP BY COALESCE(NULLIF(l.pricing_model, ''), l.model) + GROUP BY {detail_model} UNION ALL - SELECT COALESCE(NULLIF(r.pricing_model, ''), r.model), + SELECT {rollup_model}, COALESCE(SUM(r.request_count), 0), COALESCE(SUM({fresh_input_rollup} + r.output_tokens), 0), COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0) FROM usage_daily_rollups r + {rollup_join} {rollup_where} - GROUP BY COALESCE(NULLIF(r.pricing_model, ''), r.model) + GROUP BY {rollup_model} ) GROUP BY model ORDER BY total_cost DESC" @@ -1264,14 +1474,16 @@ impl Database { conditions.push(format!("{} = ?", folded_app_type_sql("l.app_type"))); params.push(Box::new(app_type.clone())); } - if let Some(ref provider_name) = filters.provider_name { - conditions.push("p.name LIKE ?".to_string()); - params.push(Box::new(format!("%{provider_name}%"))); - } - if let Some(ref model) = filters.model { - conditions.push("l.model LIKE ?".to_string()); - params.push(Box::new(format!("%{model}%"))); - } + // 与 Dashboard 顶部下拉筛选同口径:Provider 按展示名精确匹配(会话占位 + // 行如 "Claude (Session)" 也能命中),模型按有效计价模型匹配。 + push_provider_model_filters( + &mut conditions, + &mut params, + "l", + "p", + filters.provider_name.as_deref(), + filters.model.as_deref(), + ); if let Some(status) = filters.status_code { conditions.push("l.status_code = ?".to_string()); params.push(Box::new(status as i64)); @@ -2169,7 +2381,7 @@ mod tests { } // ① 分应用汇总:desktop 折叠进 claude,不再单列 claude-desktop 桶。 - let by_app = db.get_usage_summary_by_app(None, None)?; + let by_app = db.get_usage_summary_by_app(None, None, None, None)?; assert_eq!(by_app.len(), 1, "应只剩一个合并后的 claude 桶"); assert_eq!(by_app[0].app_type, "claude"); assert_eq!(by_app[0].summary.total_requests, 2, "两条行都计入 claude"); @@ -2179,7 +2391,7 @@ mod tests { ); // ② 选中 claude 过滤:汇总应同时覆盖 desktop 行。 - let claude_summary = db.get_usage_summary(None, None, Some("claude"))?; + let claude_summary = db.get_usage_summary(None, None, Some("claude"), None, None)?; assert_eq!(claude_summary.total_requests, 2); // ③ 请求日志按 claude 过滤返回两行,且 desktop 行投影仍是原始 app_type。 @@ -2198,7 +2410,7 @@ mod tests { ); // ④ 折叠不外溢:codex 过滤为空。 - let codex_summary = db.get_usage_summary(None, None, Some("codex"))?; + let codex_summary = db.get_usage_summary(None, None, Some("codex"), None, None)?; assert_eq!(codex_summary.total_requests, 0); Ok(()) @@ -2504,7 +2716,7 @@ mod tests { )?; } - let summary = db.get_usage_summary(None, None, None)?; + let summary = db.get_usage_summary(None, None, None, None, None)?; assert_eq!(summary.total_requests, 2); assert_eq!(summary.success_rate, 100.0); @@ -2584,7 +2796,7 @@ mod tests { )?; } - let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"))?; + let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"), None, None)?; assert_eq!(summary.total_requests, 20); assert_eq!(summary.total_input_tokens, 2000); assert_eq!(summary.total_output_tokens, 1000); @@ -2592,6 +2804,174 @@ mod tests { Ok(()) } + #[test] + fn test_provider_and_model_filters_cover_detail_and_rollup() -> Result<(), AppError> { + let db = Database::memory()?; + let detail_ts = local_ts(2026, 6, 10, 12, 0, 0); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO providers (id, app_type, name, settings_config) VALUES + ('prov-a', 'claude', 'Packy', '{}'), + ('prov-b', 'claude', 'DeepSeek', '{}')", + [], + )?; + + insert_usage_log( + &conn, + "a-1", + "claude", + "prov-a", + "claude-sonnet-4-6", + "proxy", + detail_ts, + 100, + 10, + 0, + 0, + 200, + "1.0", + )?; + insert_usage_log( + &conn, + "b-1", + "claude", + "prov-b", + "deepseek-v3", + "proxy", + detail_ts, + 200, + 20, + 0, + 0, + 200, + "2.0", + )?; + // 会话占位行:providers 表无此 id,展示名走 CASE 映射。 + insert_usage_log( + &conn, + "s-1", + "claude", + "_session", + "claude-sonnet-4-6", + "session_log", + detail_ts, + 999, + 99, + 0, + 0, + 200, + "0.5", + )?; + // 计价模型与请求模型不同的行:模型筛选必须按有效计价模型命中。 + insert_usage_log( + &conn, + "a-2", + "claude", + "prov-a", + "alias-model", + "proxy", + detail_ts, + 50, + 5, + 0, + 0, + 200, + "0.3", + )?; + conn.execute( + "UPDATE proxy_request_logs SET pricing_model = 'real-model' WHERE request_id = 'a-2'", + [], + )?; + + // rollup 历史日行:无范围过滤时全部计入。 + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES + ('2026-06-08', 'claude', 'prov-a', 'claude-sonnet-4-6', 5, 5, 500, 50, 0, 0, '5.0', 100), + ('2026-06-08', 'claude', 'prov-b', 'deepseek-v3', 7, 7, 700, 70, 0, 0, '7.0', 100)", + [], + )?; + } + + // ① 汇总按 Provider 展示名过滤:明细 + rollup 都命中。 + let packy = db.get_usage_summary(None, None, None, Some("Packy"), None)?; + assert_eq!(packy.total_requests, 7, "a-1 + a-2 + rollup 5"); + + // ② 汇总按模型过滤(有效计价模型口径)。 + let deepseek = db.get_usage_summary(None, None, None, None, Some("deepseek-v3"))?; + assert_eq!(deepseek.total_requests, 8, "b-1 + rollup 7"); + + // ③ pricing_model 优先于 model:alias-model 查不到,real-model 查得到。 + let by_alias = db.get_usage_summary(None, None, None, None, Some("alias-model"))?; + assert_eq!(by_alias.total_requests, 0); + let by_real = db.get_usage_summary(None, None, None, None, Some("real-model"))?; + assert_eq!(by_real.total_requests, 1); + + // ④ 会话占位行可按可读名选中。 + let session = db.get_usage_summary(None, None, None, Some("Claude (Session)"), None)?; + assert_eq!(session.total_requests, 1); + + // ⑤ Provider 统计 + 模型过滤:只剩 DeepSeek 一行。 + let provider_stats = db.get_provider_stats(None, None, None, None, Some("deepseek-v3"))?; + assert_eq!(provider_stats.len(), 1); + assert_eq!(provider_stats[0].provider_name, "DeepSeek"); + assert_eq!(provider_stats[0].request_count, 8); + + // ⑥ 模型统计 + Provider 过滤:只剩 Packy 名下的模型。 + let model_stats = db.get_model_stats(None, None, None, Some("Packy"), None)?; + let models: Vec<&str> = model_stats.iter().map(|m| m.model.as_str()).collect(); + assert!(models.contains(&"claude-sonnet-4-6")); + assert!(models.contains(&"real-model")); + assert!(!models.contains(&"deepseek-v3")); + + // ⑦ 分应用汇总(Hero 卡片数据源)同样受过滤影响。 + let by_app = db.get_usage_summary_by_app(None, None, Some("Packy"), None)?; + assert_eq!(by_app.len(), 1); + assert_eq!(by_app[0].app_type, "claude"); + assert_eq!(by_app[0].summary.total_requests, 7); + + // ⑧ 趋势(>24h 走天分桶 + rollup 分支)。 + let t_start = local_ts(2026, 6, 8, 0, 0, 0); + let t_end = local_ts(2026, 6, 10, 23, 59, 0); + let trends = db.get_daily_trends(Some(t_start), Some(t_end), None, Some("Packy"), None)?; + let total_req: u64 = trends.iter().map(|d| d.request_count).sum(); + assert_eq!(total_req, 7, "明细 2 + rollup 5"); + + // ⑨ 趋势 ≤24h 走小时分桶分支(?1/?2/?3 编号参数与追加过滤混用的路径), + // 同时验证 Provider + 模型组合过滤。 + let h_start = local_ts(2026, 6, 10, 0, 0, 0); + let h_end = local_ts(2026, 6, 10, 20, 0, 0); + let hourly = db.get_daily_trends( + Some(h_start), + Some(h_end), + None, + Some("Packy"), + Some("claude-sonnet-4-6"), + )?; + let hourly_req: u64 = hourly.iter().map(|d| d.request_count).sum(); + assert_eq!(hourly_req, 1, "仅 a-1 命中(a-2 计价模型不同)"); + + // ⑩ 请求日志列表与下拉同口径:精确名 + 有效计价模型。 + let logs = db.get_request_logs( + &LogFilters { + provider_name: Some("Packy".to_string()), + model: Some("real-model".to_string()), + ..Default::default() + }, + 0, + 10, + )?; + assert_eq!(logs.total, 1); + assert_eq!(logs.data[0].request_id, "a-2"); + + Ok(()) + } + #[test] fn test_get_usage_summary_includes_end_day_rollup_for_minute_precision_end_time( ) -> Result<(), AppError> { @@ -2645,7 +3025,7 @@ mod tests { )?; } - let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"))?; + let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"), None, None)?; assert_eq!(summary.total_requests, 30); assert_eq!(summary.total_input_tokens, 3000); assert_eq!(summary.total_output_tokens, 1500); @@ -2766,7 +3146,7 @@ mod tests { )?; } - let summary = db.get_usage_summary(None, None, None)?; + let summary = db.get_usage_summary(None, None, None, None, None)?; assert_eq!(summary.total_requests, 4); // codex-proxy contributes 100-10=90; gemini-proxy contributes 200-30=170 // (both cache-inclusive providers). claude-proxy=300, codex-session-only=50. @@ -2781,10 +3161,10 @@ mod tests { let expected_hit_rate = 60.0_f64 / 682.0_f64; assert!((summary.cache_hit_rate - expected_hit_rate).abs() < 1e-9); - let trends = db.get_daily_trends(Some(0), Some(40_000), None)?; + let trends = db.get_daily_trends(Some(0), Some(40_000), None, None, None)?; assert_eq!(trends.iter().map(|stat| stat.request_count).sum::(), 4); - let provider_stats = db.get_provider_stats(None, None, None)?; + let provider_stats = db.get_provider_stats(None, None, None, None, None)?; assert_eq!( provider_stats .iter() @@ -2802,7 +3182,7 @@ mod tests { .iter() .any(|stat| stat.provider_id == "_session")); - let model_stats = db.get_model_stats(None, None, None)?; + let model_stats = db.get_model_stats(None, None, None, None, None)?; assert_eq!( model_stats .iter() @@ -2994,7 +3374,7 @@ mod tests { )?; } - let summary = db.get_usage_summary(None, None, None)?; + let summary = db.get_usage_summary(None, None, None, None, None)?; assert_eq!(summary.total_requests, 9); let logs = db.get_request_logs(&LogFilters::default(), 0, 10)?; @@ -3042,7 +3422,7 @@ mod tests { )?; } - let stats = db.get_model_stats(None, None, None)?; + let stats = db.get_model_stats(None, None, None, None, None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].model, "claude-3-sonnet"); assert_eq!(stats[0].request_count, 1); @@ -3074,7 +3454,7 @@ mod tests { )?; } - let stats = db.get_provider_stats(Some(1500), Some(2500), Some("claude"))?; + let stats = db.get_provider_stats(Some(1500), Some(2500), Some("claude"), None, None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].provider_id, "p1"); assert_eq!(stats[0].request_count, 1); @@ -3106,7 +3486,7 @@ mod tests { )?; } - let stats = db.get_provider_stats(None, None, Some("opencode"))?; + let stats = db.get_provider_stats(None, None, Some("opencode"), None, None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].provider_id, "_opencode_session"); assert_eq!(stats[0].provider_name, "OpenCode (Session)"); @@ -3187,7 +3567,7 @@ mod tests { )?; } - let stats = db.get_provider_stats(Some(start), Some(end), Some("claude"))?; + let stats = db.get_provider_stats(Some(start), Some(end), Some("claude"), None, None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].provider_id, "p-rollup"); assert_eq!(stats[0].request_count, 8); @@ -3223,7 +3603,7 @@ mod tests { )?; } - let stats = db.get_daily_trends(Some(0), Some(15 * 60 * 60), Some("claude"))?; + let stats = db.get_daily_trends(Some(0), Some(15 * 60 * 60), Some("claude"), None, None)?; assert_eq!(stats.len(), 15); assert_eq!(stats[3].request_count, 1); @@ -3300,7 +3680,7 @@ mod tests { )?; } - let stats = db.get_daily_trends(Some(start), Some(end), Some("claude"))?; + let stats = db.get_daily_trends(Some(start), Some(end), Some("claude"), None, None)?; assert_eq!(stats.len(), 3); assert_eq!(stats[0].request_count, 1); assert_eq!(stats[0].total_tokens, 150); @@ -3385,7 +3765,7 @@ mod tests { )?; } - let stats = db.get_model_stats(Some(start), Some(end), Some("claude"))?; + let stats = db.get_model_stats(Some(start), Some(end), Some("claude"), None, None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].model, "claude-3-haiku"); assert_eq!(stats[0].request_count, 9); diff --git a/src/components/usage/ModelStatsTable.tsx b/src/components/usage/ModelStatsTable.tsx index b74b70f49..0d6ee3704 100644 --- a/src/components/usage/ModelStatsTable.tsx +++ b/src/components/usage/ModelStatsTable.tsx @@ -14,18 +14,26 @@ import type { UsageRangeSelection } from "@/types/usage"; interface ModelStatsTableProps { range: UsageRangeSelection; appType?: string; + providerName?: string; + model?: string; refreshIntervalMs: number; } export function ModelStatsTable({ range, appType, + providerName, + model, refreshIntervalMs, }: ModelStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useModelStats(range, appType, { - refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, - }); + const { data: stats, isLoading } = useModelStats( + range, + { appType, providerName, model }, + { + refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, + }, + ); if (isLoading) { return
    ; diff --git a/src/components/usage/ProviderStatsTable.tsx b/src/components/usage/ProviderStatsTable.tsx index 64c18213f..9d1ac0b18 100644 --- a/src/components/usage/ProviderStatsTable.tsx +++ b/src/components/usage/ProviderStatsTable.tsx @@ -14,18 +14,26 @@ import type { UsageRangeSelection } from "@/types/usage"; interface ProviderStatsTableProps { range: UsageRangeSelection; appType?: string; + providerName?: string; + model?: string; refreshIntervalMs: number; } export function ProviderStatsTable({ range, appType, + providerName, + model, refreshIntervalMs, }: ProviderStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useProviderStats(range, appType, { - refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, - }); + const { data: stats, isLoading } = useProviderStats( + range, + { appType, providerName, model }, + { + refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, + }, + ); if (isLoading) { return
    ; diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 2ea0204eb..dc7e3da75 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -19,13 +19,12 @@ import { } from "@/components/ui/select"; import { useRequestLogs } from "@/lib/query/usage"; import { - KNOWN_APP_TYPES, getFreshInputTokens, isUnpricedUsage, type LogFilters, type UsageRangeSelection, } from "@/types/usage"; -import { ChevronLeft, ChevronRight, Search, X } from "lucide-react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; import { UsageDateRangePicker } from "./UsageDateRangePicker"; import { fmtInt, @@ -38,6 +37,8 @@ interface RequestLogTableProps { range: UsageRangeSelection; rangeLabel: string; appType?: string; + providerName?: string; + model?: string; refreshIntervalMs: number; onRangeChange?: (range: UsageRangeSelection) => void; } @@ -46,21 +47,29 @@ export function RequestLogTable({ range, rangeLabel, appType: dashboardAppType, + providerName, + model, refreshIntervalMs, onRangeChange, }: RequestLogTableProps) { const { t, i18n } = useTranslation(); - const [appliedFilters, setAppliedFilters] = useState({}); - const [draftFilters, setDraftFilters] = useState({}); + // 应用/Provider/模型筛选已上移到 Dashboard 顶栏(全局生效); + // 这里只保留日志特有的状态码筛选。 + const [statusCode, setStatusCode] = useState(undefined); const [page, setPage] = useState(0); const [pageInput, setPageInput] = useState(""); const pageSize = 20; - const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all"; - const effectiveFilters: LogFilters = dashboardAppTypeActive - ? { ...appliedFilters, appType: dashboardAppType } - : appliedFilters; + const effectiveFilters: LogFilters = { + appType: + dashboardAppType && dashboardAppType !== "all" + ? dashboardAppType + : undefined, + providerName, + model, + statusCode, + }; const { data: result, isLoading } = useRequestLogs({ filters: effectiveFilters, @@ -80,37 +89,13 @@ export function RequestLogTable({ setPage(0); }, [ dashboardAppType, + providerName, + model, range.customEndDate, range.customStartDate, range.preset, ]); - const handleSearch = () => { - setAppliedFilters(draftFilters); - setPage(0); - }; - - const handleReset = () => { - setDraftFilters({}); - setAppliedFilters({}); - setPage(0); - }; - - const applySelectFilter = ( - key: K, - value: LogFilters[K], - ) => { - setDraftFilters((prev) => ({ - ...prev, - [key]: value, - })); - setAppliedFilters((prev) => ({ - ...prev, - [key]: value, - })); - setPage(0); - }; - const handleGoToPage = () => { const trimmed = pageInput.trim(); if (!/^\d+$/.test(trimmed)) return; @@ -127,44 +112,16 @@ export function RequestLogTable({
    - {/* App type */} - - {/* Status code */} - {/* Provider search */} -
    - - - setDraftFilters({ - ...draftFilters, - providerName: e.target.value || undefined, - }) - } - onKeyDown={(e) => { - if (e.key === "Enter") handleSearch(); - }} - /> -
    - - {/* Model search */} -
    - - setDraftFilters({ - ...draftFilters, - model: e.target.value || undefined, - }) - } - onKeyDown={(e) => { - if (e.key === "Enter") handleSearch(); - }} - /> -
    - {onRangeChange && ( )} - - {/* Search & Reset (icon-only) */} - -
    diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index 57d7d6b58..d0aeb75cd 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -22,8 +22,15 @@ import { } from "lucide-react"; import { ProviderIcon } from "@/components/ProviderIcon"; import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useQueryClient } from "@tanstack/react-query"; -import { usageKeys } from "@/lib/query/usage"; +import { usageKeys, useModelStats, useProviderStats } from "@/lib/query/usage"; import { useUsageEventBridge } from "@/hooks/useUsageEventBridge"; import { Accordion, @@ -48,13 +55,40 @@ const APP_FILTER_ICON: Record = { opencode: "opencode", }; +// Select 的 "all" 哨兵和用户自定义名称同处一个值域——真有来源/模型叫 "all" +// 就会撞名(重复 value、选中即清空筛选)。动态选项统一加前缀编码隔离值域。 +const DYNAMIC_OPTION_PREFIX = "v:"; +const encodeOptionValue = (name: string) => `${DYNAMIC_OPTION_PREFIX}${name}`; +const decodeOptionValue = (value: string) => + value === "all" ? undefined : value.slice(DYNAMIC_OPTION_PREFIX.length); + export function UsageDashboard() { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); const [range, setRange] = useState({ preset: "today" }); const [appType, setAppType] = useState("all"); + const [providerName, setProviderName] = useState( + undefined, + ); + const [model, setModel] = useState(undefined); const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000); + // 切应用时清掉下游筛选,避免留下一个在新范围内查无数据的"幽灵"组合; + // 切 Provider 同理清掉模型(模型选项随 Provider 级联)。 + const changeAppType = (next: AppTypeFilter) => { + setAppType(next); + if (next !== appType) { + setProviderName(undefined); + setModel(undefined); + } + }; + const changeProviderName = (next: string | undefined) => { + setProviderName(next); + if (next !== providerName) { + setModel(undefined); + } + }; + // 后端写入新日志时 emit `usage-log-recorded`,本 hook 立刻 invalidate 所有 // usage 查询,实现实时刷新(仅在 Dashboard 挂载时生效,离开页面自动取消监听) useUsageEventBridge(); @@ -84,6 +118,45 @@ export function UsageDashboard() { ).toLocaleString(locale)}`; }, [locale, range, resolvedRange.endDate, resolvedRange.startDate, t]); + // 顶栏下拉的选项池:Provider 列表只跟应用/时间范围走(不受自身选中值影响), + // 模型列表随所选 Provider 级联。两者都只列当前范围内真实有数据的条目。 + // refetchInterval 必须跟随面板的刷新设置——未筛选时这两个查询与统计表共享 + // query key,落下的话会以默认 30s 拖着同 key 查询一起轮询,"--" 形同虚设。 + const optionsRefetch = { + refetchInterval: + refreshIntervalMs > 0 ? refreshIntervalMs : (false as const), + }; + const { data: providerOptionsData } = useProviderStats( + range, + { appType }, + optionsRefetch, + ); + const { data: modelOptionsData } = useModelStats( + range, + { appType, providerName }, + optionsRefetch, + ); + + const providerOptions = useMemo(() => { + const names = new Set(); + for (const stat of providerOptionsData ?? []) { + names.add(stat.providerName); + } + // 数据刷新后选中项可能掉出列表(如改了时间范围);补回去保证 Select + // 仍能渲染选中文案,用户看得见才能主动清除。 + if (providerName) names.add(providerName); + return Array.from(names); + }, [providerOptionsData, providerName]); + + const modelOptions = useMemo(() => { + const names = new Set(); + for (const stat of modelOptionsData ?? []) { + names.add(stat.model); + } + if (model) names.add(model); + return Array.from(names); + }, [modelOptionsData, model]); + return ( setAppType(type)} + onClick={() => changeAppType(type)} title={label} aria-label={label} className={cn( @@ -131,6 +204,58 @@ export function UsageDashboard() { })}
    + + + +
    + + + + + + + + {REFRESH_INTERVAL_OPTIONS_MS.map((ms) => ( + + {ms > 0 ? `${ms / 1000}s` : t("usage.refreshOff")} + + ))} + + - + {triggerLabel} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b5f72703d..6c2f1b1d0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1446,6 +1446,8 @@ "allModels": "All Models", "filterBySource": "Filter by source", "filterByModel": "Filter by model", + "refreshInterval": "Auto-refresh interval", + "refreshOff": "Off", "timeRange": "Time Range", "customRange": "Calendar Filter", "customRangeHint": "Supports both date and time", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 18dbec841..b7130d992 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1446,6 +1446,8 @@ "allModels": "すべてのモデル", "filterBySource": "ソースで絞り込む", "filterByModel": "モデルで絞り込む", + "refreshInterval": "自動更新間隔", + "refreshOff": "オフ", "timeRange": "期間", "customRange": "カレンダーフィルター", "customRangeHint": "日付と時刻の両方に対応", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 1eb23c40d..55bdafa22 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1418,6 +1418,8 @@ "allModels": "全部模型", "filterBySource": "依來源篩選", "filterByModel": "依模型篩選", + "refreshInterval": "自動重新整理間隔", + "refreshOff": "關閉", "timeRange": "時間範圍", "customRange": "日曆篩選", "customRangeHint": "支援日期與時間", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c914c00ad..2b915d8f4 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1446,6 +1446,8 @@ "allModels": "全部模型", "filterBySource": "按来源筛选", "filterByModel": "按模型筛选", + "refreshInterval": "自动刷新间隔", + "refreshOff": "关闭", "timeRange": "时间范围", "customRange": "日历筛选", "customRangeHint": "支持日期与时间", From 4f355970e112a382d6ac82ec76780284bb926624 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 11 Jun 2026 23:15:11 +0800 Subject: [PATCH 39/66] chore(presets): remove LemonData provider and demote SudoCode to regular provider - LemonData: delete the provider preset from all apps (claude, claude-desktop, codex, gemini, hermes, opencode, openclaw), remove partner promotion copy (zh/en/ja/zh-TW), icon index/metadata entries, sponsor ads in all READMEs, and the logo/icon PNG assets. - SudoCode: drop the isPartner flag and partnerPromotionKey across all app presets and remove the now-orphaned partner promotion copy; it stays as a regular third_party provider, keeping its icon. --- README.md | 5 --- README_DE.md | 5 --- README_JA.md | 4 --- README_ZH.md | 4 --- assets/partners/logos/lemondata.png | Bin 6411 -> 0 bytes src/config/claudeDesktopProviderPresets.ts | 16 --------- src/config/claudeProviderPresets.ts | 18 ---------- src/config/codexProviderPresets.ts | 18 ---------- src/config/geminiProviderPresets.ts | 21 ------------ src/config/hermesProviderPresets.ts | 21 ------------ src/config/openclawProviderPresets.ts | 38 --------------------- src/config/opencodeProviderPresets.ts | 30 ---------------- src/i18n/locales/en.json | 2 -- src/i18n/locales/ja.json | 2 -- src/i18n/locales/zh-TW.json | 2 -- src/i18n/locales/zh.json | 2 -- src/icons/extracted/index.ts | 2 -- src/icons/extracted/lemondata.png | Bin 14359 -> 0 bytes src/icons/extracted/metadata.ts | 7 ---- 19 files changed, 197 deletions(-) delete mode 100644 assets/partners/logos/lemondata.png delete mode 100644 src/icons/extracted/lemondata.png diff --git a/README.md b/README.md index 8c3bcc870..a3f99c509 100644 --- a/README.md +++ b/README.md @@ -106,11 +106,6 @@ Register now via this lin Thanks to Micu API for sponsoring this project! Micu API is a global LLM relay service provider dedicated to delivering the best cost-performance ratio with high stability. Backed by a registered enterprise for core assurance, eliminating any risk of service discontinuation, with fast official invoicing support! We champion "zero cost to try": top up from as low as ¥1 with no minimum, and get fee-free refunds anytime! Micu API offers an exclusive deal for CC Switch users: register via this link and enter promo code "ccswitch" when topping up to enjoy a 10% discount! - -LemonData -Thanks to LemonData for sponsoring this project! LemonData is a high-performance AI API aggregation platform — one API key for 300+ models including GPT, Claude, Gemini, DeepSeek, and more. All models priced 30–70% below official rates with auto-failover, smart routing, and unlimited concurrency. New users get $1 free credit instantly upon registration — sign up via this linkto claim your bonus and start building right away! - - CTok Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click here to register! diff --git a/README_DE.md b/README_DE.md index f7420641b..b79becbce 100644 --- a/README_DE.md +++ b/README_DE.md @@ -106,11 +106,6 @@ Registrieren Sie sich jetzt über diesen Link und geben Sie beim Aufladen den Gutscheincode „ccswitch" ein, um 10 % Rabatt zu erhalten! - -LemonData -Danke an LemonData für die Unterstützung dieses Projekts! LemonData ist eine leistungsstarke KI-API-Aggregationsplattform — ein API-Schlüssel für mehr als 300 Modelle, darunter GPT, Claude, Gemini, DeepSeek und weitere. Alle Modelle zu Preisen 30–70 % unter den offiziellen Tarifen, mit automatischem Failover, intelligentem Routing und unbegrenzter Nebenläufigkeit. Neukunden erhalten bei der Registrierung sofort 1 $ Gratisguthaben — registrieren Sie sich über diesen Link, um Ihren Bonus einzulösen und sofort mit dem Entwickeln zu beginnen! - - CTok Danke an CTok.ai für die Unterstützung dieses Projekts! CTok.ai widmet sich dem Aufbau einer Komplettlösung für KI-Programmierwerkzeuge. Wir bieten professionelle Claude-Code-Pakete und Dienste einer technischen Community, mit Unterstützung für Google Gemini und OpenAI Codex. Durch sorgfältig gestaltete Pläne und eine professionelle Tech-Community geben wir Entwicklern verlässliche Servicegarantien und kontinuierlichen technischen Support an die Hand und machen KI-gestützte Programmierung zu einem echten Produktivitätswerkzeug. Klicken Sie hier, um sich zu registrieren! diff --git a/README_JA.md b/README_JA.md index 046567c6a..7b135cbb7 100644 --- a/README_JA.md +++ b/README_JA.md @@ -105,10 +105,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / Micu Micu API のご支援に感謝します!Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応!「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能!CC Switch ユーザー向けの限定特典:こちらのリンクから登録し、チャージ時にプロモコード「ccswitch」を入力すると 10% 割引 が適用されます! - -LemonData -LemonData のご支援に感謝します!LemonData は高性能 AI API アグリゲーションプラットフォームで、GPT、Claude、Gemini、DeepSeek など 300 以上のモデルに 1 つの API キーでアクセス可能。全モデルが公式価格の 30〜70% オフで自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。新規ユーザーは登録だけで即座に $1 の無料クレジットを獲得 — こちらのリンクから登録してボーナスを獲得し、すぐに開発を始めましょう! - CTok diff --git a/README_ZH.md b/README_ZH.md index 984f45242..fdfe3eb14 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -106,10 +106,6 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 Micu 感谢 米醋API 赞助了本项目!米醋API 是一家致力于提供极致性价比与高稳定性的全球大模型中转服务商。米醋API 背后有实体企业做核心保障,杜绝跑路风险,支持极速正规开票!我们主打“试错零成本”:1 元起充低门槛,0 手续费随时退款!米醋API 为本软件的用户提供了特别优惠,使用此链接注册并在充值时填写"ccswitch"优惠码可享九折优惠! - -LemonData -感谢 LemonData 赞助了本项目!LemonData 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 GPT、Claude、Gemini、DeepSeek 等 300+ 模型。所有模型定价为官方价格的 30%-70%,支持自动故障转移、智能路由和无限并发。新用户注册即获 $1 免费额度——通过此链接注册即可领取奖励,立即开始开发! - CTok diff --git a/assets/partners/logos/lemondata.png b/assets/partners/logos/lemondata.png deleted file mode 100644 index b645b4b14f17ee958b830af86bf3fab48e24aae6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6411 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 = { eflowcode: _eflowcode, hermes: _hermes, huoshan: _huoshan, - lemondata: _lemondata, pateway: _pateway, pipellm: _pipellm, relaxcode: _relaxcode, diff --git a/src/icons/extracted/lemondata.png b/src/icons/extracted/lemondata.png deleted file mode 100644 index 8a6d7ace92f275e1873577de2efb6fbc15a93cce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14359 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 = { keywords: ["hermes", "agent", "nous", "nousresearch"], defaultColor: "#000000", }, - lemondata: { - name: "lemondata", - displayName: "LemonData", - category: "ai-provider", - keywords: ["lemondata", "lemon", "lemoncode"], - defaultColor: "#F5C518", - }, packycode: { name: "packycode", displayName: "PackyCode", From 948d7627923b4eb5cc2f7bdeb82185b770e429e3 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 12 Jun 2026 10:40:51 +0800 Subject: [PATCH 40/66] feat(codex): add unified session history toggle for official providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex buckets resume history by the model_provider id recorded in each session: official runs (no key, built-in "openai") and cc-switch third-party runs (shared "custom") are mutually invisible in the resume picker. Add an opt-in setting that runs official providers under the shared "custom" id so future official sessions land in the same history bucket as third-party ones. Forward-only by design: existing sessions are not migrated. When enabled, official live config.toml gets model_provider = "custom" plus a [model_providers.custom] entry that mirrors the built-in openai provider (requires_openai_auth routes auth to the ChatGPT login in auth.json, name "OpenAI" keeps is_openai() feature gates, explicit supports_websockets/wire_api restore built-in defaults). auth.json is untouched. Key invariants: - Injection lives only in the live config: switch-away backfill strips the exact injected shape, so stored provider configs stay clean and turning the toggle off fully reverts on the next write. - Toggle changes apply immediately via a takeover-aware reapply: when the proxy owns the live config (backup/placeholder present), only the live backup is updated, mirroring the provider-switch path. - The takeover backup path runs the same injection so a takeover release restores a config that still carries the unified routing. - Injection refuses to activate a foreign [model_providers.custom] table (e.g. stale entry with a third-party base_url) to avoid routing ChatGPT OAuth traffic to an unknown backend. The toggle lives under Settings → Codex App Enhancements; the description warns that resuming old sessions across providers may fail because encrypted_content reasoning only decrypts on the backend that created it (upstream treats cross-provider resume as unsupported). --- src-tauri/src/codex_config.rs | 284 ++++++++++++++++++ src-tauri/src/commands/settings.rs | 17 +- src-tauri/src/services/provider/live.rs | 13 + src-tauri/src/services/provider/mod.rs | 42 +++ src-tauri/src/services/proxy.rs | 8 + src-tauri/src/settings.rs | 17 ++ src/components/settings/CodexAuthSettings.tsx | 12 +- src/hooks/useSettingsForm.ts | 3 + src/i18n/locales/en.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/zh-TW.json | 2 + src/i18n/locales/zh.json | 2 + src/lib/schemas/settings.ts | 1 + src/types.ts | 3 + 14 files changed, 406 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 965d5848a..f2735f8ce 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -1043,16 +1043,197 @@ pub fn read_codex_live_settings() -> Result { Ok(json!({ "auth": auth, "config": cfg_text })) } +/// `[model_providers.custom]` entry that makes an official (ChatGPT OAuth) +/// provider behave like Codex's built-in `openai` entry while running under +/// the shared custom id: `requires_openai_auth` routes auth to the ChatGPT +/// login in `auth.json` (base_url then defaults to the official Codex +/// backend), `name = "OpenAI"` keeps Codex's `is_openai()` feature gates +/// (web search, remote compaction), and `supports_websockets` restores the +/// built-in default that custom entries otherwise lose. +fn codex_unified_official_provider_table() -> toml_edit::Table { + let mut table = toml_edit::Table::new(); + table["name"] = toml_edit::value("OpenAI"); + table["requires_openai_auth"] = toml_edit::value(true); + table["supports_websockets"] = toml_edit::value(true); + table["wire_api"] = toml_edit::value("responses"); + table +} + +fn table_matches_codex_unified_official_provider(table: &toml_edit::Table) -> bool { + table.len() == 4 + && table.get("name").and_then(|item| item.as_str()) == Some("OpenAI") + && table + .get("requires_openai_auth") + .and_then(|item| item.as_bool()) + == Some(true) + && table + .get("supports_websockets") + .and_then(|item| item.as_bool()) + == Some(true) + && table.get("wire_api").and_then(|item| item.as_str()) == Some("responses") +} + +/// 统一 Codex 会话历史:把官方供应商的 live 配置改写为以共享的 +/// `custom` model_provider 标识运行(认证仍走 `auth.json` 的 ChatGPT 登录), +/// 使开关开启后创建的官方会话与第三方会话共用同一个 resume 历史桶。 +/// +/// 两种情况拒绝注入、原样返回: +/// - 配置已有显式 `model_provider`:用户手工指定的路由不被覆盖; +/// - 配置已有形态不同的 `[model_providers.custom]` 表:设置 `model_provider` +/// 会激活这张我们不认识的表(可能带第三方 base_url/token,会把 ChatGPT +/// OAuth 流量路由到错误后端),宁可让开关对该配置不生效。 +pub fn inject_codex_unified_session_bucket(config_text: &str) -> Result { + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + + if doc.get("model_provider").is_some() { + return Ok(config_text.to_string()); + } + + let existing_custom_conflicts = doc + .get("model_providers") + .and_then(|item| item.as_table()) + .and_then(|providers| providers.get(CC_SWITCH_CODEX_MODEL_PROVIDER_ID)) + .and_then(|item| item.as_table()) + .is_some_and(|table| !table_matches_codex_unified_official_provider(table)); + if existing_custom_conflicts { + log::warn!( + "官方 Codex 配置已存在自定义 [model_providers.custom],跳过统一会话路由注入以避免激活未知路由" + ); + return Ok(config_text.to_string()); + } + + doc["model_provider"] = toml_edit::value(CC_SWITCH_CODEX_MODEL_PROVIDER_ID); + + if doc.get("model_providers").is_none() { + let mut parent = toml_edit::Table::new(); + parent.set_implicit(true); + doc["model_providers"] = toml_edit::Item::Table(parent); + } + if let Some(providers) = doc["model_providers"].as_table_mut() { + if !providers.contains_key(CC_SWITCH_CODEX_MODEL_PROVIDER_ID) { + providers.insert( + CC_SWITCH_CODEX_MODEL_PROVIDER_ID, + toml_edit::Item::Table(codex_unified_official_provider_table()), + ); + } + } + Ok(doc.to_string()) +} + +/// `inject_codex_unified_session_bucket` 的反向操作:从配置文本里剥掉注入的 +/// 统一会话路由,保证切换回填不会把它带进数据库的存储配置(关闭开关后 +/// 切换即可完全还原)。仅当形态与注入产物完全一致时才剥离;第三方模板和 +/// 用户自定义的 `custom` 条目(带 base_url 等差异字段)原样保留。 +pub fn strip_codex_unified_session_bucket(config_text: &str) -> Result { + if !config_text.contains("model_provider") { + return Ok(config_text.to_string()); + } + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + + if doc.get("model_provider").and_then(|item| item.as_str()) + != Some(CC_SWITCH_CODEX_MODEL_PROVIDER_ID) + { + return Ok(config_text.to_string()); + } + let matches_injected = doc + .get("model_providers") + .and_then(|item| item.as_table()) + .and_then(|providers| providers.get(CC_SWITCH_CODEX_MODEL_PROVIDER_ID)) + .and_then(|item| item.as_table()) + .is_some_and(table_matches_codex_unified_official_provider); + if !matches_injected { + return Ok(config_text.to_string()); + } + + doc.as_table_mut().remove("model_provider"); + let providers_empty = doc["model_providers"] + .as_table_mut() + .map(|providers| { + providers.remove(CC_SWITCH_CODEX_MODEL_PROVIDER_ID); + providers.is_empty() + }) + .unwrap_or(false); + if providers_empty { + doc.as_table_mut().remove("model_providers"); + } + Ok(doc.to_string()) +} + +/// 统一会话开关开启时,把官方供应商 `{ auth, config }` 设置对象中的 +/// config 文本注入共享 custom 路由;开关关闭或非官方供应商时不做改动。 +/// +/// 普通 live 写入(`write_codex_live_for_provider`)与代理接管备份 +/// (`update_live_backup_from_provider`)两条落盘路径共用:接管期间 +/// live 归代理所有,注入必须进备份,接管释放恢复的 live 才带统一路由。 +pub fn apply_codex_unified_session_bucket_to_settings( + category: Option<&str>, + settings: &mut Value, +) -> Result<(), AppError> { + if category != Some("official") || !crate::settings::unify_codex_session_history() { + return Ok(()); + } + let config_text = settings + .get("config") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(); + let injected = inject_codex_unified_session_bucket(&config_text)?; + if injected != config_text { + if let Some(obj) = settings.as_object_mut() { + obj.insert("config".to_string(), Value::String(injected)); + } + } + Ok(()) +} + +/// Backfill helper: strip the unified-session injection from a live +/// `{ auth, config }` settings object before it is stored back to the DB. +pub fn strip_codex_unified_session_bucket_from_settings( + settings: &mut Value, +) -> Result<(), AppError> { + let Some(config_text) = settings + .get("config") + .and_then(|value| value.as_str()) + .map(str::to_string) + else { + return Ok(()); + }; + let stripped = strip_codex_unified_session_bucket(&config_text)?; + if stripped != config_text { + if let Some(obj) = settings.as_object_mut() { + obj.insert("config".to_string(), Value::String(stripped)); + } + } + Ok(()) +} + /// Route a Codex live write between full auth+config or config-only. /// /// Official providers with usable login material own `auth.json`. Third-party /// providers only touch `config.toml` when the compatibility setting is enabled /// so the user's ChatGPT login cache survives provider switches. +/// +/// 统一会话开关开启时,官方配置在落盘前注入共享的 `custom` 路由 +/// (见 `inject_codex_unified_session_bucket`)。 pub fn write_codex_live_for_provider( category: Option<&str>, auth: &Value, config_text: Option<&str>, ) -> Result<(), AppError> { + let unified_official_config = + if category == Some("official") && crate::settings::unify_codex_session_history() { + Some(inject_codex_unified_session_bucket( + config_text.unwrap_or(""), + )?) + } else { + None + }; + let config_text = unified_official_config.as_deref().or(config_text); + let should_write_auth = (category == Some("official") && codex_auth_has_login_material(auth)) || (category != Some("official") && !crate::settings::preserve_codex_official_auth_on_switch()); @@ -1257,6 +1438,109 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn unified_session_bucket_injects_for_empty_official_config() { + let injected = inject_codex_unified_session_bucket("").expect("inject"); + let doc: toml::Table = toml::from_str(&injected).expect("parse injected config"); + + assert_eq!( + doc.get("model_provider").and_then(|v| v.as_str()), + Some(CC_SWITCH_CODEX_MODEL_PROVIDER_ID) + ); + let custom = doc["model_providers"][CC_SWITCH_CODEX_MODEL_PROVIDER_ID] + .as_table() + .expect("custom provider table"); + assert_eq!(custom.get("name").and_then(|v| v.as_str()), Some("OpenAI")); + assert_eq!( + custom.get("requires_openai_auth").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + custom.get("supports_websockets").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + custom.get("wire_api").and_then(|v| v.as_str()), + Some("responses") + ); + } + + #[test] + fn unified_session_bucket_preserves_other_keys_and_explicit_routing() { + let with_catalog = "model_catalog_json = \"cc-switch-model-catalog.json\"\n"; + let injected = inject_codex_unified_session_bucket(with_catalog).expect("inject"); + assert!(injected.contains("model_catalog_json")); + assert!(injected.contains("model_provider = \"custom\"")); + + // 用户显式指定过 model_provider 的官方配置不被覆盖 + let explicit = "model_provider = \"openai_https\"\n"; + let unchanged = inject_codex_unified_session_bucket(explicit).expect("inject"); + assert_eq!(unchanged, explicit); + } + + #[test] + fn unified_session_bucket_skips_conflicting_custom_table() { + // 残留的非注入形态 custom 表:设置 model_provider 会把官方流量 + // 路由到表里的第三方端点,必须整体拒绝注入。 + let stale = r#"[model_providers.custom] +name = "Relay" +base_url = "https://relay.example/v1" +"#; + let unchanged = inject_codex_unified_session_bucket(stale).expect("inject"); + assert_eq!(unchanged, stale); + + // 已是注入形态的 custom 表(如重复注入)则照常补上 model_provider + let injected_once = inject_codex_unified_session_bucket("").expect("inject"); + let reinjected = inject_codex_unified_session_bucket(&injected_once).expect("re-inject"); + assert_eq!(reinjected, injected_once); + } + + #[test] + fn unified_session_bucket_strip_round_trips_injection() { + let injected = inject_codex_unified_session_bucket("").expect("inject"); + let stripped = strip_codex_unified_session_bucket(&injected).expect("strip"); + assert_eq!(stripped.trim(), ""); + + let with_catalog = "model_catalog_json = \"cc-switch-model-catalog.json\"\n"; + let injected = inject_codex_unified_session_bucket(with_catalog).expect("inject"); + let stripped = strip_codex_unified_session_bucket(&injected).expect("strip"); + assert_eq!(stripped, with_catalog); + } + + #[test] + fn unified_session_bucket_strip_keeps_third_party_custom_entry() { + // 第三方模板同样用 custom 路由,但条目带 base_url 等差异字段, + // 形态不等于注入产物,必须原样保留。 + let third_party = r#"model_provider = "custom" + +[model_providers.custom] +name = "Relay" +base_url = "https://relay.example/v1" +wire_api = "responses" +requires_openai_auth = true +"#; + let untouched = strip_codex_unified_session_bucket(third_party).expect("strip"); + assert_eq!(untouched, third_party); + } + + #[test] + fn unified_session_bucket_strip_from_settings_only_touches_config() { + let injected = inject_codex_unified_session_bucket("").expect("inject"); + let mut settings = json!({ + "auth": { "tokens": { "access_token": "secret" } }, + "config": injected, + }); + strip_codex_unified_session_bucket_from_settings(&mut settings).expect("strip settings"); + assert_eq!( + settings + .get("config") + .and_then(|v| v.as_str()) + .map(str::trim), + Some("") + ); + assert!(settings.pointer("/auth/tokens/access_token").is_some()); + } + #[test] fn extract_base_url_prefers_active_provider_section() { let input = r#"model_provider = "azure" diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 0996f5311..d49828170 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -65,10 +65,25 @@ pub async fn get_settings() -> Result { /// 保存设置 #[tauri::command] -pub async fn save_settings(settings: crate::settings::AppSettings) -> Result { +pub async fn save_settings( + state: tauri::State<'_, crate::store::AppState>, + settings: crate::settings::AppSettings, +) -> Result { let existing = crate::settings::get_settings(); let merged = merge_settings_for_save(settings, &existing); + let unify_codex_changed = + merged.unify_codex_session_history != existing.unify_codex_session_history; crate::settings::update_settings(merged).map_err(|e| e.to_string())?; + + // 统一会话开关变更时立即重写当前官方 Codex 供应商的 live 配置, + // 不必等下一次切换才生效。 + if unify_codex_changed { + if let Err(err) = + crate::services::provider::reapply_current_codex_official_live(state.inner()) + { + log::warn!("统一 Codex 会话历史开关变更后重写 live 配置失败: {err}"); + } + } Ok(true) } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index e3ce46b83..945a7cf5f 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -596,6 +596,19 @@ fn restore_live_settings_for_provider_backfill( ); } + // 统一会话开关注入的共享 `custom` 路由只属于 live 配置;切换回填时 + // 必须剥掉,否则官方供应商的存储配置被污染,关闭开关后无法还原。 + if provider.category.as_deref() == Some("official") { + if let Err(err) = + crate::codex_config::strip_codex_unified_session_bucket_from_settings(&mut settings) + { + log::warn!( + "Failed to strip unified session bucket while backfilling '{}': {err}", + provider.id + ); + } + } + // `modelCatalog` is a cc-switch–private field whose SSOT is the DB. Live's // `config.toml` only carries a lossy projection (`model_catalog_json` → // generated catalog file) that proxy takeover/restore cycles and Codex.app diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index f41fb7827..d7eb15144 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -42,6 +42,48 @@ use live::{ }; use usage::validate_usage_script; +/// 统一会话开关变更后,立即按新开关状态重写当前官方 Codex 供应商的 +/// live 配置,使开关即时生效(无需等下一次切换)。 +/// 当前供应商非官方(或不存在)时为 no-op:注入只作用于官方配置, +/// 第三方 live 配置不受开关影响。 +pub fn reapply_current_codex_official_live(state: &AppState) -> Result { + let current_id = ProviderService::current(state, AppType::Codex)?; + if current_id.is_empty() { + return Ok(false); + } + let providers = state.db.get_all_providers(AppType::Codex.as_str())?; + let Some(provider) = providers.get(¤t_id) else { + return Ok(false); + }; + if provider.category.as_deref() != Some("official") { + return Ok(false); + } + + // 代理接管期间 live 归代理所有(开启代理时官方供应商只警告不拦截, + // 二者可以共存)。与切换/保存路径一致:以 backup/占位符为所有权信号, + // 只更新备份,注入后的配置由接管释放时的恢复路径落盘。 + let has_live_backup = + futures::executor::block_on(state.db.get_live_backup(AppType::Codex.as_str())) + .ok() + .flatten() + .is_some(); + let live_taken_over = state + .proxy_service + .detect_takeover_in_live_config_for_app(&AppType::Codex); + if has_live_backup || live_taken_over { + futures::executor::block_on( + state + .proxy_service + .update_live_backup_from_provider(AppType::Codex.as_str(), provider), + ) + .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; + return Ok(true); + } + + live::write_live_with_common_config(&state.db, &AppType::Codex, provider)?; + Ok(true) +} + /// Provider business logic service pub struct ProviderService; diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index d77c7169e..b99bbf5a4 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -2033,6 +2033,14 @@ impl ProxyService { )?; Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?; } + + // 统一会话开关:备份是接管释放时恢复 live 的来源,官方配置的 + // 共享 custom 路由注入必须落在备份里,否则恢复后开关失效。 + crate::codex_config::apply_codex_unified_session_bucket_to_settings( + provider.category.as_deref(), + &mut effective_settings, + ) + .map_err(|e| format!("注入统一会话路由失败: {e}"))?; } let backup_json = match app_type_enum { diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 2d32a9401..cc11aa59a 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -357,6 +357,12 @@ pub struct AppSettings { /// Opt-in: defaults to false so third-party switches cleanly overwrite auth.json. #[serde(default)] pub preserve_codex_official_auth_on_switch: bool, + /// Run official Codex providers under the shared "custom" model_provider id + /// so future official sessions share one resume-history bucket with + /// third-party providers. Forward-only: existing sessions are not migrated. + /// Opt-in: defaults to false. + #[serde(default)] + pub unify_codex_session_history: bool, /// User has confirmed the failover toggle first-run notice #[serde(default, skip_serializing_if = "Option::is_none")] pub failover_confirmed: Option, @@ -475,6 +481,7 @@ impl Default for AppSettings { stream_check_confirmed: None, enable_failover_toggle: false, preserve_codex_official_auth_on_switch: false, + unify_codex_session_history: false, failover_confirmed: None, first_run_notice_confirmed: None, common_config_confirmed: None, @@ -829,6 +836,16 @@ pub fn preserve_codex_official_auth_on_switch() -> bool { .preserve_codex_official_auth_on_switch } +pub fn unify_codex_session_history() -> bool { + settings_store() + .read() + .unwrap_or_else(|e| { + log::warn!("设置锁已毒化,使用恢复值: {e}"); + e.into_inner() + }) + .unify_codex_session_history +} + // ===== 当前供应商管理函数 ===== /// 获取指定应用类型的当前供应商 ID(从本地 settings 读取) diff --git a/src/components/settings/CodexAuthSettings.tsx b/src/components/settings/CodexAuthSettings.tsx index 0472acfdc..5b2ad3826 100644 --- a/src/components/settings/CodexAuthSettings.tsx +++ b/src/components/settings/CodexAuthSettings.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { KeyRound } from "lucide-react"; +import { History, KeyRound } from "lucide-react"; import type { SettingsFormState } from "@/hooks/useSettings"; import { ToggleRow } from "@/components/ui/toggle-row"; @@ -30,6 +30,16 @@ export function CodexAuthSettings({ onChange({ preserveCodexOfficialAuthOnSwitch: value }) } /> + + } + title={t("settings.unifyCodexSessionHistory")} + description={t("settings.unifyCodexSessionHistoryDescription")} + checked={settings.unifyCodexSessionHistory ?? false} + onCheckedChange={(value) => + onChange({ unifyCodexSessionHistory: value }) + } + /> ); } diff --git a/src/hooks/useSettingsForm.ts b/src/hooks/useSettingsForm.ts index 245b7a66d..03c7e34a3 100644 --- a/src/hooks/useSettingsForm.ts +++ b/src/hooks/useSettingsForm.ts @@ -118,6 +118,7 @@ export function useSettingsForm(): UseSettingsFormResult { skipClaudeOnboarding: data.skipClaudeOnboarding ?? false, preserveCodexOfficialAuthOnSwitch: data.preserveCodexOfficialAuthOnSwitch ?? false, + unifyCodexSessionHistory: data.unifyCodexSessionHistory ?? false, claudeConfigDir: sanitizeDir(data.claudeConfigDir), codexConfigDir: sanitizeDir(data.codexConfigDir), geminiConfigDir: sanitizeDir(data.geminiConfigDir), @@ -143,6 +144,7 @@ export function useSettingsForm(): UseSettingsFormResult { enableClaudePluginIntegration: false, skipClaudeOnboarding: false, preserveCodexOfficialAuthOnSwitch: false, + unifyCodexSessionHistory: false, language: readPersistedLanguage(), } as SettingsFormState); @@ -182,6 +184,7 @@ export function useSettingsForm(): UseSettingsFormResult { skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false, preserveCodexOfficialAuthOnSwitch: serverData.preserveCodexOfficialAuthOnSwitch ?? false, + unifyCodexSessionHistory: serverData.unifyCodexSessionHistory ?? false, claudeConfigDir: sanitizeDir(serverData.claudeConfigDir), codexConfigDir: sanitizeDir(serverData.codexConfigDir), geminiConfigDir: sanitizeDir(serverData.geminiConfigDir), diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 54bc37ea8..77dd67da9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -670,6 +670,8 @@ "codexAuth": "Codex App Enhancements", "preserveCodexOfficialAuthOnSwitch": "Keep official login when switching third-party providers", "preserveCodexOfficialAuthOnSwitchDescription": "When enabled, you can use the Codex app's official plugins, mobile remote control, and other features while using third-party APIs.", + "unifyCodexSessionHistory": "Unified Codex session history", + "unifyCodexSessionHistoryDescription": "When enabled, the official subscription runs under the shared \"custom\" provider id, so future official sessions appear in the same history list as third-party sessions (existing sessions are not migrated). Note: resuming an old session across providers may fail because its encrypted_content reasoning can only be decrypted by the backend that created it.", "appVisibility": { "title": "Homepage Display", "description": "Choose which apps to show on the homepage", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f56ddbd71..47d87ae80 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -670,6 +670,8 @@ "codexAuth": "Codex アプリ拡張", "preserveCodexOfficialAuthOnSwitch": "サードパーティ切替時に公式ログインを保持", "preserveCodexOfficialAuthOnSwitchDescription": "オンにすると、サードパーティ API を使用する際に Codex アプリの公式プラグインやスマホからのリモート操作などの機能を利用できます。", + "unifyCodexSessionHistory": "Codex セッション履歴を統一", + "unifyCodexSessionHistoryDescription": "オンにすると、公式サブスクリプションも共有の custom プロバイダー ID で動作し、今後の公式セッションはサードパーティのセッションと同じ履歴リストに表示されます(既存セッションは移行しません)。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content の推論内容を相手のバックエンドが復号できず、再開に失敗する場合があります。", "appVisibility": { "title": "ホームページ表示", "description": "ホームページに表示するアプリを選択", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 71db22265..d4a0c7adc 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -670,6 +670,8 @@ "codexAuth": "Codex 應用增強", "preserveCodexOfficialAuthOnSwitch": "切換第三方時保留官方登入", "preserveCodexOfficialAuthOnSwitchDescription": "開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能", + "unifyCodexSessionHistory": "統一 Codex 會話歷史", + "unifyCodexSessionHistoryDescription": "開啟後,官方訂閱將以共享的 custom 供應商標識執行,未來的官方會話與第三方會話出現在同一歷史清單中(不遷移既有會話)。注意:跨供應商繼續舊會話時,對方後端可能無法解密會話中的 encrypted_content 推理內容,導致繼續失敗", "appVisibility": { "title": "主頁面顯示", "description": "選擇在主頁面顯示的應用程式", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7a8427cf4..e81ab1563 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -670,6 +670,8 @@ "codexAuth": "Codex 应用增强", "preserveCodexOfficialAuthOnSwitch": "切换第三方时保留官方登录", "preserveCodexOfficialAuthOnSwitchDescription": "开启后可以在使用第三方 API 的时候使用 Codex 应用的官方插件、手机远程操作等功能", + "unifyCodexSessionHistory": "统一 Codex 会话历史", + "unifyCodexSessionHistoryDescription": "开启后,官方订阅将以共享的 custom 供应商标识运行,未来的官方会话与第三方会话出现在同一历史列表中(不迁移既有会话)。注意:跨供应商继续旧会话时,对方后端可能无法解密会话中的 encrypted_content 推理内容,导致继续失败", "appVisibility": { "title": "主页面显示", "description": "选择在主页面显示的应用", diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index 33ee0eb66..3e52a5cb6 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -16,6 +16,7 @@ export const settingsSchema = z.object({ launchOnStartup: z.boolean().optional(), enableLocalProxy: z.boolean().optional(), preserveCodexOfficialAuthOnSwitch: z.boolean().optional(), + unifyCodexSessionHistory: z.boolean().optional(), language: z.enum(["en", "zh", "zh-TW", "ja"]).optional(), // 设备级目录覆盖 diff --git a/src/types.ts b/src/types.ts index a09238110..954eecc8e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -351,6 +351,9 @@ export interface Settings { enableFailoverToggle?: boolean; // Preserve Codex ChatGPT login in auth.json when switching third-party providers preserveCodexOfficialAuthOnSwitch?: boolean; + // Run official Codex under the shared "custom" provider id so future + // sessions share one resume-history bucket with third-party providers + unifyCodexSessionHistory?: boolean; // User has confirmed the failover toggle first-run notice failoverConfirmed?: boolean; // User has confirmed the first-run welcome notice From eab6bfd20c2dc4c59387e66d159495ee7474b818 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 12 Jun 2026 23:35:01 +0800 Subject: [PATCH 41/66] feat(codex): add opt-in migration and ledger-based restore for unified session history - Enable dialog gains a checkbox (default off) to migrate existing official sessions from the built-in "openai" bucket into the shared "custom" bucket, with per-generation backups; failed migrations retry at startup - Disable dialog offers a precise restore driven by the backup ledger: only sessions recorded as "openai" in backups are flipped back, and sessions created while the toggle was on are never touched - Completion marker and backup generations are bound to the canonical Codex config dir; migrate/restore serialize on an op lock and the marker is written conditionally inside the settings write lock - save_settings rolls back the toggle and fails the save when the live rewrite fails; migration additionally requires the live config to actually route to the shared bucket (skips with live_not_unified so refused injection or proxy takeover can't split history) - Restore refuses to run while the toggle is (re-)enabled and reports nothing_to_restore instead of a zero-count success; local migration markers are now backend-owned in merge_settings_for_save so stale frontend payloads can't resurrect them - Settings autosave reverts optimistic form state on failure so a failed toggle change can't be replayed by an unrelated save - ConfirmDialog supports an optional checkbox; all four locales updated --- src-tauri/src/codex_history_migration.rs | 871 +++++++++++++++++- src-tauri/src/commands/settings.rs | 190 +++- src-tauri/src/lib.rs | 21 + src-tauri/src/settings.rs | 80 +- src/components/ConfirmDialog.tsx | 33 +- src/components/settings/CodexAuthSettings.tsx | 103 ++- src/components/settings/ProxyTabContent.tsx | 2 +- src/components/settings/SettingsPage.tsx | 19 +- src/i18n/locales/en.json | 18 +- src/i18n/locales/ja.json | 18 +- src/i18n/locales/zh-TW.json | 18 +- src/i18n/locales/zh.json | 18 +- src/lib/api/settings.ts | 17 + src/lib/schemas/settings.ts | 2 +- src/types.ts | 2 + 15 files changed, 1369 insertions(+), 43 deletions(-) diff --git a/src-tauri/src/codex_history_migration.rs b/src-tauri/src/codex_history_migration.rs index 30443eda6..240ce5e72 100644 --- a/src-tauri/src/codex_history_migration.rs +++ b/src-tauri/src/codex_history_migration.rs @@ -10,7 +10,8 @@ use crate::config::{atomic_write, copy_file, get_app_config_dir}; use crate::database::{is_official_seed_id, Database}; use crate::error::AppError; use crate::settings::{ - CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration, + CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration, + CodexThirdPartyHistoryProviderBucketMigration, }; use chrono::{Local, Utc}; use rusqlite::{backup::Backup, params_from_iter, Connection}; @@ -24,7 +25,25 @@ use std::time::{Duration, SystemTime}; use toml_edit::DocumentMut; const MIGRATION_NAME: &str = "codex-history-provider-migration-v1"; +const OFFICIAL_UNIFY_MIGRATION_NAME: &str = "codex-official-history-unify-v1"; +/// 还原操作自身的备份目录(与迁移备份分开,保持迁移账本目录纯净)。 +const OFFICIAL_UNIFY_RESTORE_BACKUP_NAME: &str = "codex-official-history-unify-restore-v1"; const CODEX_STATE_DB_FILENAME: &str = "state_5.sqlite"; +/// SQLite 变量上限保守值,IN 列表按此分块。 +const STATE_DB_ID_CHUNK: usize = 500; + +/// 串行化官方历史的迁移与还原:开启迁移(启动重试 + 设置保存后台任务)和 +/// 关闭还原可能在毫秒级先后被触发,对同一批 jsonl / state DB 双向改写。 +static CODEX_OFFICIAL_HISTORY_OP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn lock_codex_official_history_op() -> std::sync::MutexGuard<'static, ()> { + CODEX_OFFICIAL_HISTORY_OP_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} +/// Codex 内建默认 provider id:config.toml 没有 `model_provider` 键时会话归入此桶。 +/// 官方订阅(ChatGPT OAuth / OpenAI API key)的历史会话都记录这个 id。 +const OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID: &str = "openai"; const LEGACY_CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch"; // If a Codex preset ever used a temporary routing key, keep that old key here // so local history can be bucketed under the current custom provider id. @@ -120,7 +139,7 @@ pub fn maybe_migrate_codex_third_party_history_provider_bucket( }); } - let backup_root = migration_backup_root(); + let backup_root = migration_backup_root(MIGRATION_NAME); let codex_dir = get_codex_config_dir(); let migrated_jsonl_files = migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)?; @@ -157,7 +176,7 @@ pub fn maybe_migrate_codex_provider_template_bucket( }); } - let backup_root = migration_backup_root(); + let backup_root = migration_backup_root(MIGRATION_NAME); let outcome = migrate_codex_provider_templates_to_custom(db, &backup_root)?; crate::settings::mark_codex_provider_template_migrated(CodexProviderTemplateMigration { completed_at: Utc::now().to_rfc3339(), @@ -167,6 +186,475 @@ pub fn maybe_migrate_codex_provider_template_bucket( Ok(outcome) } +/// 统一会话开关的存量迁移:把官方会话(内建 "openai" 桶)迁入共享 "custom" 桶。 +/// +/// 仅当用户在开启弹窗里勾选了"迁入既有官方会话"(`unify_codex_migrate_existing`) +/// 且本轮未完成时执行;开关关闭时标记与勾选意愿都会被清除(见 `save_settings`), +/// 重新开启并再次勾选即可补迁关闭期间产生的官方会话。 +/// custom 桶里官方与第三方会话无法区分,自动逻辑绝不反向搬回; +/// 用户可在关闭开关时选择按备份账本精确还原(见 `restore_codex_official_history_from_backups`)。 +/// 迁移前 jsonl / state DB 均备份到 `~/.cc-switch/backups/codex-official-history-unify-v1/`。 +pub fn maybe_migrate_codex_official_history_to_unified_bucket( +) -> Result { + if !crate::settings::unify_codex_session_history() { + return Ok(CodexHistoryProviderBucketMigrationOutcome { + skipped_reason: Some("unify_toggle_off".to_string()), + ..Default::default() + }); + } + if !crate::settings::unify_codex_migrate_existing_requested() { + return Ok(CodexHistoryProviderBucketMigrationOutcome { + skipped_reason: Some("stock_migration_not_requested".to_string()), + ..Default::default() + }); + } + let _op_guard = lock_codex_official_history_op(); + let codex_dir = get_codex_config_dir(); + // marker 绑定迁移时的 Codex 目录:切换 codex_config_dir 后旧 marker 不再 + // 挡住新目录的迁移(迁移幂等,重跑无害)。 + let codex_dir_key = canonical_dir_string(&codex_dir); + if crate::settings::is_codex_official_history_unify_migrated_for_dir(&codex_dir_key) { + return Ok(CodexHistoryProviderBucketMigrationOutcome { + skipped_reason: Some("already_migrated".to_string()), + ..Default::default() + }); + } + // live 必须已实际路由到共享 custom 桶才允许迁移:官方配置的注入可能被拒 + // (已有显式 model_provider / 形态冲突的 custom 表,见 + // `inject_codex_unified_session_bucket`),代理接管期间的 live 也不带统一 + // 路由(注入只进备份)。这些状态下新会话仍落 "openai" 桶,迁移只会把 + // 历史搬进当前 live 看不见的桶里。开关与迁移意愿保持不动,待 live 真正 + // 统一后(下次切换 / 接管释放后的启动重试)再迁。 + if !codex_config_text_routes_custom(&read_codex_config_text().unwrap_or_default()) { + return Ok(CodexHistoryProviderBucketMigrationOutcome { + skipped_reason: Some("live_not_unified".to_string()), + ..Default::default() + }); + } + + let source_provider_ids: BTreeSet = + std::iter::once(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID.to_string()).collect(); + let backup_root = migration_backup_root(OFFICIAL_UNIFY_MIGRATION_NAME); + let migrated_jsonl_files = + migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)?; + let migrated_state_rows = + migrate_codex_state_dbs(&codex_dir, &source_provider_ids, &backup_root)?; + // 备份代际记录来源目录,restore 据此只取当前目录的账本。 + write_backup_generation_meta(&backup_root, &codex_dir_key)?; + + let outcome = CodexHistoryProviderBucketMigrationOutcome { + source_provider_ids: source_provider_ids.into_iter().collect(), + migrated_jsonl_files, + migrated_state_rows, + skipped_reason: None, + }; + + // 条件写入在 settings 写锁内原子完成:"迁移期间开关被关掉"时不写完成标记, + // 避免下一次开启被标记挡住而漏迁"关闭期间"新产生的 openai 桶会话。 + // 与关闭路径(update_settings + 清标记)共用同一把锁,无检查-写入窗口。 + let marker_written = crate::settings::mark_codex_official_history_unify_migrated_if_enabled( + CodexOfficialHistoryUnifyMigration { + completed_at: Utc::now().to_rfc3339(), + target_provider_id: CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string(), + migrated_jsonl_files, + migrated_state_rows, + codex_config_dir: Some(codex_dir_key), + }, + )?; + if !marker_written { + return Ok(CodexHistoryProviderBucketMigrationOutcome { + skipped_reason: Some("toggle_disabled_during_migration".to_string()), + ..outcome + }); + } + + Ok(outcome) +} + +/// live config.toml 是否路由到共享 custom 桶(会话分桶只看这个实态: +/// base_url / 接管与否都不影响 session_meta 记录的 model_provider)。 +fn codex_config_text_routes_custom(config_text: &str) -> bool { + config_text + .parse::() + .ok() + .and_then(|doc| { + doc.get("model_provider") + .and_then(|item| item.as_str()) + .map(|id| id.trim() == CC_SWITCH_CODEX_MODEL_PROVIDER_ID) + }) + .unwrap_or(false) +} + +/// 目录的规范化字符串形式,用作 marker / 备份代际的目录身份。 +/// canonicalize 失败(目录尚不存在等)时退回原始路径字符串。 +fn canonical_dir_string(dir: &Path) -> String { + fs::canonicalize(dir) + .unwrap_or_else(|_| dir.to_path_buf()) + .to_string_lossy() + .to_string() +} + +/// 在备份代际根目录写入 meta.json,记录这批备份来自哪个 Codex 目录。 +/// 代际目录不存在(本轮没有任何文件被迁移)时跳过。 +fn write_backup_generation_meta(backup_root: &Path, codex_dir_key: &str) -> Result<(), AppError> { + if !backup_root.exists() { + return Ok(()); + } + let payload = serde_json::json!({ "codexConfigDir": codex_dir_key }); + let bytes = + serde_json::to_vec_pretty(&payload).map_err(|e| AppError::JsonSerialize { source: e })?; + atomic_write(&backup_root.join("meta.json"), &bytes) +} + +#[derive(Debug, Clone, Default)] +pub struct CodexOfficialHistoryRestoreOutcome { + pub restored_jsonl_files: usize, + pub restored_state_rows: usize, + pub skipped_reason: Option, +} + +/// 统一会话开关迁移备份的父目录(其下每次迁移一个时间戳代际目录)。 +fn official_history_unify_backup_parent() -> PathBuf { + get_app_config_dir() + .join("backups") + .join(OFFICIAL_UNIFY_MIGRATION_NAME) +} + +/// 是否存在可用于还原的迁移备份(给前端决定要不要显示"恢复备份"勾选)。 +/// 与 restore 的账本收集共用同一目录匹配口径:只认属于当前 Codex 目录的 +/// 代际,避免切换 codex_config_dir 后弹出注定空跑的勾选。 +/// 精确账本内容仍在真正还原时才解析。 +pub fn has_codex_official_history_unify_backup() -> bool { + has_official_history_unify_backup_for_dir( + &official_history_unify_backup_parent(), + &canonical_dir_string(&get_codex_config_dir()), + ) +} + +fn has_official_history_unify_backup_for_dir(ledger_parent: &Path, codex_dir_key: &str) -> bool { + let Ok(entries) = fs::read_dir(ledger_parent) else { + return false; + }; + entries.flatten().any(|entry| { + let generation = entry.path(); + generation.is_dir() && backup_generation_matches_dir(&generation, codex_dir_key) + }) +} + +/// 关闭统一会话开关时的可选还原:按迁移备份账本,把当时迁入共享 custom 桶的 +/// 官方会话精确翻回 "openai" 桶。 +/// +/// 备份是唯一可信的归属证据:备份里 model_provider=="openai" 的会话必定源自 +/// 官方桶。开启期间新产生的会话不在任何备份里,**永不触碰**——它们可能来自 +/// 第三方,方向无法判定(产品决策:宁可留在第三方历史)。 +/// 扫描全部备份代际取并集,多次开关循环后仍能还原早期迁入的会话; +/// 还原前改动目标先备份到独立的 restore 目录(保持迁移账本目录纯净), +/// 且只改写当前仍为 custom 的目标,重复执行无害。 +pub fn restore_codex_official_history_from_backups( +) -> Result { + let _op_guard = lock_codex_official_history_op(); + // 开关已(重新)开启时拒绝还原:live 正路由 custom,把账本会话翻回 + // openai 桶等于亲手制造分裂。覆盖"关闭保存成功后用户立刻重新开启, + // 还原排在重开迁移之后才拿到 op lock"的时序。 + if crate::settings::unify_codex_session_history() { + return Ok(CodexOfficialHistoryRestoreOutcome { + skipped_reason: Some("unify_toggle_on".to_string()), + ..Default::default() + }); + } + let config_text = read_codex_config_text().unwrap_or_default(); + restore_codex_official_history_inner( + &get_codex_config_dir(), + &official_history_unify_backup_parent(), + &migration_backup_root(OFFICIAL_UNIFY_RESTORE_BACKUP_NAME), + &config_text, + ) +} + +fn restore_codex_official_history_inner( + codex_dir: &Path, + ledger_parent: &Path, + restore_backup_root: &Path, + config_text: &str, +) -> Result { + let codex_dir_key = canonical_dir_string(codex_dir); + let (official_session_ids, official_thread_ids) = + collect_official_ledger(ledger_parent, &codex_dir_key)?; + if official_session_ids.is_empty() && official_thread_ids.is_empty() { + return Ok(CodexOfficialHistoryRestoreOutcome { + skipped_reason: Some("no_backup_ledger".to_string()), + ..Default::default() + }); + } + + let mut files = Vec::new(); + collect_jsonl_files(&codex_dir.join("sessions"), &mut files, 0, 8); + collect_jsonl_files(&codex_dir.join("archived_sessions"), &mut files, 0, 4); + let mut restored_jsonl_files = 0; + for file_path in files { + if rewrite_codex_session_file_lines(&file_path, codex_dir, restore_backup_root, |line| { + rewrite_codex_session_meta_line_for_restore(line, &official_session_ids) + })? { + restored_jsonl_files += 1; + } + } + + let mut restored_state_rows = 0; + for db_path in codex_state_db_paths(codex_dir, config_text) { + restored_state_rows += restore_codex_state_db_official_threads( + &db_path, + codex_dir, + &official_thread_ids, + restore_backup_root, + )?; + } + + if restored_jsonl_files == 0 && restored_state_rows == 0 { + // 账本非空但没有任何"当前仍为 custom"的目标(如重复还原): + // 以 reason 告知前端,避免误报"已还原 0 项"为成功。 + return Ok(CodexOfficialHistoryRestoreOutcome { + skipped_reason: Some("nothing_to_restore".to_string()), + ..Default::default() + }); + } + + Ok(CodexOfficialHistoryRestoreOutcome { + restored_jsonl_files, + restored_state_rows, + skipped_reason: None, + }) +} + +/// 从备份代际收集官方会话账本:jsonl 备份里 session_meta 为 "openai" 的 +/// 会话 id + state DB 备份里 model_provider 为 "openai" 的 thread id。 +/// 只采纳 meta.json 目录与当前 Codex 目录一致的代际,避免切换 +/// codex_config_dir 后拿旧目录的账本作用到新目录。 +/// 还原操作自身的备份(restore 目录)天然不会混入:那些副本里的 id 都是 +/// custom,解析后贡献为空。 +fn collect_official_ledger( + ledger_parent: &Path, + codex_dir_key: &str, +) -> Result<(HashSet, BTreeSet), AppError> { + let mut session_ids = HashSet::new(); + let mut thread_ids = BTreeSet::new(); + let entries = match fs::read_dir(ledger_parent) { + Ok(entries) => entries, + Err(_) => return Ok((session_ids, thread_ids)), + }; + for entry in entries.flatten() { + let generation = entry.path(); + if !generation.is_dir() { + continue; + } + if !backup_generation_matches_dir(&generation, codex_dir_key) { + continue; + } + let mut backup_files = Vec::new(); + collect_jsonl_files(&generation.join("jsonl"), &mut backup_files, 0, 10); + for backup_file in backup_files { + collect_official_session_ids_from_backup(&backup_file, &mut session_ids); + } + let mut backup_dbs = Vec::new(); + collect_files_with_extension(&generation.join("state"), "sqlite", &mut backup_dbs, 0, 4); + for backup_db in backup_dbs { + collect_official_thread_ids_from_backup(&backup_db, &mut thread_ids); + } + } + Ok((session_ids, thread_ids)) +} + +/// 备份代际是否属于指定 Codex 目录。无 meta.json 或解析失败时宽容接受: +/// 早期版本的备份没有 meta,而那个时期不存在切目录场景;误纳的代价也被 +/// "按会话 id 精确匹配 + 仅改写 custom"双重条件兜底。 +fn backup_generation_matches_dir(generation: &Path, codex_dir_key: &str) -> bool { + let Ok(text) = fs::read_to_string(generation.join("meta.json")) else { + return true; + }; + serde_json::from_str::(&text) + .ok() + .and_then(|value| { + value + .get("codexConfigDir") + .and_then(Value::as_str) + .map(|dir| dir == codex_dir_key) + }) + .unwrap_or(true) +} + +fn collect_official_session_ids_from_backup(path: &Path, session_ids: &mut HashSet) { + let Ok(content) = fs::read_to_string(path) else { + log::debug!("Failed to read unify backup file {}", path.display()); + return; + }; + for line in content.lines() { + if !line.contains("\"session_meta\"") || !line.contains("\"model_provider\"") { + continue; + } + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + if value.get("type").and_then(Value::as_str) != Some("session_meta") { + continue; + } + let Some(payload) = value.get("payload") else { + continue; + }; + if payload.get("model_provider").and_then(Value::as_str) + != Some(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID) + { + continue; + } + if let Some(session_id) = payload.get("id").and_then(Value::as_str) { + session_ids.insert(session_id.to_string()); + } + } +} + +fn collect_official_thread_ids_from_backup(db_path: &Path, thread_ids: &mut BTreeSet) { + let conn = + match Connection::open_with_flags(db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) { + Ok(conn) => conn, + Err(err) => { + log::debug!( + "Failed to open unify backup state DB {}: {err}", + db_path.display() + ); + return; + } + }; + let has_threads = Database::table_exists(&conn, "threads").unwrap_or(false) + && Database::has_column(&conn, "threads", "model_provider").unwrap_or(false); + if !has_threads { + return; + } + let Ok(mut stmt) = conn.prepare("SELECT id FROM threads WHERE model_provider = ?1") else { + return; + }; + let Ok(rows) = stmt.query_map([OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID], |row| { + row.get::<_, String>(0) + }) else { + return; + }; + for thread_id in rows.flatten() { + thread_ids.insert(thread_id); + } +} + +fn collect_files_with_extension( + dir: &Path, + extension: &str, + files: &mut Vec, + depth: u8, + max_depth: u8, +) { + if depth > max_depth || !dir.is_dir() { + return; + } + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_with_extension(&path, extension, files, depth + 1, max_depth); + } else if path.extension().and_then(|ext| ext.to_str()) == Some(extension) { + files.push(path); + } + } +} + +fn rewrite_codex_session_meta_line_for_restore( + line: &str, + official_session_ids: &HashSet, +) -> Option { + if !line.contains("\"session_meta\"") || !line.contains("\"model_provider\"") { + return None; + } + let mut value: Value = serde_json::from_str(line).ok()?; + if value.get("type").and_then(Value::as_str) != Some("session_meta") { + return None; + } + let payload = value.get_mut("payload")?.as_object_mut()?; + if payload.get("model_provider")?.as_str()? != CC_SWITCH_CODEX_MODEL_PROVIDER_ID { + return None; + } + let session_id = payload.get("id")?.as_str()?; + if !official_session_ids.contains(session_id) { + return None; + } + payload.insert( + "model_provider".to_string(), + Value::String(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID.to_string()), + ); + serde_json::to_string(&value).ok() +} + +fn restore_codex_state_db_official_threads( + db_path: &Path, + codex_dir: &Path, + official_thread_ids: &BTreeSet, + backup_root: &Path, +) -> Result { + if !db_path.exists() || official_thread_ids.is_empty() { + return Ok(0); + } + + let mut conn = Connection::open(db_path) + .map_err(|e| AppError::Database(format!("打开 Codex state DB 失败: {e}")))?; + conn.busy_timeout(Duration::from_secs(5)) + .map_err(|e| AppError::Database(format!("设置 Codex state DB busy_timeout 失败: {e}")))?; + + if !Database::table_exists(&conn, "threads")? + || !Database::has_column(&conn, "threads", "model_provider")? + { + return Ok(0); + } + + let ids: Vec<&String> = official_thread_ids.iter().collect(); + let mut matching_rows: i64 = 0; + for chunk in ids.chunks(STATE_DB_ID_CHUNK) { + let placeholders = placeholders(chunk.len()); + let count_sql = format!( + "SELECT COUNT(*) FROM threads WHERE model_provider = ? AND id IN ({placeholders})" + ); + let mut values = Vec::with_capacity(chunk.len() + 1); + values.push(CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string()); + values.extend(chunk.iter().map(|id| (*id).clone())); + let count: i64 = conn + .query_row(&count_sql, params_from_iter(values.iter()), |row| { + row.get(0) + }) + .map_err(|e| AppError::Database(format!("统计 Codex state DB 待还原行失败: {e}")))?; + matching_rows += count; + } + if matching_rows == 0 { + return Ok(0); + } + + backup_codex_state_db(db_path, codex_dir, backup_root, &conn)?; + + let tx = conn + .transaction() + .map_err(|e| AppError::Database(format!("开启 Codex state DB 还原事务失败: {e}")))?; + let mut changed = 0; + for chunk in ids.chunks(STATE_DB_ID_CHUNK) { + let placeholders = placeholders(chunk.len()); + let update_sql = format!( + "UPDATE threads SET model_provider = ? WHERE model_provider = ? AND id IN ({placeholders})" + ); + let mut values = Vec::with_capacity(chunk.len() + 2); + values.push(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID.to_string()); + values.push(CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string()); + values.extend(chunk.iter().map(|id| (*id).clone())); + changed += tx + .execute(&update_sql, params_from_iter(values.iter())) + .map_err(|e| AppError::Database(format!("还原 Codex state DB provider 失败: {e}")))?; + } + tx.commit() + .map_err(|e| AppError::Database(format!("提交 Codex state DB 还原事务失败: {e}")))?; + Ok(changed) +} + fn migrate_codex_provider_templates_to_custom( db: &Database, backup_root: &Path, @@ -257,10 +745,10 @@ fn insert_known_cc_switch_legacy_source_id(ids: &mut BTreeSet, provider_ } } -fn migration_backup_root() -> PathBuf { +fn migration_backup_root(migration_name: &str) -> PathBuf { get_app_config_dir() .join("backups") - .join(MIGRATION_NAME) + .join(migration_name) .join(Local::now().format("%Y%m%d_%H%M%S").to_string()) } @@ -524,6 +1012,17 @@ fn rewrite_codex_session_file_for_provider_bucket( codex_dir: &Path, source_provider_ids: &HashSet, backup_root: &Path, +) -> Result { + rewrite_codex_session_file_lines(path, codex_dir, backup_root, |line| { + rewrite_codex_session_meta_line(line, source_provider_ids) + }) +} + +fn rewrite_codex_session_file_lines( + path: &Path, + codex_dir: &Path, + backup_root: &Path, + rewrite_line: impl Fn(&str) -> Option, ) -> Result { let metadata_before = fs::metadata(path).map_err(|e| AppError::io(path, e))?; let modified_before = metadata_before.modified().ok(); @@ -537,7 +1036,7 @@ fn rewrite_codex_session_file_for_provider_bucket( .strip_suffix('\n') .map(|line| (line, "\n")) .unwrap_or((segment, "")); - if let Some(next_line) = rewrite_codex_session_meta_line(line, source_provider_ids) { + if let Some(next_line) = rewrite_line(line) { rewritten.push_str(&next_line); changed = true; } else { @@ -820,6 +1319,39 @@ mod tests { values.iter().map(|value| value.to_string()).collect() } + #[test] + fn detects_custom_routed_codex_config_for_unify_gate() { + // 注入产物(官方 + 统一开关) + assert!(codex_config_text_routes_custom( + r#"model_provider = "custom" + +[model_providers.custom] +name = "OpenAI" +requires_openai_auth = true +supports_websockets = true +wire_api = "responses" +"# + )); + // 第三方供应商的常规 custom 路由(带 base_url)同样算已统一 + assert!(codex_config_text_routes_custom( + r#"model_provider = "custom" + +[model_providers.custom] +name = "AIHubMix" +base_url = "https://aihubmix.example/v1" +"# + )); + // 注入被拒的形态:显式 openai 路由 / 无 model_provider(接管期间、空配置) + assert!(!codex_config_text_routes_custom( + "model_provider = \"openai\"\n" + )); + assert!(!codex_config_text_routes_custom( + "base_url = \"http://127.0.0.1:15721/codex\"\n" + )); + assert!(!codex_config_text_routes_custom("")); + assert!(!codex_config_text_routes_custom("not toml [")); + } + fn migrate_provider_templates_for_test( db: &Database, ) -> ( @@ -1092,6 +1624,333 @@ base_url = "https://proxy.example/v1" ); } + #[test] + fn simulates_official_history_unify_migration_end_to_end() { + let dir = tempdir().expect("tempdir"); + let codex_dir = dir.path().join(".codex"); + let backup_root = dir.path().join("backup"); + fs::create_dir_all(&codex_dir).expect("create codex dir"); + + let source_provider_ids = source_ids(&[OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID]); + + let session_dir = codex_dir.join("sessions/2026/06/12"); + fs::create_dir_all(&session_dir).expect("create session dir"); + let session_path = session_dir.join("official-sim.jsonl"); + fs::write( + &session_path, + concat!( + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"openai\"}}\n", + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s2\",\"model_provider\":\"custom\"}}\n", + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s3\",\"model_provider\":\"my-private-relay\"}}\n", + "{\"type\":\"response_item\",\"payload\":{\"text\":\"openai\"}}\n", + ), + ) + .expect("write session"); + + let migrated_jsonl = + migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root) + .expect("migrate jsonl"); + assert_eq!(migrated_jsonl, 1); + let session_text = fs::read_to_string(&session_path).expect("read session"); + assert_eq!( + session_text + .matches("\"model_provider\":\"custom\"") + .count(), + 2 + ); + assert!(!session_text.contains("\"model_provider\":\"openai\"")); + assert!(session_text.contains("\"model_provider\":\"my-private-relay\"")); + assert!( + session_text.contains("{\"type\":\"response_item\",\"payload\":{\"text\":\"openai\"}}") + ); + assert!(backup_root + .join("jsonl/sessions/2026/06/12/official-sim.jsonl") + .exists()); + + // 第二次执行应当无事可做(幂等) + let rerun = migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root) + .expect("rerun migrate jsonl"); + assert_eq!(rerun, 0); + + let state_db_path = codex_dir.join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&state_db_path).expect("open state db"); + conn.execute_batch( + "CREATE TABLE threads ( + id TEXT PRIMARY KEY, + model_provider TEXT NOT NULL + ); + INSERT INTO threads (id, model_provider) VALUES + ('openai-thread', 'openai'), + ('custom-thread', 'custom'), + ('manual-thread', 'my-private-relay');", + ) + .expect("seed state db"); + drop(conn); + + let migrated_state_rows = migrate_codex_state_db_provider_bucket( + &state_db_path, + &codex_dir, + &source_provider_ids, + &backup_root, + ) + .expect("migrate state db"); + assert_eq!(migrated_state_rows, 1); + + let conn = Connection::open(&state_db_path).expect("reopen state db"); + let count_provider = |provider_id: &str| -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM threads WHERE model_provider = ?1", + [provider_id], + |row| row.get(0), + ) + .expect("count provider") + }; + assert_eq!(count_provider("custom"), 2); + assert_eq!(count_provider("openai"), 0); + assert_eq!(count_provider("my-private-relay"), 1); + } + + #[test] + fn restores_only_ledgered_official_sessions_from_backups() { + let dir = tempdir().expect("tempdir"); + let codex_dir = dir.path().join(".codex"); + let ledger_parent = dir.path().join("ledger"); + let restore_backup_root = dir.path().join("restore-backup"); + + // 备份账本:一个代际,jsonl 备份里 s1 是 openai;state 备份里 t1 是 openai + let generation = ledger_parent.join("20260612_010101"); + let backup_session_dir = generation.join("jsonl/sessions/2026/06/01"); + fs::create_dir_all(&backup_session_dir).expect("create backup session dir"); + fs::write( + backup_session_dir.join("official.jsonl"), + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"openai\"}}\n", + ) + .expect("write backup session"); + let backup_state_dir = generation.join("state"); + fs::create_dir_all(&backup_state_dir).expect("create backup state dir"); + let backup_db = Connection::open(backup_state_dir.join(CODEX_STATE_DB_FILENAME)) + .expect("open backup db"); + backup_db + .execute_batch( + "CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL); + INSERT INTO threads (id, model_provider) VALUES ('t1', 'openai');", + ) + .expect("seed backup db"); + drop(backup_db); + + // 当前数据:s1(账本内,custom)应还原;s2(开启期间新会话,不在账本) + // 与 s3(手工 relay)必须原样保留 + let session_dir = codex_dir.join("sessions/2026/06/01"); + fs::create_dir_all(&session_dir).expect("create session dir"); + let official_path = session_dir.join("official.jsonl"); + fs::write( + &official_path, + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"custom\"}}\n", + ) + .expect("write official session"); + let on_period_dir = codex_dir.join("sessions/2026/06/12"); + fs::create_dir_all(&on_period_dir).expect("create on-period dir"); + let on_period_path = on_period_dir.join("on-period.jsonl"); + fs::write( + &on_period_path, + concat!( + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s2\",\"model_provider\":\"custom\"}}\n", + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s3\",\"model_provider\":\"my-private-relay\"}}\n", + ), + ) + .expect("write on-period session"); + + let state_db_path = codex_dir.join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&state_db_path).expect("open state db"); + conn.execute_batch( + "CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL); + INSERT INTO threads (id, model_provider) VALUES + ('t1', 'custom'), + ('t2', 'custom'), + ('t3', 'openai');", + ) + .expect("seed state db"); + drop(conn); + + // 代际 meta 指向当前 Codex 目录:精确匹配分支生效(而非无 meta 的宽容分支) + fs::write( + generation.join("meta.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "codexConfigDir": canonical_dir_string(&codex_dir) + })) + .expect("serialize meta"), + ) + .expect("write meta"); + + let outcome = restore_codex_official_history_inner( + &codex_dir, + &ledger_parent, + &restore_backup_root, + "", + ) + .expect("restore"); + assert_eq!(outcome.restored_jsonl_files, 1); + assert_eq!(outcome.restored_state_rows, 1); + assert!(outcome.skipped_reason.is_none()); + + let official_text = fs::read_to_string(&official_path).expect("read official"); + assert!(official_text.contains("\"model_provider\":\"openai\"")); + let on_period_text = fs::read_to_string(&on_period_path).expect("read on-period"); + assert!(on_period_text.contains("\"id\":\"s2\",\"model_provider\":\"custom\"")); + assert!(on_period_text.contains("\"model_provider\":\"my-private-relay\"")); + + let conn = Connection::open(&state_db_path).expect("reopen state db"); + let provider_of = |thread_id: &str| -> String { + conn.query_row( + "SELECT model_provider FROM threads WHERE id = ?1", + [thread_id], + |row| row.get(0), + ) + .expect("thread provider") + }; + assert_eq!(provider_of("t1"), "openai"); + assert_eq!(provider_of("t2"), "custom"); + assert_eq!(provider_of("t3"), "openai"); + drop(conn); + + // 还原前的现场已备份到独立目录 + assert!(restore_backup_root + .join("jsonl/sessions/2026/06/01/official.jsonl") + .exists()); + assert!(restore_backup_root + .join("state") + .join(CODEX_STATE_DB_FILENAME) + .exists()); + + // 幂等:第二次还原无事可做 + let rerun = restore_codex_official_history_inner( + &codex_dir, + &ledger_parent, + &dir.path().join("restore-backup-2"), + "", + ) + .expect("rerun restore"); + assert_eq!(rerun.restored_jsonl_files, 0); + assert_eq!(rerun.restored_state_rows, 0); + assert_eq!(rerun.skipped_reason.as_deref(), Some("nothing_to_restore")); + } + + #[test] + fn restore_ignores_backup_generations_from_other_codex_dirs() { + let dir = tempdir().expect("tempdir"); + let codex_dir = dir.path().join(".codex"); + let ledger_parent = dir.path().join("ledger"); + + // 账本代际属于另一个 Codex 目录 + let generation = ledger_parent.join("20260612_010101"); + let backup_session_dir = generation.join("jsonl/sessions/2026/06/01"); + fs::create_dir_all(&backup_session_dir).expect("create backup session dir"); + fs::write( + backup_session_dir.join("official.jsonl"), + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"openai\"}}\n", + ) + .expect("write backup session"); + fs::write( + generation.join("meta.json"), + "{\n \"codexConfigDir\": \"/some/other/codex-dir\"\n}", + ) + .expect("write meta"); + + let session_dir = codex_dir.join("sessions/2026/06/01"); + fs::create_dir_all(&session_dir).expect("create session dir"); + let session_path = session_dir.join("official.jsonl"); + fs::write( + &session_path, + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"custom\"}}\n", + ) + .expect("write session"); + + let outcome = restore_codex_official_history_inner( + &codex_dir, + &ledger_parent, + &dir.path().join("restore-backup"), + "", + ) + .expect("restore"); + assert_eq!(outcome.skipped_reason.as_deref(), Some("no_backup_ledger")); + let text = fs::read_to_string(&session_path).expect("read session"); + assert!(text.contains("\"model_provider\":\"custom\"")); + } + + #[test] + fn backup_probe_only_counts_generations_for_current_dir() { + let dir = tempdir().expect("tempdir"); + let ledger_parent = dir.path().join("ledger"); + let codex_dir_key = "/current/codex-dir"; + + // 空父目录 / 父目录不存在:无备份 + assert!(!has_official_history_unify_backup_for_dir( + &ledger_parent, + codex_dir_key + )); + + // 只有其他目录的代际:不算有备份 + let other = ledger_parent.join("20260612_010101"); + fs::create_dir_all(&other).expect("create generation"); + fs::write( + other.join("meta.json"), + "{\n \"codexConfigDir\": \"/some/other/codex-dir\"\n}", + ) + .expect("write meta"); + assert!(!has_official_history_unify_backup_for_dir( + &ledger_parent, + codex_dir_key + )); + + // 无 meta 的早期代际:宽容接受(与 restore 的账本口径一致) + fs::create_dir_all(ledger_parent.join("20260612_020202")).expect("create legacy gen"); + assert!(has_official_history_unify_backup_for_dir( + &ledger_parent, + codex_dir_key + )); + + // 精确匹配当前目录的代际 + fs::remove_dir_all(ledger_parent.join("20260612_020202")).expect("remove legacy gen"); + let matched = ledger_parent.join("20260612_030303"); + fs::create_dir_all(&matched).expect("create matched gen"); + fs::write( + matched.join("meta.json"), + format!("{{\n \"codexConfigDir\": \"{codex_dir_key}\"\n}}"), + ) + .expect("write matched meta"); + assert!(has_official_history_unify_backup_for_dir( + &ledger_parent, + codex_dir_key + )); + } + + #[test] + fn restore_skips_when_no_backup_ledger_exists() { + let dir = tempdir().expect("tempdir"); + let codex_dir = dir.path().join(".codex"); + let session_dir = codex_dir.join("sessions/2026/06/01"); + fs::create_dir_all(&session_dir).expect("create session dir"); + fs::write( + session_dir.join("session.jsonl"), + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"custom\"}}\n", + ) + .expect("write session"); + + let outcome = restore_codex_official_history_inner( + &codex_dir, + &dir.path().join("missing-ledger"), + &dir.path().join("restore-backup"), + "", + ) + .expect("restore"); + assert_eq!(outcome.skipped_reason.as_deref(), Some("no_backup_ledger")); + assert_eq!(outcome.restored_jsonl_files, 0); + assert_eq!(outcome.restored_state_rows, 0); + + let text = fs::read_to_string(session_dir.join("session.jsonl")).expect("read session"); + assert!(text.contains("\"model_provider\":\"custom\"")); + } + #[test] fn rewrites_only_codex_session_meta_provider_ids() { let dir = tempdir().expect("tempdir"); diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index d49828170..8cceccb4f 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -36,24 +36,11 @@ fn merge_settings_for_save( } _ => {} } - if incoming.local_migrations.is_none() { - incoming.local_migrations = existing.local_migrations.clone(); - } else if let (Some(incoming_migrations), Some(existing_migrations)) = - (&mut incoming.local_migrations, &existing.local_migrations) - { - if incoming_migrations - .codex_third_party_history_provider_bucket_v1 - .is_none() - { - incoming_migrations.codex_third_party_history_provider_bucket_v1 = existing_migrations - .codex_third_party_history_provider_bucket_v1 - .clone(); - } - if incoming_migrations.codex_provider_template_v1.is_none() { - incoming_migrations.codex_provider_template_v1 = - existing_migrations.codex_provider_template_v1.clone(); - } - } + // local_migrations 是纯后端状态(迁移完成标记),前端没有合法的修改场景, + // 无条件取现有值。若按 incoming 透传:后端清掉 marker(如关闭统一会话 + // 开关)后、前端 query 缓存刷新前的一次全量保存会把旧 marker 重放回来, + // 重新开启时被"复活"的标记挡住而漏迁。 + incoming.local_migrations = existing.local_migrations.clone(); incoming } @@ -73,20 +60,109 @@ pub async fn save_settings( let merged = merge_settings_for_save(settings, &existing); let unify_codex_changed = merged.unify_codex_session_history != existing.unify_codex_session_history; + let unify_codex_enabled = merged.unify_codex_session_history; crate::settings::update_settings(merged).map_err(|e| e.to_string())?; // 统一会话开关变更时立即重写当前官方 Codex 供应商的 live 配置, // 不必等下一次切换才生效。 if unify_codex_changed { + // live 重写失败时回滚设置并把保存整体报失败:若设置保持已切换状态, + // live 仍跑旧桶,后续的历史迁移/还原会让会话再次分裂(开启=历史 + // 迁走而新会话仍写 openai 桶;关闭=会话还原而 live 仍写 custom)。 + // 报错让前端 saved=false 短路还原;回滚是整次保存的事务语义 + // (本开关的保存只携带开关相关字段)。 if let Err(err) = crate::services::provider::reapply_current_codex_official_live(state.inner()) { - log::warn!("统一 Codex 会话历史开关变更后重写 live 配置失败: {err}"); + log::warn!("统一 Codex 会话历史开关变更后重写 live 配置失败,回滚设置: {err}"); + if let Err(rollback_err) = crate::settings::update_settings(existing) { + log::error!("回滚统一会话开关设置失败: {rollback_err}"); + } + return Err(format!( + "统一 Codex 会话历史开关未生效(live 配置重写失败): {err}" + )); + } + + if unify_codex_enabled { + // 后台执行存量迁移(openai 桶 → custom 桶;仅当用户勾选了迁入既有 + // 会话,函数内部自门控)。大会话目录可能要读数秒,不能阻塞设置保存; + // 失败时不写完成标记,下次启动自动重试。 + tauri::async_runtime::spawn_blocking(|| { + match crate::codex_history_migration::maybe_migrate_codex_official_history_to_unified_bucket() { + Ok(outcome) => { + if let Some(reason) = outcome.skipped_reason { + log::debug!("○ Codex official history unify migration skipped: {reason}"); + } else { + log::info!( + "✓ Codex official history unify migration completed: jsonl_files={}, state_rows={}", + outcome.migrated_jsonl_files, + outcome.migrated_state_rows + ); + } + } + Err(e) => { + log::warn!("✗ Codex official history unify migration failed: {e}"); + } + } + }); + } else { + // 清除标记与迁移意愿,让重新开启并再次勾选时能补迁 + // 关闭期间落入 openai 桶的官方会话。 + if let Err(err) = crate::settings::clear_codex_official_history_unify_migration() { + log::warn!("清除统一会话迁移标记失败: {err}"); + } + if let Err(err) = crate::settings::clear_codex_unify_migrate_existing() { + log::warn!("清除统一会话迁移意愿失败: {err}"); + } } } Ok(true) } +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexUnifyHistoryRestoreResult { + pub restored_jsonl_files: usize, + pub restored_state_rows: usize, + /// 还原被跳过的原因(如当前目录没有账本),前端据此提示而非报"成功 0 项"。 + #[serde(skip_serializing_if = "Option::is_none")] + pub skipped_reason: Option, +} + +/// 是否存在统一会话开关的迁移备份(决定关闭弹窗里是否显示"恢复备份"勾选)。 +#[tauri::command] +pub async fn has_codex_unify_history_backup() -> Result { + Ok(crate::codex_history_migration::has_codex_official_history_unify_backup()) +} + +/// 按迁移备份账本把当时迁入共享桶的官方会话还原回 "openai" 桶。 +/// 由关闭统一会话开关的确认弹窗触发;幂等,可安全重试。 +#[tauri::command] +pub async fn restore_codex_unified_history() -> Result { + let outcome = tauri::async_runtime::spawn_blocking(|| { + crate::codex_history_migration::restore_codex_official_history_from_backups() + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + + if let Some(reason) = &outcome.skipped_reason { + log::debug!("○ Codex official history restore skipped: {reason}"); + } else { + log::info!( + "✓ Codex official history restored from backups: jsonl_files={}, state_rows={}", + outcome.restored_jsonl_files, + outcome.restored_state_rows + ); + } + + Ok(CodexUnifyHistoryRestoreResult { + restored_jsonl_files: outcome.restored_jsonl_files, + restored_state_rows: outcome.restored_state_rows, + skipped_reason: outcome.skipped_reason, + }) +} + /// 重启应用程序(当 app_config_dir 变更后使用) #[tauri::command] pub async fn restart_app(app: AppHandle) -> Result { @@ -201,8 +277,9 @@ pub async fn set_auto_launch(enabled: bool) -> Result { mod tests { use super::merge_settings_for_save; use crate::settings::{ - AppSettings, CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration, - LocalMigrations, S3SyncSettings, WebDavSyncSettings, + AppSettings, CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration, + CodexThirdPartyHistoryProviderBucketMigration, LocalMigrations, S3SyncSettings, + WebDavSyncSettings, }; #[test] @@ -401,6 +478,13 @@ mod tests { completed_at: "2026-05-20T00:01:00Z".to_string(), migrated_provider_ids: vec!["legacy".to_string()], }), + codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration { + completed_at: "2026-06-12T00:00:00Z".to_string(), + target_provider_id: "custom".to_string(), + migrated_jsonl_files: 5, + migrated_state_rows: 7, + codex_config_dir: None, + }), }), ..AppSettings::default() }; @@ -430,6 +514,70 @@ mod tests { template_migration.migrated_provider_ids, vec!["legacy".to_string()] ); + + let unify_migration = merged + .local_migrations + .as_ref() + .and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref()) + .expect("official unify migration marker should be preserved"); + assert_eq!(unify_migration.migrated_jsonl_files, 5); + assert_eq!(unify_migration.migrated_state_rows, 7); + } + + /// incoming 带有 local_migrations(哪怕是空的)也不能覆盖后端维护的标记。 + #[test] + fn save_settings_should_keep_backend_migration_markers_over_incoming() { + let existing = AppSettings { + local_migrations: Some(LocalMigrations { + codex_third_party_history_provider_bucket_v1: None, + codex_provider_template_v1: None, + codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration { + completed_at: "2026-06-12T00:00:00Z".to_string(), + target_provider_id: "custom".to_string(), + migrated_jsonl_files: 1, + migrated_state_rows: 2, + codex_config_dir: None, + }), + }), + ..AppSettings::default() + }; + + let incoming = AppSettings { + local_migrations: Some(LocalMigrations::default()), + ..AppSettings::default() + }; + let merged = merge_settings_for_save(incoming, &existing); + + assert!(merged + .local_migrations + .as_ref() + .and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref()) + .is_some()); + } + + /// 后端清掉 marker 后(如关闭统一会话开关)、前端缓存刷新前的全量保存 + /// 会携带旧 marker;merge 必须忽略它,否则被"复活"的标记会让重新开启 + /// 时误判已迁移而漏迁。 + #[test] + fn save_settings_should_ignore_stale_incoming_migration_markers() { + let existing = AppSettings::default(); + + let incoming = AppSettings { + local_migrations: Some(LocalMigrations { + codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration { + completed_at: "2026-06-12T00:00:00Z".to_string(), + target_provider_id: "custom".to_string(), + migrated_jsonl_files: 1, + migrated_state_rows: 2, + codex_config_dir: None, + }), + ..LocalMigrations::default() + }), + ..AppSettings::default() + }; + let merged = merge_settings_for_save(incoming, &existing); + + assert!(merged.local_migrations.is_none()); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eeebed147..863bb21a0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -600,6 +600,25 @@ pub fn run() { log::warn!("✗ Codex provider template bucket migration failed: {e}"); } } + + // 统一会话开关的官方历史迁移:开关开启但上次未完成(如文件被占用 + // 中途失败)时在启动期重试;函数内部自门控,开关关闭时直接跳过。 + match crate::codex_history_migration::maybe_migrate_codex_official_history_to_unified_bucket() { + Ok(outcome) => { + if let Some(reason) = outcome.skipped_reason { + log::debug!("○ Codex official history unify migration skipped: {reason}"); + } else { + log::info!( + "✓ Codex official history unify migration completed: jsonl_files={}, state_rows={}", + outcome.migrated_jsonl_files, + outcome.migrated_state_rows + ); + } + } + Err(e) => { + log::warn!("✗ Codex official history unify migration failed: {e}"); + } + } }); } @@ -1156,6 +1175,8 @@ pub fn run() { commands::read_live_provider_settings, commands::get_settings, commands::save_settings, + commands::has_codex_unify_history_backup, + commands::restore_codex_unified_history, commands::get_rectifier_config, commands::set_rectifier_config, commands::get_optimizer_config, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index cc11aa59a..ffa00909f 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -287,6 +287,10 @@ pub struct LocalMigrations { Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub codex_provider_template_v1: Option, + /// 统一会话开关的官方历史迁移标记。开关关闭时会被清除, + /// 这样重新开启能把"关闭期间"落入 openai 桶的官方会话补迁进来。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex_official_history_unify_v1: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -312,6 +316,21 @@ pub struct CodexProviderTemplateMigration { pub migrated_provider_ids: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexOfficialHistoryUnifyMigration { + pub completed_at: String, + pub target_provider_id: String, + #[serde(default)] + pub migrated_jsonl_files: usize, + #[serde(default)] + pub migrated_state_rows: usize, + /// 迁移时的规范化 Codex 目录。标记只对同一目录生效: + /// 切换 codex_config_dir 后旧标记不会挡住新目录的迁移。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex_config_dir: Option, +} + /// 应用设置结构 /// /// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。 @@ -358,11 +377,15 @@ pub struct AppSettings { #[serde(default)] pub preserve_codex_official_auth_on_switch: bool, /// Run official Codex providers under the shared "custom" model_provider id - /// so future official sessions share one resume-history bucket with - /// third-party providers. Forward-only: existing sessions are not migrated. - /// Opt-in: defaults to false. + /// so official sessions share one resume-history bucket with third-party + /// providers. Opt-in: defaults to false. #[serde(default)] pub unify_codex_session_history: bool, + /// User opted in (via the enable dialog checkbox) to migrate existing + /// official sessions ("openai" bucket) into the shared bucket. Persisted so + /// a failed migration retries at startup; cleared when the toggle turns off. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unify_codex_migrate_existing: Option, /// User has confirmed the failover toggle first-run notice #[serde(default, skip_serializing_if = "Option::is_none")] pub failover_confirmed: Option, @@ -482,6 +505,7 @@ impl Default for AppSettings { enable_failover_toggle: false, preserve_codex_official_auth_on_switch: false, unify_codex_session_history: false, + unify_codex_migrate_existing: None, failover_confirmed: None, first_run_notice_confirmed: None, common_config_confirmed: None, @@ -766,6 +790,56 @@ pub fn mark_codex_provider_template_migrated( }) } +/// 统一会话迁移标记是否覆盖指定目录。标记里没记目录(不应出现的旧格式) +/// 视为不匹配——重跑迁移是幂等的,宁可重迁也不漏迁。 +pub fn is_codex_official_history_unify_migrated_for_dir(codex_dir: &str) -> bool { + get_settings() + .local_migrations + .as_ref() + .and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref()) + .is_some_and(|migration| migration.codex_config_dir.as_deref() == Some(codex_dir)) +} + +/// 条件写入迁移完成标记:仅当此刻开关仍开启且迁移意愿仍在时才写。 +/// 检查与写入在 settings 写锁内原子完成,与关闭开关路径 +/// (`update_settings` / 清标记)串行,消除"迁移线程复查开关后、写标记前 +/// 用户恰好关闭开关"的竞态窗口。返回是否实际写入。 +pub fn mark_codex_official_history_unify_migrated_if_enabled( + migration: CodexOfficialHistoryUnifyMigration, +) -> Result { + let mut written = false; + mutate_settings(|settings| { + if settings.unify_codex_session_history + && settings.unify_codex_migrate_existing.unwrap_or(false) + { + settings + .local_migrations + .get_or_insert_with(Default::default) + .codex_official_history_unify_v1 = Some(migration); + written = true; + } + })?; + Ok(written) +} + +pub fn clear_codex_official_history_unify_migration() -> Result<(), AppError> { + mutate_settings(|settings| { + if let Some(migrations) = settings.local_migrations.as_mut() { + migrations.codex_official_history_unify_v1 = None; + } + }) +} + +pub fn unify_codex_migrate_existing_requested() -> bool { + get_settings().unify_codex_migrate_existing.unwrap_or(false) +} + +pub fn clear_codex_unify_migrate_existing() -> Result<(), AppError> { + mutate_settings(|settings| { + settings.unify_codex_migrate_existing = None; + }) +} + /// 从文件重新加载设置到内存缓存 /// 用于导入配置等场景,确保内存缓存与文件同步 pub fn reload_settings() -> Result<(), AppError> { diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index c2eb952b3..788056d9a 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { Dialog, DialogContent, @@ -7,6 +8,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { AlertTriangle, Info } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -18,7 +20,10 @@ interface ConfirmDialogProps { cancelText?: string; variant?: "destructive" | "info"; zIndex?: "base" | "nested" | "alert" | "top"; - onConfirm: () => void; + /** 可选勾选项:提供 label 即显示,勾选状态经 onConfirm 参数回传 */ + checkboxLabel?: string; + checkboxDefaultChecked?: boolean; + onConfirm: (checkboxChecked: boolean) => void; onCancel: () => void; } @@ -30,10 +35,21 @@ export function ConfirmDialog({ cancelText, variant = "destructive", zIndex = "alert", + checkboxLabel, + checkboxDefaultChecked = false, onConfirm, onCancel, }: ConfirmDialogProps) { const { t } = useTranslation(); + const [checkboxChecked, setCheckboxChecked] = useState( + checkboxDefaultChecked, + ); + + useEffect(() => { + if (isOpen) { + setCheckboxChecked(checkboxDefaultChecked); + } + }, [isOpen, checkboxDefaultChecked]); const IconComponent = variant === "info" ? Info : AlertTriangle; const iconClass = @@ -58,13 +74,26 @@ export function ConfirmDialog({ {message} + {checkboxLabel ? ( + + ) : null} diff --git a/src/components/settings/CodexAuthSettings.tsx b/src/components/settings/CodexAuthSettings.tsx index 5b2ad3826..f58b373d5 100644 --- a/src/components/settings/CodexAuthSettings.tsx +++ b/src/components/settings/CodexAuthSettings.tsx @@ -1,11 +1,18 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { History, KeyRound } from "lucide-react"; +import { toast } from "sonner"; import type { SettingsFormState } from "@/hooks/useSettings"; import { ToggleRow } from "@/components/ui/toggle-row"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { settingsApi } from "@/lib/api"; interface CodexAuthSettingsProps { settings: SettingsFormState; - onChange: (updates: Partial) => void; + /** 返回 false(或 resolve 为 false)表示保存失败;其余返回值视为成功 */ + onChange: ( + updates: Partial, + ) => void | boolean | Promise; } export function CodexAuthSettings({ @@ -13,6 +20,73 @@ export function CodexAuthSettings({ onChange, }: CodexAuthSettingsProps) { const { t } = useTranslation(); + const [showEnableConfirm, setShowEnableConfirm] = useState(false); + const [showDisableConfirm, setShowDisableConfirm] = useState(false); + const [hasUnifyBackup, setHasUnifyBackup] = useState(false); + + const handleUnifyHistoryChange = (checked: boolean) => { + if (checked) { + setShowEnableConfirm(true); + return; + } + // 先探测有无迁移备份,决定关闭弹窗是否提供"恢复备份"勾选 + void settingsApi + .hasCodexUnifyHistoryBackup() + .catch(() => false) + .then((hasBackup) => { + setHasUnifyBackup(hasBackup); + setShowDisableConfirm(true); + }); + }; + + const handleEnableConfirm = (migrateExisting: boolean) => { + setShowEnableConfirm(false); + void onChange({ + unifyCodexSessionHistory: true, + unifyCodexMigrateExisting: migrateExisting, + }); + }; + + // 备份探测可能落后于正在后台进行的迁移(刚勾选迁入就立刻关闭时, + // 备份尚未产出)。只要本轮勾选过"迁入既有会话",就必须提供恢复入口; + // 真正有没有账本交给后端 restore 的 skippedReason 判定。 + const showRestoreOption = + hasUnifyBackup || (settings.unifyCodexMigrateExisting ?? false); + + const handleDisableConfirm = async (restoreBackup: boolean) => { + setShowDisableConfirm(false); + const saved = await onChange({ + unifyCodexSessionHistory: false, + unifyCodexMigrateExisting: false, + }); + // 关闭保存失败时绝不还原:否则开关仍开着(live 仍统一路由), + // 已迁移会话却被翻回 openai 桶,历史被拆成两半。 + if (saved === false) return; + // 不再以探测结果短路:还原命令会在迁移锁上排队,等到迁移落盘后 + // 拿到完整账本;确实无账本时由 skippedReason 提示。 + if (!restoreBackup) return; + try { + const result = await settingsApi.restoreCodexUnifiedHistory(); + if (result.skippedReason) { + // unify_toggle_on:还原排队期间开关被重新开启,后端拒绝还原 + toast.info( + result.skippedReason === "unify_toggle_on" + ? t("settings.unifyCodexHistoryRestoreSkippedToggleOn") + : t("settings.unifyCodexHistoryRestoreNothing"), + ); + return; + } + toast.success( + t("settings.unifyCodexHistoryRestoreCompleted", { + files: result.restoredJsonlFiles, + rows: result.restoredStateRows, + }), + ); + } catch (error) { + console.error("Failed to restore codex unified history:", error); + toast.error(t("settings.unifyCodexHistoryRestoreFailed")); + } + }; return (
    @@ -36,9 +110,32 @@ export function CodexAuthSettings({ title={t("settings.unifyCodexSessionHistory")} description={t("settings.unifyCodexSessionHistoryDescription")} checked={settings.unifyCodexSessionHistory ?? false} - onCheckedChange={(value) => - onChange({ unifyCodexSessionHistory: value }) + onCheckedChange={handleUnifyHistoryChange} + /> + + setShowEnableConfirm(false)} + /> + + void handleDisableConfirm(restoreBackup)} + onCancel={() => setShowDisableConfirm(false)} />
    ); diff --git a/src/components/settings/ProxyTabContent.tsx b/src/components/settings/ProxyTabContent.tsx index a89a7a535..cfd332782 100644 --- a/src/components/settings/ProxyTabContent.tsx +++ b/src/components/settings/ProxyTabContent.tsx @@ -22,7 +22,7 @@ import type { SettingsFormState } from "@/hooks/useSettings"; interface ProxyTabContentProps { settings: SettingsFormState; - onAutoSave: (updates: Partial) => Promise; + onAutoSave: (updates: Partial) => Promise; } export function ProxyTabContent({ diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 22441aed5..748c926ad 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -163,19 +163,34 @@ export function SettingsPage({ // 通用设置即时保存(无需手动点击) // 使用 autoSaveSettings 避免误触发系统 API(开机自启、Claude 插件等) + // 返回保存是否成功:需要在保存成功后追加动作的调用方(如统一会话历史 + // 关闭后的备份还原)据此短路,其余调用方可忽略返回值。 const handleAutoSave = useCallback( - async (updates: Partial) => { - if (!settings) return; + async (updates: Partial): Promise => { + if (!settings) return false; + // 乐观更新前捕获旧值:autoSaveSettings 发送的是全量表单状态,后端按 + // diff 触发副作用(如统一会话开关的 live 重写与历史迁移)。保存失败 + // 不回滚的话,失败的变更会滞留在表单里,被之后任意一次无关保存原样 + // 重放,绕过确认弹窗。 + const previousValues = Object.fromEntries( + Object.keys(updates).map((key) => [ + key, + settings[key as keyof SettingsFormState], + ]), + ) as Partial; updateSettings(updates); try { await autoSaveSettings(updates); + return true; } catch (error) { console.error("[SettingsPage] Failed to autosave settings", error); + updateSettings(previousValues); toast.error( t("settings.saveFailedGeneric", { defaultValue: "保存失败,请重试", }), ); + return false; } }, [autoSaveSettings, settings, t, updateSettings], diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 77dd67da9..9afe4c699 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -279,6 +279,18 @@ "message": "Failover is an advanced feature. Please make sure you understand how it works before enabling.\n\nWe recommend configuring provider priorities in the failover queue first.", "confirm": "I understand, enable" }, + "unifyCodexHistory": { + "title": "Unified Codex session history", + "message": "When enabled, the official subscription and third-party providers share one session history list. Note: resuming an old session across providers may fail because its encrypted_content reasoning cannot be decrypted by another backend.\n\nYou can also migrate your existing official session history into the shared list (originals are backed up to ~/.cc-switch/backups first and can be restored when you turn this off).", + "migrateExisting": "Also migrate existing official session history", + "confirm": "I understand, enable" + }, + "unifyCodexHistoryOff": { + "title": "Turn off unified session history", + "message": "After turning this off, the official subscription and third-party providers return to separate history lists. Sessions created while it was on cannot be attributed to a provider, so they stay in the third-party history and the official subscription will not see them.", + "restoreBackup": "Restore the official sessions migrated at enable time back to the official history (exact restore from backup)", + "confirm": "Turn off" + }, "usage": { "title": "Configure Usage Query", "message": "Usage query requires a custom script or API parameters. Please make sure you have obtained the necessary information from your provider.\n\nIf unsure how to configure, please consult your provider's documentation first.", @@ -671,7 +683,11 @@ "preserveCodexOfficialAuthOnSwitch": "Keep official login when switching third-party providers", "preserveCodexOfficialAuthOnSwitchDescription": "When enabled, you can use the Codex app's official plugins, mobile remote control, and other features while using third-party APIs.", "unifyCodexSessionHistory": "Unified Codex session history", - "unifyCodexSessionHistoryDescription": "When enabled, the official subscription runs under the shared \"custom\" provider id, so future official sessions appear in the same history list as third-party sessions (existing sessions are not migrated). Note: resuming an old session across providers may fail because its encrypted_content reasoning can only be decrypted by the backend that created it.", + "unifyCodexSessionHistoryDescription": "When enabled, the official subscription runs under the shared \"custom\" provider id so official and third-party sessions appear in one history list, optionally migrating existing official sessions in (backed up first). When turning it off, the migrated sessions can be restored from backup. Note: resuming an old session across providers may fail because its encrypted_content reasoning can only be decrypted by the backend that created it.", + "unifyCodexHistoryRestoreCompleted": "Official session history restored from backup ({{files}} session files, {{rows}} index rows)", + "unifyCodexHistoryRestoreFailed": "Failed to restore official session history, please try again", + "unifyCodexHistoryRestoreNothing": "No restorable migration backup for the current Codex directory", + "unifyCodexHistoryRestoreSkippedToggleOn": "Unified session history was re-enabled; restore skipped", "appVisibility": { "title": "Homepage Display", "description": "Choose which apps to show on the homepage", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 47d87ae80..cebf6bf25 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -279,6 +279,18 @@ "message": "フェイルオーバーは上級機能です。有効にする前に、その仕組みを理解していることをご確認ください。\n\nフェイルオーバーキューでプロバイダーの優先順位を先に設定することをお勧めします。", "confirm": "理解しました、有効にする" }, + "unifyCodexHistory": { + "title": "Codex セッション履歴を統一", + "message": "オンにすると、公式サブスクリプションとサードパーティが同じセッション履歴リストを共有します。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content を相手のバックエンドが復号できず失敗する場合があります。\n\n既存の公式セッション履歴を共有リストへ移行することもできます(移行前に ~/.cc-switch/backups へ自動バックアップされ、オフにする際に復元を選択できます)。", + "migrateExisting": "既存の公式セッション履歴も移行する", + "confirm": "理解しました、オンにする" + }, + "unifyCodexHistoryOff": { + "title": "セッション履歴の統一をオフにする", + "message": "オフにすると、公式サブスクリプションとサードパーティはそれぞれ独立した履歴リストに戻ります。オン期間中に作成されたセッションは提供元を判別できないため、サードパーティの履歴に残り、公式サブスクリプションからは見えなくなります。", + "restoreBackup": "オンにした際に移行した公式セッションを公式履歴へ復元する(バックアップから正確に復元)", + "confirm": "オフにする" + }, "usage": { "title": "使用量クエリの設定", "message": "使用量クエリにはカスタムスクリプトまたは API パラメータが必要です。プロバイダーから必要な情報を取得していることをご確認ください。\n\n設定方法が不明な場合は、プロバイダーのドキュメントを先にご確認ください。", @@ -671,7 +683,11 @@ "preserveCodexOfficialAuthOnSwitch": "サードパーティ切替時に公式ログインを保持", "preserveCodexOfficialAuthOnSwitchDescription": "オンにすると、サードパーティ API を使用する際に Codex アプリの公式プラグインやスマホからのリモート操作などの機能を利用できます。", "unifyCodexSessionHistory": "Codex セッション履歴を統一", - "unifyCodexSessionHistoryDescription": "オンにすると、公式サブスクリプションも共有の custom プロバイダー ID で動作し、今後の公式セッションはサードパーティのセッションと同じ履歴リストに表示されます(既存セッションは移行しません)。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content の推論内容を相手のバックエンドが復号できず、再開に失敗する場合があります。", + "unifyCodexSessionHistoryDescription": "オンにすると、公式サブスクリプションも共有の custom プロバイダー ID で動作し、公式とサードパーティのセッションが同じ履歴リストに表示されます。既存の公式セッションの移行も選択できます(移行前に自動バックアップ)。オフにする際はバックアップから復元できます。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content の推論内容を相手のバックエンドが復号できず、再開に失敗する場合があります。", + "unifyCodexHistoryRestoreCompleted": "バックアップから公式セッション履歴を復元しました(セッションファイル {{files}} 件、インデックス {{rows}} 行)", + "unifyCodexHistoryRestoreFailed": "公式セッション履歴の復元に失敗しました。もう一度お試しください", + "unifyCodexHistoryRestoreNothing": "現在の Codex ディレクトリに復元可能な移行バックアップはありません", + "unifyCodexHistoryRestoreSkippedToggleOn": "統一セッション履歴が再度有効化されたため、復元をスキップしました", "appVisibility": { "title": "ホームページ表示", "description": "ホームページに表示するアプリを選択", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index d4a0c7adc..66caee2a7 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -279,6 +279,18 @@ "message": "故障轉移是一項進階功能,啟用前請確保您已了解其運作原理。\n\n建議先在故障轉移佇列中設定好供應商優先順序。", "confirm": "我已了解,繼續啟用" }, + "unifyCodexHistory": { + "title": "統一 Codex 會話歷史", + "message": "開啟後,官方訂閱與第三方將共用同一個會話歷史清單。注意:跨供應商繼續舊會話時,可能因對方後端無法解密 encrypted_content 推理內容而失敗。\n\n可選擇同時把現有官方會話歷史遷入共享清單(遷移前自動備份到 ~/.cc-switch/backups,關閉開關時可選擇恢復)。", + "migrateExisting": "同時遷入現有官方會話歷史", + "confirm": "我已了解,繼續開啟" + }, + "unifyCodexHistoryOff": { + "title": "關閉統一會話歷史", + "message": "關閉後,官方訂閱與第三方將恢復各自獨立的會話歷史清單。開啟期間產生的會話因無法區分來源,將留在第三方歷史中,官方訂閱將看不到它們。", + "restoreBackup": "把開啟時遷入的官方會話還原回官方歷史(按備份精確還原)", + "confirm": "關閉" + }, "usage": { "title": "設定用量查詢", "message": "用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\n\n如不確定如何設定,請先查閱供應商文件。", @@ -671,7 +683,11 @@ "preserveCodexOfficialAuthOnSwitch": "切換第三方時保留官方登入", "preserveCodexOfficialAuthOnSwitchDescription": "開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能", "unifyCodexSessionHistory": "統一 Codex 會話歷史", - "unifyCodexSessionHistoryDescription": "開啟後,官方訂閱將以共享的 custom 供應商標識執行,未來的官方會話與第三方會話出現在同一歷史清單中(不遷移既有會話)。注意:跨供應商繼續舊會話時,對方後端可能無法解密會話中的 encrypted_content 推理內容,導致繼續失敗", + "unifyCodexSessionHistoryDescription": "開啟後,官方訂閱將以共享的 custom 供應商標識執行,官方與第三方會話出現在同一歷史清單中,並可選擇把現有官方會話一併遷入(遷移前自動備份)。關閉開關時可按備份恢復遷入的會話。注意:跨供應商繼續舊會話時,對方後端可能無法解密會話中的 encrypted_content 推理內容,導致繼續失敗", + "unifyCodexHistoryRestoreCompleted": "已按備份還原官方會話歷史({{files}} 個會話檔案、{{rows}} 條索引記錄)", + "unifyCodexHistoryRestoreFailed": "還原官方會話歷史失敗,請重試", + "unifyCodexHistoryRestoreNothing": "目前 Codex 目錄沒有可恢復的遷移備份", + "unifyCodexHistoryRestoreSkippedToggleOn": "統一會話歷史開關已重新開啟,已跳過還原", "appVisibility": { "title": "主頁面顯示", "description": "選擇在主頁面顯示的應用程式", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index e81ab1563..7f7689f78 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -279,6 +279,18 @@ "message": "故障转移是一项高级功能,启用前请确保您已了解其工作原理。\n\n建议先在故障转移队列中配置好供应商优先级。", "confirm": "我已了解,继续启用" }, + "unifyCodexHistory": { + "title": "统一 Codex 会话历史", + "message": "开启后,官方订阅与第三方将共用同一个会话历史列表。注意:跨供应商继续旧会话时,可能因对方后端无法解密 encrypted_content 推理内容而失败。\n\n可选择同时把现有官方会话历史迁入共享列表(迁移前自动备份到 ~/.cc-switch/backups,关闭开关时可选择恢复)。", + "migrateExisting": "同时迁入现有官方会话历史", + "confirm": "我已了解,继续开启" + }, + "unifyCodexHistoryOff": { + "title": "关闭统一会话历史", + "message": "关闭后,官方订阅与第三方将恢复各自独立的会话历史列表。开启期间产生的会话因无法区分来源,将留在第三方历史中,官方订阅将看不到它们。", + "restoreBackup": "把开启时迁入的官方会话还原回官方历史(按备份精确还原)", + "confirm": "关闭" + }, "usage": { "title": "配置用量查询", "message": "用量查询需要配置专用的查询脚本或 API 参数,请确保您已从供应商处获取相关信息。\n\n如不确定如何配置,请先查阅供应商文档。", @@ -671,7 +683,11 @@ "preserveCodexOfficialAuthOnSwitch": "切换第三方时保留官方登录", "preserveCodexOfficialAuthOnSwitchDescription": "开启后可以在使用第三方 API 的时候使用 Codex 应用的官方插件、手机远程操作等功能", "unifyCodexSessionHistory": "统一 Codex 会话历史", - "unifyCodexSessionHistoryDescription": "开启后,官方订阅将以共享的 custom 供应商标识运行,未来的官方会话与第三方会话出现在同一历史列表中(不迁移既有会话)。注意:跨供应商继续旧会话时,对方后端可能无法解密会话中的 encrypted_content 推理内容,导致继续失败", + "unifyCodexSessionHistoryDescription": "开启后,官方订阅将以共享的 custom 供应商标识运行,官方与第三方会话出现在同一历史列表中,并可选择把现有官方会话一并迁入(迁移前自动备份)。关闭开关时可按备份恢复迁入的会话。注意:跨供应商继续旧会话时,对方后端可能无法解密会话中的 encrypted_content 推理内容,导致继续失败", + "unifyCodexHistoryRestoreCompleted": "已按备份还原官方会话历史({{files}} 个会话文件、{{rows}} 条索引记录)", + "unifyCodexHistoryRestoreFailed": "还原官方会话历史失败,请重试", + "unifyCodexHistoryRestoreNothing": "当前 Codex 目录没有可恢复的迁移备份", + "unifyCodexHistoryRestoreSkippedToggleOn": "统一会话历史开关已重新开启,已跳过还原", "appVisibility": { "title": "主页面显示", "description": "选择在主页面显示的应用", diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 813ea929a..9c394c00b 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -19,6 +19,13 @@ export interface WebDavTestResult { message?: string; } +export interface CodexUnifyHistoryRestoreResult { + restoredJsonlFiles: number; + restoredStateRows: number; + /** 还原被跳过的原因(如当前目录没有账本);存在时不应报成功 */ + skippedReason?: string; +} + export interface WebDavSyncResult { status: string; } @@ -32,6 +39,16 @@ export const settingsApi = { return await invoke("save_settings", { settings }); }, + /** 是否存在统一 Codex 会话历史的迁移备份(关闭弹窗据此显示"恢复备份"勾选) */ + async hasCodexUnifyHistoryBackup(): Promise { + return await invoke("has_codex_unify_history_backup"); + }, + + /** 按迁移备份账本把当时迁入共享桶的官方会话还原回 openai 桶(幂等) */ + async restoreCodexUnifiedHistory(): Promise { + return await invoke("restore_codex_unified_history"); + }, + async restart(): Promise { return await invoke("restart_app"); }, diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index 3e52a5cb6..dbdff5e1e 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -59,7 +59,7 @@ export const settingsSchema = z.object({ }) .optional(), - // 本机自动迁移状态(后端维护,前端保存设置时应透传) + // 本机自动迁移状态(后端维护且保存时后端忽略前端值,仅供读取展示) localMigrations: z .object({ codexThirdPartyHistoryProviderBucketV1: z diff --git a/src/types.ts b/src/types.ts index 954eecc8e..6fccc84fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -354,6 +354,8 @@ export interface Settings { // Run official Codex under the shared "custom" provider id so future // sessions share one resume-history bucket with third-party providers unifyCodexSessionHistory?: boolean; + // User opted in (enable dialog checkbox) to migrate existing official sessions + unifyCodexMigrateExisting?: boolean; // User has confirmed the failover toggle first-run notice failoverConfirmed?: boolean; // User has confirmed the first-run welcome notice From a95b22dd79238e3877c535268ec2de81a3b07f4e Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 12 Jun 2026 23:35:07 +0800 Subject: [PATCH 42/66] fix(ui): keep ToggleRow icon from shrinking next to long descriptions --- src/components/ui/toggle-row.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/toggle-row.tsx b/src/components/ui/toggle-row.tsx index 6df8a37e3..a8535f357 100644 --- a/src/components/ui/toggle-row.tsx +++ b/src/components/ui/toggle-row.tsx @@ -20,7 +20,7 @@ export function ToggleRow({ return (
    -
    +
    {icon}
    From 6e519a749663efab1928c311aee0dc466cf03b30 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 12 Jun 2026 23:48:41 +0800 Subject: [PATCH 43/66] fix(usage): compact toolbar controls and unify visual style - Reduce all four filter controls to w-[100px] h-9 - Add ChevronDown icon to date picker trigger for consistency - Suppress focus border highlight on select triggers after close --- src/components/usage/UsageDashboard.tsx | 6 +++--- src/components/usage/UsageDateRangePicker.tsx | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index 0740dc376..9d49fcc27 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -206,7 +206,7 @@ export function UsageDashboard() { onValueChange={(v) => changeProviderName(decodeOptionValue(v))} > @@ -231,7 +231,7 @@ export function UsageDashboard() { onValueChange={(v) => setModel(decodeOptionValue(v))} > @@ -257,7 +257,7 @@ export function UsageDashboard() { onValueChange={(v) => changeRefreshInterval(Number(v))} > diff --git a/src/components/usage/UsageDateRangePicker.tsx b/src/components/usage/UsageDateRangePicker.tsx index 0e6af9d71..8f094c53d 100644 --- a/src/components/usage/UsageDateRangePicker.tsx +++ b/src/components/usage/UsageDateRangePicker.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react"; +import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -273,11 +273,12 @@ export function UsageDateRangePicker({ Date: Sat, 13 Jun 2026 19:11:59 +0800 Subject: [PATCH 44/66] feat: add Claude Fable 5 model mapping across Claude Code and Desktop - Wire claude-fable-5 as a fourth tier on both proxy paths, with a fable -> opus -> default fallback mirroring the official downgrade. - Whitelist the fable- prefix for the Desktop 1.12603.1+ validator. - Clarify fallbackModelHint (zh/en/ja/zh-TW): a blank tier on third-party endpoints forwards the literal model name and 404s. Refs #3980, #4026, #4049. --- src-tauri/src/claude_desktop_config.rs | 168 +++++++++++++++++- src-tauri/src/proxy/model_mapper.rs | 68 ++++++- .../forms/ClaudeDesktopProviderForm.tsx | 28 +-- .../providers/forms/ClaudeFormFields.tsx | 23 ++- .../providers/forms/ProviderForm.tsx | 4 + .../providers/forms/hooks/useModelState.ts | 42 ++++- src/config/claudeDesktopProviderPresets.ts | 9 +- src/i18n/locales/en.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/zh-TW.json | 4 +- src/i18n/locales/zh.json | 4 +- .../ClaudeDesktopProviderForm.test.tsx | 24 ++- tests/components/ClaudeFormFields.test.tsx | 2 + 13 files changed, 343 insertions(+), 41 deletions(-) diff --git a/src-tauri/src/claude_desktop_config.rs b/src-tauri/src/claude_desktop_config.rs index 88c68dcf0..0824aedf1 100644 --- a/src-tauri/src/claude_desktop_config.rs +++ b/src-tauri/src/claude_desktop_config.rs @@ -60,6 +60,16 @@ pub const DEFAULT_PROXY_ROUTES: &[ClaudeDesktopDefaultRoute] = &[ env_key: "ANTHROPIC_DEFAULT_HAIKU_MODEL", supports_1m: true, }, + // fable 置于末尾:next_catalog_safe_route_id 给非安全品牌 route 借用合法 + // 角色名时仍按 sonnet→opus→haiku 顺序分配(向后兼容既有 catalog),不会把 + // 无关品牌模型借用成 fable 顶配档名。UI 行序由前端 ROLE_ORDER 独立控制为 + // Sonnet/Opus/Fable/Haiku(所有 proxy 路径都经 normalizeProxyRows 重排), + // 与此处物理顺序无关。 + ClaudeDesktopDefaultRoute { + route_id: "claude-fable-5", + env_key: "ANTHROPIC_DEFAULT_FABLE_MODEL", + supports_1m: true, + }, ]; #[derive(Debug, Clone)] @@ -238,11 +248,16 @@ pub fn is_claude_safe_model_id(model: &str) -> bool { // 角色前缀后必须还有实际模型标识,拒绝 claude-sonnet- 这类退化值 // (否则会写入 profile 并触发 Claude Desktop fail-all 拒收整组)。 - ["sonnet-", "opus-", "haiku-"].iter().any(|prefix| { - route_tail - .strip_prefix(prefix) - .is_some_and(|rest| !rest.is_empty()) - }) + // Claude Desktop 1.12603.1+ 的 fail-all validator 角色白名单已纳入 fable + // (app.asar 内 ["sonnet","opus","haiku","fable","mythos"]),故 claude-fable-* + // 可安全写入 profile。mythos 官方未公开发布,暂不暴露给用户。 + ["sonnet-", "opus-", "haiku-", "fable-"] + .iter() + .any(|prefix| { + route_tail + .strip_prefix(prefix) + .is_some_and(|rest| !rest.is_empty()) + }) } fn inference_model_json(spec: &InferenceModelSpec) -> Value { @@ -693,8 +708,8 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result Result bool { ) } -/// 按角色关键词(opus / haiku / sonnet)归类一个 Claude 模型名/route_id。 +/// 按角色关键词(opus / haiku / fable / sonnet)归类一个 Claude 模型名/route_id。 /// 仅在命中明确角色词时返回 Some,未知模型返回 None(不回落,保持精确报错语义)。 -/// 与前端 `routeRoleFromId` 同序(opus → haiku → sonnet)。 +/// 与前端 `routeRoleFromId` 同序(opus → haiku → fable → sonnet)。 fn claude_role_keyword(model: &str) -> Option<&'static str> { let normalized = model.to_ascii_lowercase(); if normalized.contains("opus") { Some("opus") } else if normalized.contains("haiku") { Some("haiku") + } else if normalized.contains("fable") { + Some("fable") } else if normalized.contains("sonnet") { Some("sonnet") } else { @@ -1650,6 +1679,127 @@ mod tests { assert!(err.to_string().contains("gpt-5")); } + #[test] + fn claude_desktop_proxy_maps_fable_to_opus_tier() { + // issue #4026/#4049:老用户只配 Sonnet/Opus/Haiku 三档、未显式配置 + // fable 档时,fable 请求按官方分类器降级方向回落到 opus 档兜底。 + let mut provider = proxy_provider("proxy"); + provider + .meta + .as_mut() + .expect("meta") + .claude_desktop_model_routes = std::collections::HashMap::from([ + ( + "claude-opus-4-8".to_string(), + ClaudeDesktopModelRoute { + model: "upstream-opus".to_string(), + label_override: None, + supports_1m: Some(true), + }, + ), + ( + "claude-sonnet-4-6".to_string(), + ClaudeDesktopModelRoute { + model: "upstream-sonnet".to_string(), + label_override: None, + supports_1m: Some(true), + }, + ), + ]); + + let mapped = map_proxy_request_model( + json!({"model": "claude-fable-5", "messages": []}), + &provider, + ) + .expect("fable should fall back to the opus tier"); + assert_eq!(mapped["model"], json!("upstream-opus")); + + // 带 [1m] 标记与日期后缀的形态也应命中同一回落。 + let mapped_one_m = map_proxy_request_model( + json!({"model": "claude-fable-5[1m]", "messages": []}), + &provider, + ) + .expect("fable with [1m] marker should fall back to the opus tier"); + assert_eq!(mapped_one_m["model"], json!("upstream-opus")); + + let mapped_dated = map_proxy_request_model( + json!({"model": "claude-fable-5-20260609", "messages": []}), + &provider, + ) + .expect("dated fable alias should fall back to the opus tier"); + assert_eq!(mapped_dated["model"], json!("upstream-opus")); + } + + #[test] + fn claude_desktop_proxy_fable_without_opus_route_still_errors() { + // 没有 opus 档可回落时保持精确报错语义,不静默落到其他档。 + let mut provider = proxy_provider("proxy"); + provider + .meta + .as_mut() + .expect("meta") + .claude_desktop_model_routes = std::collections::HashMap::from([( + "claude-sonnet-4-6".to_string(), + ClaudeDesktopModelRoute { + model: "upstream-sonnet".to_string(), + label_override: None, + supports_1m: Some(true), + }, + )]); + + let err = map_proxy_request_model( + json!({"model": "claude-fable-5", "messages": []}), + &provider, + ) + .expect_err("fable without an opus route should fail"); + assert!(err.to_string().contains("claude-fable-5")); + } + + #[test] + fn claude_desktop_proxy_maps_fable_to_dedicated_route() { + // Desktop 1.12603.1+ fail-all 校验已放行 claude-fable-5,用户可显式配置 + // 独立 fable 档;此时 fable 请求精确命中 fable 档,不再降级到 opus。 + let mut provider = proxy_provider("proxy"); + provider + .meta + .as_mut() + .expect("meta") + .claude_desktop_model_routes = std::collections::HashMap::from([ + ( + "claude-opus-4-8".to_string(), + ClaudeDesktopModelRoute { + model: "upstream-opus".to_string(), + label_override: None, + supports_1m: Some(true), + }, + ), + ( + "claude-fable-5".to_string(), + ClaudeDesktopModelRoute { + model: "upstream-fable".to_string(), + label_override: None, + supports_1m: Some(true), + }, + ), + ]); + + // 精确匹配优先命中 fable 档 + let mapped = map_proxy_request_model( + json!({"model": "claude-fable-5", "messages": []}), + &provider, + ) + .expect("explicit fable route should match"); + assert_eq!(mapped["model"], json!("upstream-fable")); + + // 带日期后缀经角色关键词回落仍归 fable 档,而非降级 opus + let mapped_dated = map_proxy_request_model( + json!({"model": "claude-fable-5-20260609", "messages": []}), + &provider, + ) + .expect("dated fable alias should map via fable role keyword"); + assert_eq!(mapped_dated["model"], json!("upstream-fable")); + } + #[test] fn claude_desktop_proxy_accepts_opus_4_7_4_8_alias_during_rollout() { let mut provider = proxy_provider("proxy"); diff --git a/src-tauri/src/proxy/model_mapper.rs b/src-tauri/src/proxy/model_mapper.rs index e7652cc97..b2a18826e 100644 --- a/src-tauri/src/proxy/model_mapper.rs +++ b/src-tauri/src/proxy/model_mapper.rs @@ -11,6 +11,7 @@ pub struct ModelMapping { pub haiku_model: Option, pub sonnet_model: Option, pub opus_model: Option, + pub fable_model: Option, pub default_model: Option, } @@ -35,6 +36,11 @@ impl ModelMapping { .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(String::from), + fable_model: env + .and_then(|e| e.get("ANTHROPIC_DEFAULT_FABLE_MODEL")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from), default_model: env .and_then(|e| e.get("ANTHROPIC_MODEL")) .and_then(|v| v.as_str()) @@ -48,6 +54,7 @@ impl ModelMapping { self.haiku_model.is_some() || self.sonnet_model.is_some() || self.opus_model.is_some() + || self.fable_model.is_some() || self.default_model.is_some() } @@ -56,6 +63,16 @@ impl ModelMapping { let model_lower = original_model.to_lowercase(); // 1. 按模型类型匹配 + if model_lower.contains("fable") { + if let Some(ref m) = self.fable_model { + return m.clone(); + } + // 未单独配置 fable 档时归入 opus 档,与 Claude Code 官方 + // 分类器降级方向一致(fable→opus),避免落到 default 失去层级。 + if let Some(ref m) = self.opus_model { + return m.clone(); + } + } if model_lower.contains("haiku") { if let Some(ref m) = self.haiku_model { return m.clone(); @@ -154,7 +171,8 @@ mod tests { "ANTHROPIC_MODEL": "default-model", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped", "ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped" + "ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped", + "ANTHROPIC_DEFAULT_FABLE_MODEL": "fable-mapped" } }), website_url: None, @@ -214,6 +232,54 @@ mod tests { assert_eq!(mapped, Some("opus-mapped".to_string())); } + #[test] + fn test_fable_mapping() { + let provider = create_provider_with_mapping(); + let body = json!({"model": "claude-fable-5"}); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "fable-mapped"); + assert_eq!(mapped, Some("fable-mapped".to_string())); + } + + #[test] + fn test_fable_with_one_m_suffix_mapping() { + // Claude Code 实际会发 claude-fable-5[1m] 形态(issue #3980) + let provider = create_provider_with_mapping(); + let body = json!({"model": "claude-fable-5[1m]"}); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "fable-mapped"); + assert_eq!(mapped, Some("fable-mapped".to_string())); + } + + #[test] + fn test_fable_falls_back_to_opus_when_unset() { + let mut provider = create_provider_with_mapping(); + provider.settings_config = json!({ + "env": { + "ANTHROPIC_MODEL": "default-model", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped" + } + }); + let body = json!({"model": "claude-fable-5"}); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "opus-mapped"); + assert_eq!(mapped, Some("opus-mapped".to_string())); + } + + #[test] + fn test_fable_falls_back_to_default_without_opus() { + let mut provider = create_provider_with_mapping(); + provider.settings_config = json!({ + "env": { + "ANTHROPIC_MODEL": "default-model" + } + }); + let body = json!({"model": "claude-fable-5"}); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "default-model"); + assert_eq!(mapped, Some("default-model".to_string())); + } + #[test] fn test_thinking_does_not_affect_model_mapping() { // Issue #2081: thinking 参数不应影响模型映射 diff --git a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx index e8807fc91..64f4b1ae5 100644 --- a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx +++ b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx @@ -117,7 +117,7 @@ const CLAUDE_ROUTE_PREFIX = "claude-"; const ANTHROPIC_CLAUDE_ROUTE_PREFIX = "anthropic/claude-"; const LEGACY_ONE_M_MARKER = "[1m]"; const ROLE_ROUTE_IDS = CLAUDE_DESKTOP_ROLE_ROUTE_IDS; -const ROLE_ORDER: RouteRole[] = ["sonnet", "opus", "haiku"]; +const ROLE_ORDER: RouteRole[] = ["sonnet", "opus", "fable", "haiku"]; function envString( settingsConfig: Record | undefined, @@ -138,8 +138,10 @@ function clonePlainRecord(value: unknown): Record { function routeRoleFromId(route: string): RouteRole { const normalized = route.trim().toLowerCase(); + // 与后端 claude_role_keyword 同序(opus → haiku → fable → sonnet)。 if (normalized.includes("opus")) return "opus"; if (normalized.includes("haiku")) return "haiku"; + if (normalized.includes("fable")) return "fable"; return "sonnet"; } @@ -193,9 +195,10 @@ function initialRouteRows( }); } -// Proxy 模式对齐 Claude Code:固定 Sonnet / Opus / Haiku 三档。 -// 把任意来源的 route 行按角色归类到固定三槽(缺档留空),保证 UI 永远三行、 -// 用户不会漏配 Haiku 导致子 agent 找不到模型。 +// Proxy 模式对齐 Claude Code:固定 Sonnet / Opus / Fable / Haiku 四档。 +// 把任意来源的 route 行按角色归类到固定四槽(缺档留空),保证 UI 永远四行、 +// 用户不会漏配某档导致子 agent 找不到模型。 +// (fable 自 Desktop 1.12603.1+ 起被 fail-all 校验放行,可作为独立档位。) function normalizeProxyRows(rows: RouteRow[]): RouteRow[] { return ROLE_ORDER.map((role) => { const match = rows.find( @@ -221,7 +224,8 @@ function isClaudeSafeRoute(route: string) { // 角色前缀后必须还有实际模型标识,拒绝 claude-sonnet- 这类退化值 // (否则会写入 profile 并触发 Claude Desktop fail-all 拒收整组)。 - return ["sonnet-", "opus-", "haiku-"].some( + // 与后端 is_claude_safe_model_id 镜像;fable 自 Desktop 1.12603.1+ 起被校验放行。 + return ["sonnet-", "opus-", "haiku-", "fable-"].some( (prefix) => routeTail.startsWith(prefix) && routeTail.length > prefix.length, ); @@ -574,9 +578,9 @@ export function ClaudeDesktopProviderForm({ .filter((route) => route.route || route.model); if (mode === "proxy") { - // 固定三档(Sonnet / Opus / Haiku),route_id 由 UI 生成、恒合法, + // 固定四档(Sonnet / Opus / Fable / Haiku),route_id 由 UI 生成、恒合法, // 因此只要求至少填一个实际请求模型;留空档继承第一个已填档(Sonnet 优先), - // 对齐 Claude Code 的兜底,保证落库三档齐全、子 agent 不会找不到 Haiku。 + // 对齐 Claude Code 的兜底,保证落库四档齐全、子 agent 不会找不到模型。 const primary = routeEntries.find((route) => route.model); if (!primary) { toast.error( @@ -931,9 +935,13 @@ export function ClaudeDesktopProviderForm({ ? t("claudeDesktop.routeRoleHaiku", { defaultValue: "Haiku", }) - : t("claudeDesktop.routeRoleSonnet", { - defaultValue: "Sonnet", - }); + : role === "fable" + ? t("claudeDesktop.routeRoleFable", { + defaultValue: "Fable", + }) + : t("claudeDesktop.routeRoleSonnet", { + defaultValue: "Sonnet", + }); return (
    void; // Speed Test Endpoints @@ -187,6 +189,8 @@ export function ClaudeFormFields({ defaultSonnetModelName, defaultOpusModel, defaultOpusModelName, + defaultFableModel, + defaultFableModelName, onModelChange, speedTestEndpoints, apiFormat, @@ -204,6 +208,7 @@ export function ClaudeFormFields({ defaultHaikuModel || defaultSonnetModel || defaultOpusModel || + defaultFableModel || apiFormat !== "anthropic" || apiKeyField !== "ANTHROPIC_AUTH_TOKEN" || customUserAgent @@ -495,7 +500,7 @@ export function ClaudeFormFields({ }; type ModelRoleRow = { - role: "sonnet" | "opus" | "haiku"; + role: "sonnet" | "opus" | "fable" | "haiku"; label: string; model: string; displayName: string; @@ -526,6 +531,16 @@ export function ClaudeFormFields({ inputId: "claudeDefaultOpusModel", supportsOneM: true, }, + { + role: "fable", + label: t("providerForm.modelRoleFable", { defaultValue: "Fable" }), + model: defaultFableModel, + displayName: defaultFableModelName, + modelField: "ANTHROPIC_DEFAULT_FABLE_MODEL", + displayNameField: "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", + inputId: "claudeDefaultFableModel", + supportsOneM: true, + }, { role: "haiku", label: t("providerForm.modelRoleHaiku", { defaultValue: "Haiku" }), @@ -786,6 +801,7 @@ export function ClaudeFormFields({ claudeModel || defaultSonnetModel || defaultOpusModel || + defaultFableModel || defaultHaikuModel; if (value) { for (const row of modelRoleRows) { @@ -809,7 +825,8 @@ export function ClaudeFormFields({ !claudeModel && !defaultHaikuModel && !defaultSonnetModel && - !defaultOpusModel + !defaultOpusModel && + !defaultFableModel } className="h-7 gap-1" > @@ -936,7 +953,7 @@ export function ClaudeFormFields({

    {t("providerForm.fallbackModelHint", { defaultValue: - "仅在 Claude Code 请求没有明确落到 Sonnet、Opus 或 Haiku 角色时使用;通常可以留空。", + "用于未明确落到 Sonnet、Opus、Fable、Haiku 角色的请求。使用第三方/中转端点时建议填写:否则这些请求(含 Haiku 后台子任务)会以原始 Claude 模型名透传给上游,可能因上游无此模型而报错。官方端点可留空。", })}

    diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 8bafb5d3c..95240a4a2 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -449,6 +449,8 @@ function ProviderFormFull({ defaultSonnetModelName, defaultOpusModel, defaultOpusModelName, + defaultFableModel, + defaultFableModelName, handleModelChange, } = useModelState({ settingsConfig: form.getValues("settingsConfig"), @@ -2009,6 +2011,8 @@ function ProviderFormFull({ defaultSonnetModelName={defaultSonnetModelName} defaultOpusModel={defaultOpusModel} defaultOpusModelName={defaultOpusModelName} + defaultFableModel={defaultFableModel} + defaultFableModelName={defaultFableModelName} onModelChange={handleModelChange} speedTestEndpoints={speedTestEndpoints} apiFormat={localApiFormat} diff --git a/src/components/providers/forms/hooks/useModelState.ts b/src/components/providers/forms/hooks/useModelState.ts index 2a8a33d19..302a4c8da 100644 --- a/src/components/providers/forms/hooks/useModelState.ts +++ b/src/components/providers/forms/hooks/useModelState.ts @@ -12,7 +12,9 @@ export type ClaudeModelEnvField = | "ANTHROPIC_DEFAULT_SONNET_MODEL" | "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME" | "ANTHROPIC_DEFAULT_OPUS_MODEL" - | "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"; + | "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME" + | "ANTHROPIC_DEFAULT_FABLE_MODEL" + | "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME"; export const CLAUDE_ONE_M_MARKER = "[1M]"; @@ -69,8 +71,28 @@ function parseModelsFromConfig(settingsConfig: string) { typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME === "string" ? env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME : stripClaudeOneMMarker(opus); + // 回填链镜像运行时映射链(fable → opus → default),保证 UI 展示 + // 与代理实际转发的模型一致。 + const fable = + typeof env.ANTHROPIC_DEFAULT_FABLE_MODEL === "string" + ? env.ANTHROPIC_DEFAULT_FABLE_MODEL + : opus; + const fableName = + typeof env.ANTHROPIC_DEFAULT_FABLE_MODEL_NAME === "string" + ? env.ANTHROPIC_DEFAULT_FABLE_MODEL_NAME + : stripClaudeOneMMarker(fable); - return { model, haiku, haikuName, sonnet, sonnetName, opus, opusName }; + return { + model, + haiku, + haikuName, + sonnet, + sonnetName, + opus, + opusName, + fable, + fableName, + }; } catch { return { model: "", @@ -80,6 +102,8 @@ function parseModelsFromConfig(settingsConfig: string) { sonnetName: "", opus: "", opusName: "", + fable: "", + fableName: "", }; } } @@ -106,6 +130,10 @@ export function useModelState({ const [defaultOpusModelName, setDefaultOpusModelName] = useState( initial.opusName, ); + const [defaultFableModel, setDefaultFableModel] = useState(initial.fable); + const [defaultFableModelName, setDefaultFableModelName] = useState( + initial.fableName, + ); const isUserEditingRef = useRef(false); const lastConfigRef = useRef(settingsConfig); @@ -134,6 +162,8 @@ export function useModelState({ setDefaultSonnetModelName(parsed.sonnetName); setDefaultOpusModel(parsed.opus); setDefaultOpusModelName(parsed.opusName); + setDefaultFableModel(parsed.fable); + setDefaultFableModelName(parsed.fableName); }, [settingsConfig]); const handleModelChange = useCallback( @@ -152,6 +182,10 @@ export function useModelState({ if (field === "ANTHROPIC_DEFAULT_OPUS_MODEL") setDefaultOpusModel(value); if (field === "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME") setDefaultOpusModelName(value); + if (field === "ANTHROPIC_DEFAULT_FABLE_MODEL") + setDefaultFableModel(value); + if (field === "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME") + setDefaultFableModelName(value); try { const currentConfig = latestConfigRef.current @@ -195,6 +229,10 @@ export function useModelState({ setDefaultOpusModel, defaultOpusModelName, setDefaultOpusModelName, + defaultFableModel, + setDefaultFableModel, + defaultFableModelName, + setDefaultFableModelName, handleModelChange, }; } diff --git a/src/config/claudeDesktopProviderPresets.ts b/src/config/claudeDesktopProviderPresets.ts index 4146cda77..f92acf723 100644 --- a/src/config/claudeDesktopProviderPresets.ts +++ b/src/config/claudeDesktopProviderPresets.ts @@ -25,13 +25,16 @@ export interface ClaudeDesktopRoutePreset { } /** - * Claude Desktop 3P fail-all 校验只接受 `claude-(sonnet|opus|haiku)-*` 形式的 - * routeId(1.6259.1+,实测 2026-05-13)。所有预设工厂、表单角色下拉、 - * 后端 `next_catalog_safe_route_id` 都从此映射派生 routeId,避免散落硬编码。 + * Claude Desktop 3P fail-all 校验接受的角色名。Desktop 1.12603.1+ 起白名单 + * 纳入 fable(app.asar 内 ["sonnet","opus","haiku","fable","mythos"],实测 + * 2026-06-13);此前 1.6259.1 仅接受 sonnet/opus/haiku。mythos 官方未公开 + * 发布,暂不暴露给用户。所有预设工厂、表单角色下拉、后端 + * `next_catalog_safe_route_id` 都从此映射派生 routeId,避免散落硬编码。 */ export const CLAUDE_DESKTOP_ROLE_ROUTE_IDS = { sonnet: "claude-sonnet-4-6", opus: "claude-opus-4-8", + fable: "claude-fable-5", haiku: "claude-haiku-4-5", } as const; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9afe4c699..8481814cb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -194,6 +194,7 @@ "routeModelLabel": "Model role", "routeRoleSonnet": "Sonnet", "routeRoleOpus": "Opus", + "routeRoleFable": "Fable", "routeRoleHaiku": "Haiku", "labelOverrideLabel": "Menu display name", "upstreamModelLabel": "Requested model", @@ -1093,6 +1094,7 @@ "modelRoleLabel": "Model role", "modelRoleSonnet": "Sonnet", "modelRoleOpus": "Opus", + "modelRoleFable": "Fable", "modelRoleHaiku": "Haiku", "modelDisplayNameLabel": "Display name", "modelDisplayNamePlaceholder": "e.g. DeepSeek V4 Pro", @@ -1100,7 +1102,7 @@ "modelOneMHeader": "Declare 1M", "modelOneMLabel": "1M", "fallbackModelLabel": "Default fallback model", - "fallbackModelHint": "Used only when a Claude Code request does not clearly map to Sonnet, Opus, or Haiku. Usually safe to leave blank.", + "fallbackModelHint": "A fallback for requests that don't clearly map to Sonnet, Opus, Fable, or Haiku. Recommended for third-party/relay endpoints—otherwise such requests (including Haiku background subtasks) are forwarded under their original Claude model name and may fail if the upstream doesn't host it. Safe to leave blank for official endpoints.", "quickSetModels": "Quick Set", "quickSetSuccess": "Model name applied to all roles", "advancedOptionsToggle": "Advanced Options", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index cebf6bf25..48eceb5e3 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -194,6 +194,7 @@ "routeModelLabel": "モデル役割", "routeRoleSonnet": "Sonnet", "routeRoleOpus": "Opus", + "routeRoleFable": "Fable", "routeRoleHaiku": "Haiku", "labelOverrideLabel": "メニュー表示名", "upstreamModelLabel": "リクエストモデル", @@ -1093,6 +1094,7 @@ "modelRoleLabel": "モデル役割", "modelRoleSonnet": "Sonnet", "modelRoleOpus": "Opus", + "modelRoleFable": "Fable", "modelRoleHaiku": "Haiku", "modelDisplayNameLabel": "表示名", "modelDisplayNamePlaceholder": "例: DeepSeek V4 Pro", @@ -1100,7 +1102,7 @@ "modelOneMHeader": "1M 対応を宣言", "modelOneMLabel": "1M", "fallbackModelLabel": "既定フォールバックモデル", - "fallbackModelHint": "Claude Code のリクエストが Sonnet、Opus、Haiku のいずれにも明確に対応しない場合のみ使われます。通常は空欄で構いません。", + "fallbackModelHint": "Sonnet・Opus・Fable・Haiku のいずれにも明確に対応しないリクエストのフォールバックです。サードパーティ/中継エンドポイントでは設定を推奨します。設定しないと、これらのリクエスト(Haiku のバックグラウンドサブタスクを含む)が元の Claude モデル名のまま上流へ転送され、上流に該当モデルが無い場合は失敗することがあります。公式エンドポイントでは空欄で構いません。", "quickSetModels": "一括設定", "quickSetSuccess": "モデル名をすべての役割に適用しました", "advancedOptionsToggle": "高級オプション", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 66caee2a7..957b442a2 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -194,6 +194,7 @@ "routeModelLabel": "模型角色", "routeRoleSonnet": "Sonnet", "routeRoleOpus": "Opus", + "routeRoleFable": "Fable", "routeRoleHaiku": "Haiku", "labelOverrideLabel": "選單顯示名稱", "upstreamModelLabel": "實際請求模型", @@ -1065,6 +1066,7 @@ "modelRoleLabel": "模型角色", "modelRoleSonnet": "Sonnet", "modelRoleOpus": "Opus", + "modelRoleFable": "Fable", "modelRoleHaiku": "Haiku", "modelDisplayNameLabel": "顯示名稱", "modelDisplayNamePlaceholder": "例如 DeepSeek V4 Pro", @@ -1072,7 +1074,7 @@ "modelOneMHeader": "聲明支援 1M", "modelOneMLabel": "1M", "fallbackModelLabel": "備用模型", - "fallbackModelHint": "僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。", + "fallbackModelHint": "用於未明確落到 Sonnet、Opus、Fable、Haiku 角色的請求。使用第三方/中轉端點時建議填寫:否則這些請求(含 Haiku 背景子任務)會以原始 Claude 模型名透傳給上游,可能因上游無此模型而報錯。官方端點可留空。", "quickSetModels": "一鍵設定", "quickSetSuccess": "已將模型名稱套用至所有角色", "advancedOptionsToggle": "進階選項", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7f7689f78..3fdf71975 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -194,6 +194,7 @@ "routeModelLabel": "模型角色", "routeRoleSonnet": "Sonnet", "routeRoleOpus": "Opus", + "routeRoleFable": "Fable", "routeRoleHaiku": "Haiku", "labelOverrideLabel": "菜单显示名", "upstreamModelLabel": "实际请求模型", @@ -1093,6 +1094,7 @@ "modelRoleLabel": "模型角色", "modelRoleSonnet": "Sonnet", "modelRoleOpus": "Opus", + "modelRoleFable": "Fable", "modelRoleHaiku": "Haiku", "modelDisplayNameLabel": "显示名称", "modelDisplayNamePlaceholder": "例如 DeepSeek V4 Pro", @@ -1100,7 +1102,7 @@ "modelOneMHeader": "声明支持 1M", "modelOneMLabel": "1M", "fallbackModelLabel": "默认兜底模型", - "fallbackModelHint": "仅在 Claude Code 请求没有明确落到 Sonnet、Opus 或 Haiku 角色时使用;通常可以留空。", + "fallbackModelHint": "用于未明确落到 Sonnet、Opus、Fable、Haiku 角色的请求。使用第三方/中转端点时建议填写:否则这些请求(含 Haiku 后台子任务)会以原始 Claude 模型名透传给上游,可能因上游无此模型而报错。官方端点可留空。", "quickSetModels": "一键设置", "quickSetSuccess": "已将模型名称应用到所有角色", "advancedOptionsToggle": "高级选项", diff --git a/tests/components/ClaudeDesktopProviderForm.test.tsx b/tests/components/ClaudeDesktopProviderForm.test.tsx index 43427b002..c8b118516 100644 --- a/tests/components/ClaudeDesktopProviderForm.test.tsx +++ b/tests/components/ClaudeDesktopProviderForm.test.tsx @@ -97,7 +97,7 @@ describe("ClaudeDesktopProviderForm", () => { expect(document.activeElement).toBe(currentInput); }); - it("代理模式始终渲染 Sonnet / Opus / Haiku 三档(即使只配了一档)", () => { + it("代理模式始终渲染 Sonnet / Opus / Fable / Haiku 四档(即使只配了一档)", () => { renderForm({ name: "Proxy Provider", settingsConfig: { @@ -114,13 +114,13 @@ describe("ClaudeDesktopProviderForm", () => { }, }); - // 固定三档:每档各一个「菜单显示名」输入框,无论初始只配了几档。 - expect(screen.getAllByPlaceholderText("DeepSeek V4 Pro")).toHaveLength(3); + // 固定四档:每档各一个「菜单显示名」输入框,无论初始只配了几档。 + expect(screen.getAllByPlaceholderText("DeepSeek V4 Pro")).toHaveLength(4); }); - it("代理模式初始无路由且默认路由未就绪时不渲染空三档", () => { + it("代理模式初始无路由且默认路由未就绪时不渲染空四档", () => { // mock 的 getClaudeDesktopDefaultRoutes 返回 [],模拟默认路由尚未就绪。 - // 修复前:normalizeProxyRows([]) 会渲染 3 条空行并把 routes.length 撑到 3, + // 修复前:normalizeProxyRows([]) 会渲染空行并把 routes.length 撑起来, // 永久挡住 seed effect 的默认路由回填。修复后应保持空、等待 seed。 renderForm({ name: "Proxy Provider", @@ -139,7 +139,7 @@ describe("ClaudeDesktopProviderForm", () => { expect(screen.queryAllByPlaceholderText("DeepSeek V4 Pro")).toHaveLength(0); }); - it("保存模型映射时补齐固定三档并把留空档回填为 Sonnet 模型", async () => { + it("保存模型映射时补齐固定四档并把留空档回填为 Sonnet 模型", async () => { const onSubmit = vi.fn(); renderForm( { @@ -166,19 +166,25 @@ describe("ClaudeDesktopProviderForm", () => { await waitFor(() => expect(onSubmit).toHaveBeenCalled()); const submitted = onSubmit.mock.calls[0][0]; - // claude-old 迁移到 Sonnet;留空的 Opus / Haiku 回填为 Sonnet 的上游模型, - // 保证落库三档齐全,子 agent 调用的 Haiku 始终可解析。 + // claude-old 迁移到 Sonnet;留空的 Opus / Fable / Haiku 回填为 Sonnet 的 + // 上游模型,保证落库四档齐全,子 agent 调用的各档始终可解析。 expect(submitted.meta.claudeDesktopModelRoutes).toMatchObject({ "claude-sonnet-4-6": { model: "upstream-old", labelOverride: "upstream-old", }, "claude-opus-4-8": { model: "upstream-old" }, + "claude-fable-5": { model: "upstream-old" }, "claude-haiku-4-5": { model: "upstream-old" }, }); expect( Object.keys(submitted.meta.claudeDesktopModelRoutes).sort(), - ).toEqual(["claude-haiku-4-5", "claude-opus-4-8", "claude-sonnet-4-6"]); + ).toEqual([ + "claude-fable-5", + "claude-haiku-4-5", + "claude-opus-4-8", + "claude-sonnet-4-6", + ]); }); it("回填空档时继承 Sonnet 的 1M 声明", async () => { diff --git a/tests/components/ClaudeFormFields.test.tsx b/tests/components/ClaudeFormFields.test.tsx index 336339260..260462ba7 100644 --- a/tests/components/ClaudeFormFields.test.tsx +++ b/tests/components/ClaudeFormFields.test.tsx @@ -83,6 +83,8 @@ const renderCopilotForm = (overrides: Partial = {}) => { defaultSonnetModelName: "Claude Sonnet", defaultOpusModel: "", defaultOpusModelName: "", + defaultFableModel: "", + defaultFableModelName: "", onModelChange: vi.fn(), speedTestEndpoints: [], apiFormat: "anthropic", From 526bb60f5c6edbfac2945136af3d2c5db76babbe Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 13 Jun 2026 20:16:09 +0800 Subject: [PATCH 45/66] fix(ui): make Claude Desktop model-mapping placeholders role-consistent The menu display name and request model columns used mismatched example brands (DeepSeek vs Kimi), implying a display name maps to an unrelated request model. Derive both placeholders from the row role so each row stays brand-consistent, and route the lightweight Haiku tier to a flash example (deepseek-v4-flash) while other tiers use deepseek-v4-pro. --- .../providers/forms/ClaudeDesktopProviderForm.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx index 64f4b1ae5..bd0fd3e44 100644 --- a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx +++ b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx @@ -942,6 +942,15 @@ export function ClaudeDesktopProviderForm({ : t("claudeDesktop.routeRoleSonnet", { defaultValue: "Sonnet", }); + // Haiku 档示范映射到轻量模型(flash),其余档映射到 pro; + // 两列占位联动,保持每行「菜单显示名 ↔ 实际请求模型」品牌一致。 + const isHaikuRole = role === "haiku"; + const labelPlaceholder = isHaikuRole + ? "DeepSeek V4 Flash" + : "DeepSeek V4 Pro"; + const modelPlaceholder = isHaikuRole + ? "deepseek-v4-flash" + : "deepseek-v4-pro"; return (
    updateRoute(index, { model: event.target.value }) } - placeholder="kimi-k2 / deepseek-chat" + placeholder={modelPlaceholder} className="flex-1" /> {fetchedModels.length > 0 && ( From cd8252c7d970405a65ce1a8cf1d3e31e33dd3852 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 13 Jun 2026 22:15:31 +0800 Subject: [PATCH 46/66] fix(ui): raise popover/tooltip z-index above fullscreen panels PopoverContent and TooltipContent used z-50, below FullScreenPanel's z-[60] opaque overlay. Popovers such as the provider preset search therefore rendered behind the panel and looked unresponsive on click. Bump both to z-[100], matching the select dropdown: above fullscreen panels, below modal dialogs (z-[110]). --- src/components/ui/popover.tsx | 2 +- src/components/ui/tooltip.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 9f00cbca2..d979a5271 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -16,7 +16,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - "z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-[100] rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className, )} {...props} diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 92a97e46a..8a99d7ab9 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-[100] overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className, )} {...props} From 276b2572a3c693f26b7b3c719b1c28ed167a6244 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 13 Jun 2026 22:15:32 +0800 Subject: [PATCH 47/66] fix(providers): scope preset search to provider names only The preset search text also included websiteUrl and the shared category label, producing imprecise matches: a single category term matched the whole group, and URL fragments like "com"/"api" matched nearly everything. Restrict the search text to the display name and raw name; category labels are still used for rendering. --- .../forms/ProviderPresetSelector.tsx | 29 +++--------- .../ProviderPresetSelector.test.tsx | 45 +++++-------------- 2 files changed, 15 insertions(+), 59 deletions(-) diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx index c24c5a87e..29ca675f6 100644 --- a/src/components/providers/forms/ProviderPresetSelector.tsx +++ b/src/components/providers/forms/ProviderPresetSelector.tsx @@ -63,19 +63,9 @@ export function getPresetDisplayName( export function getPresetSearchText( entry: PresetEntry, - presetCategoryLabels: Record, t: PresetTranslator, ): string { - const presetCategory = entry.preset.category ?? "others"; - const categoryLabel = - presetCategoryLabels[presetCategory] ?? String(t("providerPreset.other")); - - return [ - getPresetDisplayName(entry.preset, t), - entry.preset.name, - entry.preset.websiteUrl, - categoryLabel, - ] + return [getPresetDisplayName(entry.preset, t), entry.preset.name] .join(" ") .toLowerCase(); } @@ -83,7 +73,6 @@ export function getPresetSearchText( export function filterPresetEntries( entries: PresetEntry[], query: string, - presetCategoryLabels: Record, t: PresetTranslator, ): PresetEntry[] { const normalizedQuery = query.trim().toLowerCase(); @@ -92,9 +81,7 @@ export function filterPresetEntries( } return entries.filter((entry) => - getPresetSearchText(entry, presetCategoryLabels, t).includes( - normalizedQuery, - ), + getPresetSearchText(entry, t).includes(normalizedQuery), ); } @@ -117,7 +104,6 @@ export function sortPresetEntries( export interface PresetVisibilityOptions { query: string; sortMode: PresetSortMode; - presetCategoryLabels: Record; t: PresetTranslator; } @@ -125,13 +111,9 @@ export function getVisiblePresetEntries( entries: PresetEntry[], options: PresetVisibilityOptions, ): PresetEntry[] { - const { query, sortMode, presetCategoryLabels, t } = options; + const { query, sortMode, t } = options; - return sortPresetEntries( - filterPresetEntries(entries, query, presetCategoryLabels, t), - sortMode, - t, - ); + return sortPresetEntries(filterPresetEntries(entries, query, t), sortMode, t); } interface ProviderPresetSelectorProps { @@ -165,10 +147,9 @@ export function ProviderPresetSelector({ getVisiblePresetEntries(presetEntries, { query: searchQuery, sortMode, - presetCategoryLabels, t, }), - [presetEntries, presetCategoryLabels, searchQuery, sortMode, t], + [presetEntries, searchQuery, sortMode, t], ); const getCategoryHint = (): ReactNode => { diff --git a/tests/components/ProviderPresetSelector.test.tsx b/tests/components/ProviderPresetSelector.test.tsx index 394aaa798..005161d2a 100644 --- a/tests/components/ProviderPresetSelector.test.tsx +++ b/tests/components/ProviderPresetSelector.test.tsx @@ -153,52 +153,28 @@ describe("ProviderPresetSelector pure helpers", () => { ); }); - it("拼接显示名、原始名称、URL、分类 label,并统一 lower-case", () => { - const searchText = getPresetSearchText( - presetEntries[1], - presetCategoryLabels, - t, - ); + it("仅拼接显示名与原始名称、统一 lower-case,不含 URL 或分类 label", () => { + const searchText = getPresetSearchText(presetEntries[1], t); expect(searchText).toContain("alpha 本地名"); expect(searchText).toContain("alpha raw"); - expect(searchText).toContain("https://alpha.example.com/v1"); - expect(searchText).toContain("官方"); + expect(searchText).not.toContain("example.com"); + expect(searchText).not.toContain("官方"); expect(searchText).toBe(searchText.toLowerCase()); }); it("空 query 返回原数组,非空 query 大小写不敏感匹配", () => { + expect(filterPresetEntries(presetEntries, " ", t)).toBe(presetEntries); expect( - filterPresetEntries(presetEntries, " ", presetCategoryLabels, t), - ).toBe(presetEntries); - expect( - getIds( - filterPresetEntries( - presetEntries, - "ALPHA 本地名", - presetCategoryLabels, - t, - ), - ), + getIds(filterPresetEntries(presetEntries, "ALPHA 本地名", t)), ).toEqual(["alpha"]); }); - it("支持通过 URL 和分类 label 搜索", () => { + it("不再通过 URL 或分类 label 搜索(仅匹配名称)", () => { expect( - getIds( - filterPresetEntries( - presetEntries, - "cn-gateway.example.com", - presetCategoryLabels, - t, - ), - ), - ).toEqual(["beta"]); - expect( - getIds( - filterPresetEntries(presetEntries, "聚合", presetCategoryLabels, t), - ), - ).toEqual(["gamma"]); + getIds(filterPresetEntries(presetEntries, "cn-gateway.example.com", t)), + ).toEqual([]); + expect(getIds(filterPresetEntries(presetEntries, "聚合", t))).toEqual([]); }); it("支持 A-Z 排序、original 副本恢复原顺序,并且 getVisible 先 filter 再 sort", () => { @@ -222,7 +198,6 @@ describe("ProviderPresetSelector pure helpers", () => { getVisiblePresetEntries(presetEntries, { query: "a", sortMode: nameAscMode, - presetCategoryLabels, t, }), ), From 1992d6be7262ad3aeff8ee6042ba625bf7e6f088 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 13 Jun 2026 22:26:36 +0800 Subject: [PATCH 48/66] feat: add Kimi K2.7 Code model and upgrade official Kimi presets Add kimi-k2.7-code pricing seed (in $0.95 / out $4.00 / cache-read $0.19 per 1M tokens, 256K context) and point all six official Moonshot Kimi presets (Claude Code, Codex, Claude Desktop, Hermes, OpenCode, OpenClaw) at the new model. Rename the version-tagged OpenCode/OpenClaw presets to "Kimi K2.7 Code" and correct the OpenClaw context window to 262144. The seed is applied via the idempotent INSERT OR IGNORE path that runs on every startup, so existing users pick up the new pricing without a schema migration. Kimi For Coding and Nvidia presets are intentionally untouched. --- src-tauri/src/database/schema.rs | 8 ++++++++ src/config/claudeDesktopProviderPresets.ts | 6 +++++- src/config/claudeProviderPresets.ts | 8 ++++---- src/config/codexProviderPresets.ts | 8 ++++++-- src/config/hermesProviderPresets.ts | 4 ++-- src/config/openclawProviderPresets.ts | 12 ++++++------ src/config/opencodeProviderPresets.ts | 6 +++--- 7 files changed, 34 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index ce66507c9..b4050ce76 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -1803,6 +1803,14 @@ impl Database { ), ("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"), ("kimi-k2.6", "Kimi K2.6", "0.95", "4.00", "0.16", "0"), + ( + "kimi-k2.7-code", + "Kimi K2.7 Code", + "0.95", + "4.00", + "0.19", + "0", + ), // MiniMax 系列 ("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"), ( diff --git a/src/config/claudeDesktopProviderPresets.ts b/src/config/claudeDesktopProviderPresets.ts index f92acf723..e891d0ecf 100644 --- a/src/config/claudeDesktopProviderPresets.ts +++ b/src/config/claudeDesktopProviderPresets.ts @@ -419,7 +419,11 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [ baseUrl: "https://api.moonshot.cn/anthropic", mode: "proxy", apiFormat: "anthropic", - modelRoutes: brandedRoutes("kimi-k2.6", "kimi-k2.6", "kimi-k2.6"), + modelRoutes: brandedRoutes( + "kimi-k2.7-code", + "kimi-k2.7-code", + "kimi-k2.7-code", + ), icon: "kimi", iconColor: "#6366F1", }, diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index c4af9b3b3..949f56d89 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -372,10 +372,10 @@ export const providerPresets: ProviderPreset[] = [ env: { ANTHROPIC_BASE_URL: "https://api.moonshot.cn/anthropic", ANTHROPIC_AUTH_TOKEN: "", - ANTHROPIC_MODEL: "kimi-k2.6", - ANTHROPIC_DEFAULT_HAIKU_MODEL: "kimi-k2.6", - ANTHROPIC_DEFAULT_SONNET_MODEL: "kimi-k2.6", - ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2.6", + ANTHROPIC_MODEL: "kimi-k2.7-code", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "kimi-k2.7-code", + ANTHROPIC_DEFAULT_SONNET_MODEL: "kimi-k2.7-code", + ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2.7-code", }, }, category: "cn_official", diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 9118e9174..9da6d031b 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -426,12 +426,16 @@ requires_openai_auth = true`, config: generateThirdPartyConfig( "kimi", "https://api.moonshot.cn/v1", - "kimi-k2.6", + "kimi-k2.7-code", ), endpointCandidates: ["https://api.moonshot.cn/v1"], apiFormat: "openai_chat", modelCatalog: modelCatalog([ - { model: "kimi-k2.6", displayName: "Kimi K2.6", contextWindow: 262144 }, + { + model: "kimi-k2.7-code", + displayName: "Kimi K2.7 Code", + contextWindow: 262144, + }, ]), codexChatReasoning: { supportsThinking: true, diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index 04a847f67..eaf882da3 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -521,13 +521,13 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ base_url: "https://api.moonshot.cn/v1", api_key: "", api_mode: "chat_completions", - models: [{ id: "kimi-k2.6", name: "Kimi K2.6" }], + models: [{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }], }, category: "cn_official", icon: "kimi", iconColor: "#6366F1", suggestedDefaults: { - model: { default: "kimi-k2.6", provider: "kimi" }, + model: { default: "kimi-k2.7-code", provider: "kimi" }, }, }, { diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index c1e3110e6..83892862f 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -490,7 +490,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, }, { - name: "Kimi k2.6", + name: "Kimi K2.7 Code", websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch", apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", settingsConfig: { @@ -499,9 +499,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "openai-completions", models: [ { - id: "kimi-k2.6", - name: "Kimi K2.6", - contextWindow: 131072, + id: "kimi-k2.7-code", + name: "Kimi K2.7 Code", + contextWindow: 262144, cost: { input: 0.002, output: 0.006 }, }, ], @@ -523,8 +523,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, }, suggestedDefaults: { - model: { primary: "kimi/kimi-k2.6" }, - modelCatalog: { "kimi/kimi-k2.6": { alias: "Kimi" } }, + model: { primary: "kimi/kimi-k2.7-code" }, + modelCatalog: { "kimi/kimi-k2.7-code": { alias: "Kimi" } }, }, }, { diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 53da43c3f..290925128 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -588,19 +588,19 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, { - name: "Kimi k2.6", + name: "Kimi K2.7 Code", websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch", apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch", settingsConfig: { npm: "@ai-sdk/openai-compatible", - name: "Kimi k2.6", + name: "Kimi K2.7 Code", options: { baseURL: "https://api.moonshot.cn/v1", apiKey: "", setCacheKey: true, }, models: { - "kimi-k2.6": { name: "Kimi K2.6" }, + "kimi-k2.7-code": { name: "Kimi K2.7 Code" }, }, }, category: "cn_official", From b37a9e8f607cc7eb2b4907666b7d29a5b113e670 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 13 Jun 2026 23:16:11 +0800 Subject: [PATCH 49/66] chore: remove stray nested preset file accidentally committed in #667 cc-switch-main/src/config/universalProviderPresets.ts was an outdated duplicate of the real preset file, introduced by mistake in #667 and referenced by no code. Remove the orphaned nested directory. --- .../src/config/universalProviderPresets.ts | 162 ------------------ 1 file changed, 162 deletions(-) delete mode 100644 cc-switch-main/src/config/universalProviderPresets.ts diff --git a/cc-switch-main/src/config/universalProviderPresets.ts b/cc-switch-main/src/config/universalProviderPresets.ts deleted file mode 100644 index e2f9cd04b..000000000 --- a/cc-switch-main/src/config/universalProviderPresets.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * 统一供应商(Universal Provider)预设配置 - * - * 统一供应商是跨应用共享的配置,修改后会自动同步到 Claude、Codex、Gemini 三个应用。 - * 适用于 NewAPI 等支持多种协议的 API 网关。 - */ - -import type { - UniversalProvider, - UniversalProviderApps, - UniversalProviderModels, -} from "@/types"; - -/** - * 统一供应商预设接口 - */ -export interface UniversalProviderPreset { - /** 预设名称 */ - name: string; - /** 供应商类型标识 */ - providerType: string; - /** 默认启用的应用 */ - defaultApps: UniversalProviderApps; - /** 默认模型配置 */ - defaultModels: UniversalProviderModels; - /** 网站链接 */ - websiteUrl?: string; - /** 图标名称 */ - icon?: string; - /** 图标颜色 */ - iconColor?: string; - /** 描述 */ - description?: string; - /** 是否为自定义模板(允许用户完全自定义) */ - isCustomTemplate?: boolean; -} - -/** - * NewAPI 默认模型配置 - */ -const NEWAPI_DEFAULT_MODELS: UniversalProviderModels = { - claude: { - model: "claude-sonnet-4-20250514", - haikuModel: "claude-haiku-4-20250514", - sonnetModel: "claude-sonnet-4-20250514", - opusModel: "claude-sonnet-4-20250514", - }, - codex: { - model: "gpt-4o", - reasoningEffort: "high", - }, - gemini: { - model: "gemini-2.5-pro", - }, -}; - -const N1N_DEFAULT_MODELS: UniversalProviderModels = { - claude: { - model: "claude-3-5-sonnet-20240620", - haikuModel: "claude-3-haiku-20240307", - sonnetModel: "claude-3-5-sonnet-20240620", - opusModel: "claude-3-opus-20240229", - }, - codex: { - model: "gpt-4o", - reasoningEffort: "high", - }, - gemini: { - model: "gemini-1.5-pro-latest", - }, -}; - -/** - * 统一供应商预设列表 - */ -export const universalProviderPresets: UniversalProviderPreset[] = [ - { - name: "n1n.ai", - providerType: "n1n", - defaultApps: { - claude: true, - codex: true, - gemini: true, - }, - defaultModels: N1N_DEFAULT_MODELS, - websiteUrl: "https://n1n.ai", - icon: "openai", - iconColor: "#000000", - description: - "n1n.ai - 聚合 OpenAI, Anthropic, Google 等主流大模型的一站式 AI 服务平台", - }, - { - name: "NewAPI", - providerType: "newapi", - defaultApps: { - claude: true, - codex: true, - gemini: true, - }, - defaultModels: NEWAPI_DEFAULT_MODELS, - websiteUrl: "https://www.newapi.pro", - icon: "newapi", - iconColor: "#00A67E", - description: - "NewAPI 是一个可自部署的 API 网关,支持 Anthropic、OpenAI、Gemini 等多种协议", - }, - { - name: "自定义网关", - providerType: "custom_gateway", - defaultApps: { - claude: true, - codex: true, - gemini: true, - }, - defaultModels: NEWAPI_DEFAULT_MODELS, - icon: "openai", - iconColor: "#6366F1", - description: "自定义配置的 API 网关", - isCustomTemplate: true, - }, -]; - -/** - * 根据预设创建统一供应商 - */ -export function createUniversalProviderFromPreset( - preset: UniversalProviderPreset, - id: string, - baseUrl: string, - apiKey: string, - customName?: string, -): UniversalProvider { - return { - id, - name: customName || preset.name, - providerType: preset.providerType, - apps: { ...preset.defaultApps }, - baseUrl, - apiKey, - models: JSON.parse(JSON.stringify(preset.defaultModels)), // Deep copy - websiteUrl: preset.websiteUrl, - icon: preset.icon, - iconColor: preset.iconColor, - createdAt: Date.now(), - }; -} - -/** - * 获取预设的显示名称(用于 UI) - */ -export function getPresetDisplayName(preset: UniversalProviderPreset): string { - return preset.name; -} - -/** - * 根据类型查找预设 - */ -export function findPresetByType( - providerType: string, -): UniversalProviderPreset | undefined { - return universalProviderPresets.find((p) => p.providerType === providerType); -} From efbb52a3fc0cd6c0e6ade5a4efb55fb55b3e10ff Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 13 Jun 2026 23:16:11 +0800 Subject: [PATCH 50/66] feat(presets): add GLM 5.1 context window for AtlasCloud Codex preset Declare the 200000-token context window for zai-org/glm-5.1, matching the other GLM 5.1 preset entries. --- src/config/codexProviderPresets.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 9da6d031b..72c77df87 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -949,7 +949,11 @@ requires_openai_auth = true`, endpointCandidates: ["https://api.atlascloud.ai/v1"], apiFormat: "openai_chat", modelCatalog: modelCatalog([ - { model: "zai-org/glm-5.1", displayName: "GLM 5.1" }, + { + model: "zai-org/glm-5.1", + displayName: "GLM 5.1", + contextWindow: 200000, + }, ]), isPartner: true, partnerPromotionKey: "atlascloud", From 3bb17434fb5fb25b061e194afba00ebf3c542029 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 13 Jun 2026 23:16:11 +0800 Subject: [PATCH 51/66] test: align preset tests with Kimi K2.7 and Fable model tiers Update the Codex chat preset test's Kimi expectation (kimi-k2.6 -> kimi-k2.7-code) after the Kimi K2.7 upgrade, update the Claude Desktop form test for the four-tier (Sonnet/Opus/Fable/Haiku) routes, and reformat UsageDateRangePicker imports (prettier). --- src/components/usage/UsageDateRangePicker.tsx | 7 +++++- .../ClaudeDesktopProviderForm.test.tsx | 24 +++++++++++-------- tests/config/codexChatProviderPresets.test.ts | 2 +- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/components/usage/UsageDateRangePicker.tsx b/src/components/usage/UsageDateRangePicker.tsx index 8f094c53d..067e7eeb1 100644 --- a/src/components/usage/UsageDateRangePicker.tsx +++ b/src/components/usage/UsageDateRangePicker.tsx @@ -1,6 +1,11 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; +import { + CalendarDays, + ChevronDown, + ChevronLeft, + ChevronRight, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { diff --git a/tests/components/ClaudeDesktopProviderForm.test.tsx b/tests/components/ClaudeDesktopProviderForm.test.tsx index c8b118516..47c43143b 100644 --- a/tests/components/ClaudeDesktopProviderForm.test.tsx +++ b/tests/components/ClaudeDesktopProviderForm.test.tsx @@ -49,7 +49,7 @@ describe("ClaudeDesktopProviderForm", () => { }, }); - // 固定三档(Sonnet / Opus / Haiku)下有三个菜单显示名输入,取 Sonnet(首个)。 + // 固定四档(Sonnet / Opus / Fable / Haiku)下有四个菜单显示名输入,取 Sonnet(首个)。 const input = screen.getAllByPlaceholderText( "DeepSeek V4 Pro", )[0] as HTMLInputElement; @@ -115,7 +115,11 @@ describe("ClaudeDesktopProviderForm", () => { }); // 固定四档:每档各一个「菜单显示名」输入框,无论初始只配了几档。 - expect(screen.getAllByPlaceholderText("DeepSeek V4 Pro")).toHaveLength(4); + // Haiku 档的占位示例是 "DeepSeek V4 Flash"、其余三档是 "DeepSeek V4 Pro" + // (见组件的 role-consistent 占位逻辑),故用正则同时匹配两种占位、数满四档。 + expect( + screen.getAllByPlaceholderText(/DeepSeek V4 (Pro|Flash)/), + ).toHaveLength(4); }); it("代理模式初始无路由且默认路由未就绪时不渲染空四档", () => { @@ -177,14 +181,14 @@ describe("ClaudeDesktopProviderForm", () => { "claude-fable-5": { model: "upstream-old" }, "claude-haiku-4-5": { model: "upstream-old" }, }); - expect( - Object.keys(submitted.meta.claudeDesktopModelRoutes).sort(), - ).toEqual([ - "claude-fable-5", - "claude-haiku-4-5", - "claude-opus-4-8", - "claude-sonnet-4-6", - ]); + expect(Object.keys(submitted.meta.claudeDesktopModelRoutes).sort()).toEqual( + [ + "claude-fable-5", + "claude-haiku-4-5", + "claude-opus-4-8", + "claude-sonnet-4-6", + ], + ); }); it("回填空档时继承 Sonnet 的 1M 声明", async () => { diff --git a/tests/config/codexChatProviderPresets.test.ts b/tests/config/codexChatProviderPresets.test.ts index 61fa416cd..8986caf3c 100644 --- a/tests/config/codexChatProviderPresets.test.ts +++ b/tests/config/codexChatProviderPresets.test.ts @@ -76,7 +76,7 @@ const expectedChatPresets = new Map< "Kimi", { baseUrl: "https://api.moonshot.cn/v1", - contextWindows: { "kimi-k2.6": 262144 }, + contextWindows: { "kimi-k2.7-code": 262144 }, }, ], [ From 11572b1337999a53eb6be6e657e2b17fd72f49d3 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 13 Jun 2026 23:47:27 +0800 Subject: [PATCH 52/66] feat(codex): restore Kimi For Coding preset with thinking on by default The Kimi For Coding preset was removed (74104946) because the coding endpoint (api.kimi.com/coding/v1) enforces a User-Agent whitelist that rejects Codex's default codex-cli UA with 403. The provider-level custom User-Agent feature now lets users override the UA (to claude-cli/*) under proxy takeover, so the preset can be restored. - Re-add the Codex Kimi For Coding preset (openai_chat, kimi-for-coding, 256K context) in the same position it was removed from. - Enable thinking mode by default via codexChatReasoning (supportsThinking true, thinkingParam "thinking"), mirroring the general Kimi preset since both target the same Moonshot model family. The proxy injects thinking:{type:enabled} when Codex requests reasoning. - Restore the trilingual user-manual row to "Kimi / Kimi For Coding". Note: this preset requires proxy takeover and a whitelisted custom User-Agent to work; the default codex-cli UA still gets 403. --- docs/user-manual/en/2-providers/2.1-add.md | 2 +- docs/user-manual/ja/2-providers/2.1-add.md | 2 +- docs/user-manual/zh/2-providers/2.1-add.md | 2 +- src/config/codexProviderPresets.ts | 30 ++++++++++++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/user-manual/en/2-providers/2.1-add.md b/docs/user-manual/en/2-providers/2.1-add.md index 2e1102461..972f9fa75 100644 --- a/docs/user-manual/en/2-providers/2.1-add.md +++ b/docs/user-manual/en/2-providers/2.1-add.md @@ -88,7 +88,7 @@ Codex presets fall into two groups by upstream protocol. |-------------|-------------| | DeepSeek | DeepSeek models | | Zhipu GLM / GLM en | Zhipu AI GLM models | -| Kimi | Moonshot Kimi models | +| Kimi / Kimi For Coding | Moonshot Kimi models | | MiniMax / MiniMax en | MiniMax models | | StepFun / StepFun en | StepFun Step models | | Baidu Qianfan Coding Plan | Baidu Qianfan coding plan | diff --git a/docs/user-manual/ja/2-providers/2.1-add.md b/docs/user-manual/ja/2-providers/2.1-add.md index b913e89fd..b3bce48e2 100644 --- a/docs/user-manual/ja/2-providers/2.1-add.md +++ b/docs/user-manual/ja/2-providers/2.1-add.md @@ -88,7 +88,7 @@ Codex プリセットは上流プロトコルにより 2 種類に分かれま |----------|------| | DeepSeek | DeepSeek モデル | | Zhipu GLM / GLM en | Zhipu AI の GLM モデル | -| Kimi | Moonshot Kimi モデル | +| Kimi / Kimi For Coding | Moonshot Kimi モデル | | MiniMax / MiniMax en | MiniMax モデル | | StepFun / StepFun en | StepFun Step モデル | | Baidu Qianfan Coding Plan | 百度千帆コーディングプラン | diff --git a/docs/user-manual/zh/2-providers/2.1-add.md b/docs/user-manual/zh/2-providers/2.1-add.md index 8147997c1..710dddd5f 100644 --- a/docs/user-manual/zh/2-providers/2.1-add.md +++ b/docs/user-manual/zh/2-providers/2.1-add.md @@ -88,7 +88,7 @@ Codex 预设按上游协议分两类。 |----------|------| | DeepSeek | DeepSeek 模型 | | 智谱 GLM / GLM en | 智谱 AI 的 GLM 模型 | -| Kimi | Moonshot Kimi 模型 | +| Kimi / Kimi For Coding | Moonshot Kimi 模型 | | MiniMax / MiniMax en | MiniMax 模型 | | StepFun / StepFun en | 阶跃星辰 Step 模型 | | 百度千帆 Coding Plan | 百度千帆编程套餐 | diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 72c77df87..016ce1cf7 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -448,6 +448,36 @@ requires_openai_auth = true`, icon: "kimi", iconColor: "#6366F1", }, + { + name: "Kimi For Coding", + websiteUrl: "https://www.kimi.com/code/docs/", + apiKeyUrl: "https://www.kimi.com/code/", + auth: generateThirdPartyAuth(""), + config: generateThirdPartyConfig( + "kimi_coding", + "https://api.kimi.com/coding/v1", + "kimi-for-coding", + ), + endpointCandidates: ["https://api.kimi.com/coding/v1"], + apiFormat: "openai_chat", + modelCatalog: modelCatalog([ + { + model: "kimi-for-coding", + displayName: "Kimi For Coding", + contextWindow: 262144, + }, + ]), + codexChatReasoning: { + supportsThinking: true, + supportsEffort: false, + thinkingParam: "thinking", + effortParam: "none", + outputFormat: "reasoning_content", + }, + category: "cn_official", + icon: "kimi", + iconColor: "#6366F1", + }, { name: "StepFun", websiteUrl: "https://platform.stepfun.com/step-plan", From 0c46efe1be1da8a91ad87240ae342c5590b0541d Mon Sep 17 00:00:00 2001 From: thisTom Date: Sun, 14 Jun 2026 16:55:23 +0800 Subject: [PATCH 53/66] fix(macos): prevent duplicate provider terminal sessions (#4156) * fix(macos): prevent duplicate provider terminals * fix(macos): keep Ghostty fallback on AppleScript failure --------- Co-authored-by: thisTom <19346741+thisTom@users.noreply.github.com> --- src-tauri/src/commands/misc.rs | 273 ++++++++++++++++++++++++++------- 1 file changed, 214 insertions(+), 59 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 81aa1328d..a59560955 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -2620,29 +2620,58 @@ exec bash --norc --noprofile result } -/// macOS: Terminal.app +/// Escape a value as an AppleScript string literal. #[cfg(target_os = "macos")] -fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> { - use std::process::Command; +fn applescript_string_literal(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} - let applescript = format!( - r#"tell application "Terminal" - activate - do script "bash '{}'" +/// Build the launcher command literal used by AppleScript. +#[cfg(target_os = "macos")] +fn applescript_launcher_command(script_file: &std::path::Path) -> String { + applescript_string_literal(&format!( + "bash {}", + shell_single_quote(&script_file.to_string_lossy()) + )) +} + +/// macOS: Terminal.app AppleScript. +/// A cold `activate` creates a default empty window before `do script` opens the command session. +/// Use `launch` for cold starts so `do script` can create the only new session without reusing restored windows. +#[cfg(target_os = "macos")] +fn build_macos_terminal_applescript(script_file: &std::path::Path) -> String { + format!( + r#"set launcher_script to {launcher} +set was_running to application "Terminal" is running +tell application "Terminal" + if was_running then + activate + do script launcher_script + else + launch + do script launcher_script + activate + end if end tell"#, - script_file.display() - ); + launcher = applescript_launcher_command(script_file) + ) +} + +/// Run AppleScript through `osascript -e` with shared error handling. +#[cfg(target_os = "macos")] +fn run_terminal_osascript(applescript: &str, terminal_label: &str) -> Result<(), String> { + use std::process::Command; let output = Command::new("osascript") .arg("-e") - .arg(&applescript) + .arg(applescript) .output() .map_err(|e| format!("执行 osascript 失败: {e}"))?; if !output.status.success() { let stderr = decode_command_output(&output.stderr); return Err(format!( - "Terminal.app 执行失败 (exit code: {:?}): {}", + "{terminal_label} 执行失败 (exit code: {:?}): {}", output.status.code(), stderr )); @@ -2651,11 +2680,20 @@ end tell"#, Ok(()) } +/// macOS: Terminal.app +#[cfg(target_os = "macos")] +fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> { + run_terminal_osascript( + &build_macos_terminal_applescript(script_file), + "Terminal.app", + ) +} + /// macOS: iTerm2 #[cfg(target_os = "macos")] fn build_macos_iterm2_applescript(script_file: &std::path::Path) -> String { format!( - r#"set launcher_script to "bash '{}'" + r#"set launcher_script to {launcher} set was_running to application "iTerm" is running tell application "iTerm" if was_running then @@ -2683,63 +2721,59 @@ tell application "iTerm" write text launcher_script end tell end tell"#, - script_file.display() + launcher = applescript_launcher_command(script_file) ) } /// macOS: iTerm2 #[cfg(target_os = "macos")] fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> { - use std::process::Command; - - let applescript = build_macos_iterm2_applescript(script_file); - - let output = Command::new("osascript") - .arg("-e") - .arg(&applescript) - .output() - .map_err(|e| format!("执行 osascript 失败: {e}"))?; - - if !output.status.success() { - let stderr = decode_command_output(&output.stderr); - return Err(format!( - "iTerm2 执行失败 (exit code: {:?}): {}", - output.status.code(), - stderr - )); - } - - Ok(()) + run_terminal_osascript(&build_macos_iterm2_applescript(script_file), "iTerm2") } -/// macOS: Ghostty — use --quit-after-last-window-closed to avoid cloning existing tabs +/// Keep the launcher path inside a `bash -c` string. +/// A bare `.sh` passed through `open --args` may also be opened as a document. +#[cfg(target_os = "macos")] +fn build_macos_dash_c_command(script_file: &std::path::Path) -> String { + format!( + "exec bash {}", + shell_single_quote(&script_file.to_string_lossy()) + ) +} + +/// macOS: Ghostty. +/// Warm starts use AppleScript to create one command window. +/// Cold starts use `initial-command` so the first default surface runs the launcher. +/// Do not use `initial-window=false` plus `new window`: cold launch can still create the default window first. +#[cfg(target_os = "macos")] +fn build_macos_ghostty_applescript(script_file: &std::path::Path) -> String { + format!( + r#"set launcher_command to {launcher} +set was_running to application "Ghostty" is running +if was_running then + tell application "Ghostty" + new window with configuration {{command:launcher_command}} + end tell +else + do shell script "open -na Ghostty --args --quit-after-last-window-closed=true " & quoted form of ("--initial-command=" & launcher_command) +end if +"#, + launcher = applescript_launcher_command(script_file) + ) +} + +/// macOS: Ghostty #[cfg(target_os = "macos")] fn launch_macos_ghostty(script_file: &std::path::Path) -> Result<(), String> { - use std::process::Command; - - let output = Command::new("open") - .args([ - "-na", - "Ghostty", - "--args", - "--quit-after-last-window-closed=true", - "-e", - "bash", - ]) - .arg(script_file) - .output() - .map_err(|e| format!("启动 Ghostty 失败: {e}"))?; - - if !output.status.success() { - let stderr = decode_command_output(&output.stderr); - return Err(format!( - "Ghostty 启动失败 (exit code: {:?}): {}", - output.status.code(), - stderr - )); + match run_terminal_osascript(&build_macos_ghostty_applescript(script_file), "Ghostty") { + Ok(()) => Ok(()), + Err(applescript_error) => { + log::warn!( + "Ghostty AppleScript launch failed, falling back to open -na: {applescript_error}" + ); + launch_macos_open_app("Ghostty", script_file, true) + } } - - Ok(()) } /// macOS: 使用 open -na 启动支持 --args 参数的终端(Alacritty/Kitty/WezTerm/Kaku) @@ -2757,7 +2791,10 @@ fn launch_macos_open_app( if use_e_flag { cmd.arg("-e"); } - cmd.arg("bash").arg(script_file); + // Keep the script path inside `bash -c`; a trailing bare `.sh` can be opened as a document. + cmd.arg("bash") + .arg("-c") + .arg(build_macos_dash_c_command(script_file)); let output = cmd .output() @@ -4693,6 +4730,124 @@ mod tests { assert!(running_branch.contains("create tab with default profile")); } + /// Terminal `activate` creates a default empty window on cold start; `launch` does not. + #[cfg(target_os = "macos")] + #[test] + fn terminal_applescript_cold_start_uses_launch_before_do_script() { + let script = build_macos_terminal_applescript(Path::new("/tmp/cc_switch_launcher.sh")); + + assert!( + script.contains(r#"set was_running to application "Terminal" is running"#), + "missing was_running detection:\n{script}" + ); + // Cold launches avoid `activate` until after `do script`, so no default empty window is created first. + assert!( + script.contains( + "else\n launch\n do script launcher_script\n activate" + ), + "cold start should launch before activating:\n{script}" + ); + // Already-running launches should create a fresh session. + assert!( + script.contains( + "if was_running then\n activate\n do script launcher_script\n" + ), + "already-running branch should use bare do script:\n{script}" + ); + } + + /// Restored windows should not receive the launcher command. + #[cfg(target_os = "macos")] + #[test] + fn terminal_applescript_does_not_hijack_restored_windows() { + let script = build_macos_terminal_applescript(Path::new("/tmp/cc_switch_launcher.sh")); + assert!( + !script.contains(" in window 1"), + "should not inject into an existing/restored Terminal window:\n{script}" + ); + assert!( + !script.contains("count of windows"), + "should not infer restored-window safety from window count:\n{script}" + ); + } + + /// Ghostty cold starts use `initial-command`; warm starts use the scripting dictionary. + #[cfg(target_os = "macos")] + #[test] + fn ghostty_applescript_cold_start_uses_initial_command() { + let script = build_macos_ghostty_applescript(Path::new("/tmp/cc_switch_launcher.sh")); + + // Warm launches execute through the AppleScript command property, not `open -na ... -e`. + assert!( + script.contains(r#"set launcher_command to "bash '/tmp/cc_switch_launcher.sh'""#), + "missing launcher_command:\n{script}" + ); + assert!(script.contains("if was_running then")); + assert!(script.contains("new window with configuration {command:launcher_command}")); + assert!( + !script.contains(" --args -e"), + "should not execute through open -na -e:\n{script}" + ); + // Cold launches make Ghostty's first default surface execute the launcher. + assert!(script.contains(r#"set was_running to application "Ghostty" is running"#)); + assert!( + script.contains( + r#"do shell script "open -na Ghostty --args --quit-after-last-window-closed=true " & quoted form of ("--initial-command=" & launcher_command)"# + ), + "cold start should use initial-command:\n{script}" + ); + assert!( + !script.contains("--initial-window=false"), + "should not rely on initial-window=false:\n{script}" + ); + assert!( + !script.contains("delay 0.5"), + "should not rely on a fixed delay:\n{script}" + ); + assert!( + !script.contains("old_ids"), + "should not track default windows for closing:\n{script}" + ); + assert!( + !script.contains("close window"), + "should not close a default window:\n{script}" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn dash_c_command_wraps_script_path_inside_quoted_arg() { + // The script path must stay inside the `-c` string, not as a bare argv. + let s = build_macos_dash_c_command(Path::new("/tmp/cc_switch_launcher_1.sh")); + assert_eq!(s, "exec bash '/tmp/cc_switch_launcher_1.sh'"); + + // Spaces and single quotes must stay shell-safe too. + let s2 = build_macos_dash_c_command(Path::new("/Users/me/it's dir/x.sh")); + assert_eq!(s2, r#"exec bash '/Users/me/it'"'"'s dir/x.sh'"#); + } + + /// AppleScript launchers need both shell-path quoting and AppleScript string quoting. + #[cfg(target_os = "macos")] + #[test] + fn applescript_builders_safely_quote_special_paths() { + // First shell-quote the path, then wrap the whole command as an AppleScript string. + let expected = r#""bash '/Users/me/it'\"'\"'s dir/x.sh'""#; + let p = Path::new("/Users/me/it's dir/x.sh"); + assert_eq!(applescript_launcher_command(p), expected); + assert!( + build_macos_terminal_applescript(p).contains(expected), + "Terminal did not quote safely" + ); + assert!( + build_macos_iterm2_applescript(p).contains(expected), + "iTerm2 did not quote safely" + ); + assert!( + build_macos_ghostty_applescript(p).contains(expected), + "Ghostty did not quote safely" + ); + } + #[test] fn build_windows_cwd_command_str_uses_cd_for_drive_paths() { let command = build_windows_cwd_command_str(r"C:\work\repo"); From b7ad1c4bf84a6596aefc6abd9caba44f31f5b87d Mon Sep 17 00:00:00 2001 From: WangJiati Date: Sun, 14 Jun 2026 18:18:43 +0800 Subject: [PATCH 54/66] =?UTF-8?q?=E8=B0=83=E6=95=B4=E9=A2=84=E8=AE=BE?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E6=8C=89=E9=92=AE=E5=A4=96=E8=A7=82?= =?UTF-8?q?=E4=B8=8E=E6=90=9C=E7=B4=A2=E6=A1=86=E4=BD=8D=E7=BD=AE=20(#4183?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 调整预设供应商按钮外观与搜索框位置 1. 调整预设供应商按钮外观,显示默认图标,大小统一; 2. 调整预设供应商搜索框位置。 Co-Authored-By: Claude Opus 4.7 * test(provider): 新增预设按钮外观与 inline 搜索的单元测试 覆盖: 1. 所有预设按钮固定 200px 宽度,视觉对齐一致 2. preset.icon 存在时按钮内渲染 ProviderIcon 3. preset 无 icon 且无 theme.icon 时渲染占位元素保持文字对齐 4. 点击放大镜 inline 切换搜索输入框可见性,ESC 收起并清空 Co-Authored-By: Claude Opus 4.7 * refactor(provider-preset): responsive grid layout and search polish - Replace fixed-width preset buttons with a responsive CSS grid (auto-fill, 150px min column) - Add a leading placeholder to the custom button so its label aligns with iconed presets - Close the inline search box on outside click, restoring the old Popover behavior - Span the empty-state hint across the full grid row - Update component tests for the new layout and behaviors --------- Co-authored-by: Claude Opus 4.7 Co-authored-by: Jason --- .../forms/ProviderPresetSelector.tsx | 261 ++++++++++-------- .../ProviderPresetSelector.test.tsx | 149 ++++++++++ 2 files changed, 291 insertions(+), 119 deletions(-) diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx index 29ca675f6..56177eae4 100644 --- a/src/components/providers/forms/ProviderPresetSelector.tsx +++ b/src/components/providers/forms/ProviderPresetSelector.tsx @@ -1,19 +1,8 @@ -import { useMemo, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { FormLabel } from "@/components/ui/form"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons"; import { ArrowUpAZ, Search, Zap, Star, Layers, Settings2 } from "lucide-react"; import type { ProviderPreset } from "@/config/claudeProviderPresets"; @@ -141,6 +130,25 @@ export function ProviderPresetSelector({ const [sortMode, setSortMode] = useState( PresetSortMode.Original, ); + const searchContainerRef = useRef(null); + + // 点击搜索区域外时收起并清空,对齐旧 Popover 的「点击外部关闭」行为 + useEffect(() => { + if (!searchOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if ( + searchContainerRef.current && + !searchContainerRef.current.contains(event.target as Node) + ) { + setSearchOpen(false); + setSearchQuery(""); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [searchOpen]); const visiblePresetEntries = useMemo( () => @@ -195,26 +203,38 @@ export function ProviderPresetSelector({ }; const renderPresetIcon = (preset: AnyPreset) => { - const iconType = preset.theme?.icon; - if (!iconType) return null; - - switch (iconType) { - case "claude": - return ; - case "codex": - return ; - case "gemini": - return ; - case "generic": - return ; - default: - return null; + if (preset.icon) { + return ( + + ); } + + const iconType = preset.theme?.icon; + if (iconType) { + switch (iconType) { + case "claude": + return ; + case "codex": + return ; + case "gemini": + return ; + case "generic": + return ; + } + } + + return ; }; const getPresetButtonClass = (isSelected: boolean, preset: AnyPreset) => { const baseClass = - "inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"; + "inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors w-full"; if (isSelected) { if (preset.theme?.backgroundColor) { @@ -241,101 +261,95 @@ export function ProviderPresetSelector({
    {t("providerPreset.label")} - -
    - - - - - - - - - {t("providerPreset.searchTooltip", { - defaultValue: "Search presets", - })} - - - - setSearchQuery(event.target.value)} - placeholder={t("providerPreset.searchPlaceholder", { - defaultValue: "Search presets...", - })} - aria-label={t("providerPreset.searchAriaLabel", { - defaultValue: "Search provider presets", - })} - autoFocus - /> - - +
    + {searchOpen && ( + setSearchQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + setSearchQuery(""); + setSearchOpen(false); + } + }} + placeholder={t("providerPreset.searchPlaceholder", { + defaultValue: "Search presets...", + })} + aria-label={t("providerPreset.searchAriaLabel", { + defaultValue: "Search provider presets", + })} + className="w-48 h-8" + autoFocus + /> + )} + - - - - - - {sortMode === PresetSortMode.NameAsc - ? t("providerPreset.sortOriginalTooltip", { - defaultValue: "Restore original order", - }) - : t("providerPreset.sortNameAscTooltip", { - defaultValue: "Sort A-Z", - })} - - -
    - + +
    -
    +
    {visiblePresetEntries.length === 0 && ( -
    +
    {t("providerPreset.noSearchResults", { defaultValue: "No matching presets.", })} @@ -359,7 +373,9 @@ export function ProviderPresetSelector({ } > {renderPresetIcon(entry.preset)} - {getPresetDisplayName(entry.preset, t)} + + {getPresetDisplayName(entry.preset, t)} + {isPartner && ( @@ -372,20 +388,25 @@ export function ProviderPresetSelector({ {onUniversalPresetSelect && universalProviderPresets.length > 0 && ( <> -
    +
    {universalProviderPresets.map((preset) => ( )}
    diff --git a/tests/components/ProviderPresetSelector.test.tsx b/tests/components/ProviderPresetSelector.test.tsx index 005161d2a..26c44a3a9 100644 --- a/tests/components/ProviderPresetSelector.test.tsx +++ b/tests/components/ProviderPresetSelector.test.tsx @@ -15,6 +15,29 @@ import { type PresetSortMode, } from "@/components/providers/forms/ProviderPresetSelector"; +// Mock ProviderIcon 以避免依赖图标库的实际内容 +vi.mock("@/components/ProviderIcon", () => ({ + ProviderIcon: ({ + icon, + name, + color, + size, + }: { + icon?: string; + name: string; + color?: string; + size?: number; + }) => ( + + ), +})); + const presetCategoryLabels = { official: "官方", cn_official: "国产官方", @@ -295,4 +318,130 @@ describe("ProviderPresetSelector", () => { ), ).toBeInTheDocument(); }); + + it("所有预设按钮填满网格列宽(w-full)实现等宽对齐", () => { + renderSelector(); + + const presetButtons = screen.getAllByRole("button"); + const fullWidthButtons = presetButtons.filter((btn) => + btn.className.includes("w-full"), + ); + + // 至少包含 custom + 4 个预设 = 5 个等宽按钮(搜索/排序按钮为 size-8 不计入) + expect(fullWidthButtons.length).toBeGreaterThanOrEqual(5); + }); + + it("preset.icon 存在时按钮内渲染图标元素(img/svg)", () => { + const entriesWithIcon = [ + { + id: "with-icon", + preset: { + name: "With Icon", + websiteUrl: "https://icon.example.com", + settingsConfig: {}, + category: "official" as ProviderCategory, + icon: "claude-api", + iconColor: "#D4915D", + }, + }, + ]; + + renderSelector({ entries: entriesWithIcon }); + + const button = screen.getByRole("button", { name: /with icon/i }); + const icon = button.querySelector('[data-testid="provider-icon"]'); + expect(icon).not.toBeNull(); + expect(icon?.getAttribute("data-icon")).toBe("claude-api"); + expect(icon?.getAttribute("data-color")).toBe("#D4915D"); + }); + + it("preset 无 icon 且无 theme.icon 时,按钮内仍渲染占位元素保持文字对齐", () => { + const entriesWithoutIcon = [ + { + id: "no-icon", + preset: { + name: "No Icon", + websiteUrl: "https://noicon.example.com", + settingsConfig: {}, + category: "official" as ProviderCategory, + }, + }, + ]; + + renderSelector({ entries: entriesWithoutIcon }); + + const button = screen.getByRole("button", { name: /no icon/i }); + // 占位 span(16x16)应该存在,保证文字位置与有图标的按钮对齐 + const placeholder = button.querySelector("span[aria-hidden]"); + expect(placeholder).not.toBeNull(); + }); + + it("custom 按钮同样渲染占位元素,文字与带图标的预设按钮对齐", () => { + renderSelector(); + + const customButton = screen.getByRole("button", { + name: "providerPreset.custom", + }); + const placeholder = customButton.querySelector("span[aria-hidden]"); + expect(placeholder).not.toBeNull(); + }); + + it("点击放大镜 inline 切换搜索输入框可见性,ESC 收起并清空", async () => { + const user = userEvent.setup(); + renderSelector(); + + // 初始没有搜索输入框 + expect( + screen.queryByRole("textbox", { + name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i, + }), + ).not.toBeInTheDocument(); + + // 点击放大镜展开输入框 + await user.click(getSearchButton()); + const input = getSearchInput(); + expect(input).toBeInTheDocument(); + + // 输入关键字过滤 + await user.type(input, "gateway"); + expect( + screen.getByRole("button", { name: "Beta Gateway" }), + ).toBeInTheDocument(); + + // ESC 收起输入框并清空 + await user.keyboard("{Escape}"); + expect( + screen.queryByRole("textbox", { + name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i, + }), + ).not.toBeInTheDocument(); + // 收起后所有预设恢复显示 + expect( + screen.getByRole("button", { name: "preset.gamma" }), + ).toBeInTheDocument(); + }); + + it("点击搜索区域外自动收起并清空", async () => { + const user = userEvent.setup(); + renderSelector(); + + await user.click(getSearchButton()); + await user.type(getSearchInput(), "gateway"); + expect(getSearchInput()).toBeInTheDocument(); + + // 点击搜索区域外的元素(custom 按钮)应收起搜索框 + await user.click( + screen.getByRole("button", { name: "providerPreset.custom" }), + ); + + expect( + screen.queryByRole("textbox", { + name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i, + }), + ).not.toBeInTheDocument(); + // 收起后清空 query,所有预设恢复显示 + expect( + screen.getByRole("button", { name: "preset.gamma" }), + ).toBeInTheDocument(); + }); }); From a5903d86006b0597c069e15dbb3a680e8de95a3c Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 14 Jun 2026 15:15:34 +0800 Subject: [PATCH 55/66] feat(health-check): replace real-LLM probe with HTTP reachability check The provider panel health check sent a real streaming model request, which many third-party providers block (401/403/WAF), causing false negatives while only stable official endpoints passed. Replace it with a lightweight reachability probe: GET the provider base_url and treat any HTTP response (200/4xx/5xx) as reachable; only DNS/connect/TLS/timeout count as failure. Latency is the probe's TTFB. Backend (services/stream_check.rs): rewrite ~2200 -> ~350 lines, dropping real-request building, format conversion, auth and API-path resolution while keeping per-app base_url extraction. Defaults: 8s timeout, 1 retry, 1500ms degraded threshold. Failover invariant: the reachability check must never reset the circuit breaker (reachable != usable; a 403 host is reachable but broken for real traffic). Remove the resetCircuitBreaker call from useStreamCheck; failover failure detection stays driven solely by real proxy traffic (forwarder/circuit_breaker untouched). useResetCircuitBreaker is kept dormant for a future manual-recovery entry. Open the check to all providers: drop the official/copilot/codex-oauth/third-party gating and the 'sends a real request' confirm dialog. For official providers whose base_url is intentionally empty, fall back to the endpoint the client actually uses (Claude -> api.anthropic.com, Codex -> chatgpt.com/backend-api/codex, Gemini -> generativelanguage). Non-official providers with a missing base_url still error to avoid a false green light. Claude Desktop Official is native 1P mode (talks to claude.ai, cc-switch not in the request path, no reliable endpoint) so its button stays hidden. Slim StreamCheckConfig and per-provider testConfig to timeout/threshold/retries (drop test model + prompt); sync zh/en/ja/zh-TW. Retain the now-unused anthropic_to_openai/anthropic_to_gemini transform utilities and their test suites. --- src-tauri/src/commands/stream_check.rs | 181 +- src-tauri/src/provider.rs | 8 +- src-tauri/src/proxy/providers/mod.rs | 3 +- src-tauri/src/proxy/providers/transform.rs | 4 + .../src/proxy/providers/transform_gemini.rs | 5 + src-tauri/src/services/stream_check.rs | 2365 +++-------------- src/components/providers/ProviderActions.tsx | 6 +- src/components/providers/ProviderCard.tsx | 13 +- src/components/providers/ProviderList.tsx | 51 +- .../forms/ProviderAdvancedConfig.tsx | 56 +- src/components/usage/ModelTestConfigPanel.tsx | 105 +- src/hooks/useStreamCheck.ts | 100 +- src/i18n/locales/en.json | 6 + src/i18n/locales/ja.json | 6 + src/i18n/locales/zh-TW.json | 6 + src/i18n/locales/zh.json | 6 + src/lib/api/model-test.ts | 17 +- src/types.ts | 6 +- 18 files changed, 542 insertions(+), 2402 deletions(-) diff --git a/src-tauri/src/commands/stream_check.rs b/src-tauri/src/commands/stream_check.rs index 9fc605775..71759ce01 100644 --- a/src-tauri/src/commands/stream_check.rs +++ b/src-tauri/src/commands/stream_check.rs @@ -1,4 +1,7 @@ -//! 流式健康检查命令 +//! 供应商连通性检查命令 +//! +//! 注意:本检查只探测 base_url 是否可达,不发真实大模型请求,也不触碰故障转移 +//! 熔断器(熔断器由真实转发流量驱动)。详见 `services::stream_check`。 use crate::app_config::AppType; use crate::commands::copilot::CopilotAuthState; @@ -10,7 +13,7 @@ use crate::store::AppState; use std::collections::HashSet; use tauri::State; -/// 流式健康检查(单个供应商) +/// 连通性检查(单个供应商) #[tauri::command] pub async fn stream_check_provider( state: State<'_, AppState>, @@ -25,25 +28,12 @@ pub async fn stream_check_provider( .get(&provider_id) .ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?; - let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?; + // Copilot 端点是动态的(随 OAuth token 解析),需预先取出 host 再探测; + // 其余供应商传 None,由服务层从 settings_config 提取 base_url。无需鉴权。 let base_url_override = resolve_copilot_base_url_override(provider, &copilot_state).await?; - let claude_api_format_override = resolve_claude_api_format_override( - &app_type, - provider, - &config, - &copilot_state, - auth_override.as_ref(), - ) - .await?; - let result = StreamCheckService::check_with_retry( - &app_type, - provider, - &config, - auth_override, - base_url_override, - claude_api_format_override, - ) - .await?; + let result = + StreamCheckService::check_with_retry(&app_type, provider, &config, base_url_override) + .await?; // 记录日志 let _ = @@ -54,7 +44,7 @@ pub async fn stream_check_provider( Ok(result) } -/// 批量流式健康检查 +/// 批量连通性检查 #[tauri::command] pub async fn stream_check_all_providers( state: State<'_, AppState>, @@ -65,7 +55,6 @@ pub async fn stream_check_all_providers( let config = state.db.get_stream_check_config()?; let providers = state.db.get_all_providers(app_type.as_str())?; - let mut results = Vec::new(); let allowed_ids: Option> = if proxy_targets_only { let mut ids = HashSet::new(); if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) { @@ -81,6 +70,7 @@ pub async fn stream_check_all_providers( None }; + let mut results = Vec::new(); for (id, provider) in providers { if let Some(ids) = &allowed_ids { if !ids.contains(&id) { @@ -88,54 +78,22 @@ pub async fn stream_check_all_providers( } } - let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?; let base_url_override = resolve_copilot_base_url_override(&provider, &copilot_state).await?; - let claude_api_format_override = resolve_claude_api_format_override( - &app_type, - &provider, - &config, - &copilot_state, - auth_override.as_ref(), - ) - .await - .unwrap_or_else(|e| { - log::warn!( - "[StreamCheck] Failed to resolve Claude API format override for {}: {}", - provider.id, - e - ); - None - }); - let result = StreamCheckService::check_with_retry( - &app_type, - &provider, - &config, - auth_override, - base_url_override, - claude_api_format_override, - ) - .await - .unwrap_or_else(|e| { - let (http_status, message) = match &e { - crate::error::AppError::HttpStatus { status, .. } => ( - Some(*status), - StreamCheckService::classify_http_status(*status).to_string(), - ), - _ => (None, e.to_string()), - }; - StreamCheckResult { - status: HealthStatus::Failed, - success: false, - message, - response_time_ms: None, - http_status, - model_used: String::new(), - tested_at: chrono::Utc::now().timestamp(), - retry_count: 0, - error_category: None, - } - }); + let result = + StreamCheckService::check_with_retry(&app_type, &provider, &config, base_url_override) + .await + .unwrap_or_else(|e| StreamCheckResult { + status: HealthStatus::Failed, + success: false, + message: e.to_string(), + response_time_ms: None, + http_status: None, + model_used: String::new(), + tested_at: chrono::Utc::now().timestamp(), + retry_count: 0, + error_category: None, + }); let _ = state .db @@ -147,13 +105,13 @@ pub async fn stream_check_all_providers( Ok(results) } -/// 获取流式检查配置 +/// 获取连通性检查配置 #[tauri::command] pub fn get_stream_check_config(state: State<'_, AppState>) -> Result { state.db.get_stream_check_config() } -/// 保存流式检查配置 +/// 保存连通性检查配置 #[tauri::command] pub fn save_stream_check_config( state: State<'_, AppState>, @@ -162,39 +120,8 @@ pub fn save_stream_check_config( state.db.save_stream_check_config(&config) } -async fn resolve_copilot_auth_override( - provider: &crate::provider::Provider, - copilot_state: &State<'_, CopilotAuthState>, -) -> Result, AppError> { - let is_copilot = is_copilot_provider(provider); - - if !is_copilot { - return Ok(None); - } - - let auth_manager = copilot_state.0.read().await; - let account_id = provider - .meta - .as_ref() - .and_then(|meta| meta.managed_account_id_for("github_copilot")); - - let token = match account_id.as_deref() { - Some(id) => auth_manager - .get_valid_token_for_account(id) - .await - .map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?, - None => auth_manager - .get_valid_token() - .await - .map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?, - }; - - Ok(Some(crate::proxy::providers::AuthInfo::new( - token, - crate::proxy::providers::AuthStrategy::GitHubCopilot, - ))) -} - +/// Copilot 供应商的 base_url 需要从 OAuth 管理器动态解析(按账号或默认端点)。 +/// `is_full_url` 的供应商已是完整地址,无需解析。 async fn resolve_copilot_base_url_override( provider: &crate::provider::Provider, copilot_state: &State<'_, CopilotAuthState>, @@ -238,54 +165,6 @@ fn is_copilot_provider(provider: &crate::provider::Provider) -> bool { .unwrap_or(false) } -async fn resolve_claude_api_format_override( - app_type: &AppType, - provider: &crate::provider::Provider, - config: &StreamCheckConfig, - copilot_state: &State<'_, CopilotAuthState>, - auth_override: Option<&crate::proxy::providers::AuthInfo>, -) -> Result, AppError> { - if *app_type != AppType::Claude { - return Ok(None); - } - - let is_copilot = auth_override - .map(|auth| auth.strategy == crate::proxy::providers::AuthStrategy::GitHubCopilot) - .unwrap_or(false); - if !is_copilot { - return Ok(None); - } - - let model_id = StreamCheckService::resolve_effective_test_model(app_type, provider, config); - let auth_manager = copilot_state.0.read().await; - let account_id = provider - .meta - .as_ref() - .and_then(|meta| meta.managed_account_id_for("github_copilot")); - - let vendor_result = match account_id.as_deref() { - Some(id) => { - auth_manager - .get_model_vendor_for_account(id, &model_id) - .await - } - None => auth_manager.get_model_vendor(&model_id).await, - }; - - let api_format = match vendor_result { - Ok(Some(vendor)) if vendor.eq_ignore_ascii_case("openai") => "openai_responses", - Ok(Some(_)) | Ok(None) => "openai_chat", - Err(err) => { - log::warn!( - "[StreamCheck] Failed to resolve Copilot model vendor for {model_id}: {err}. Falling back to chat/completions" - ); - "openai_chat" - } - }; - - Ok(Some(api_format.to_string())) -} - #[cfg(test)] mod tests { use super::is_copilot_provider; diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index c99758ebe..648575de0 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -287,21 +287,15 @@ pub struct UsageResult { pub error: Option, } -/// 供应商单独的模型测试配置 +/// 供应商单独的连通检测配置 #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProviderTestConfig { /// 是否启用单独配置(false 时使用全局配置) #[serde(default)] pub enabled: bool, - /// 测试用的模型名称(覆盖全局配置) - #[serde(rename = "testModel", skip_serializing_if = "Option::is_none")] - pub test_model: Option, /// 超时时间(秒) #[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")] pub timeout_secs: Option, - /// 测试提示词 - #[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")] - pub test_prompt: Option, /// 降级阈值(毫秒) #[serde( rename = "degradedThresholdMs", diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index e9dae2487..7f3d8e1ac 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -48,8 +48,7 @@ pub use claude::{ pub use codex::CodexAdapter; pub use codex::{ apply_codex_chat_upstream_model, codex_provider_upstream_model, - codex_provider_uses_chat_completions, is_origin_only_url, resolve_codex_chat_reasoning_config, - should_convert_codex_responses_to_chat, + resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_chat, }; pub use gemini::GeminiAdapter; diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 9b0dd8f72..c13b26cd1 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -112,6 +112,10 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> { } /// Anthropic 请求 → OpenAI Chat Completions 请求 +/// +/// 转换工具库 API:当前无生产调用方(连通性检查不再发真实请求,曾是其唯一 crate 内 +/// 消费者),但保留其转换逻辑与下方测试套件,供代理转换路径复用 / 未来接线。 +#[allow(dead_code)] pub fn anthropic_to_openai(body: Value) -> Result { anthropic_to_openai_with_reasoning_content(body, false) } diff --git a/src-tauri/src/proxy/providers/transform_gemini.rs b/src-tauri/src/proxy/providers/transform_gemini.rs index d386d57a5..76601c3e1 100644 --- a/src-tauri/src/proxy/providers/transform_gemini.rs +++ b/src-tauri/src/proxy/providers/transform_gemini.rs @@ -39,6 +39,11 @@ pub(crate) fn is_synthesized_tool_call_id(id: &str) -> bool { id.starts_with(SYNTHESIZED_ID_PREFIX) } +/// Anthropic 请求 → Gemini 原生请求。 +/// +/// 转换工具库 API:当前无生产调用方(连通性检查不再发真实请求,曾是其唯一 crate 内 +/// 消费者),但保留其转换逻辑与下方测试套件,供代理转换路径复用 / 未来接线。 +#[allow(dead_code)] pub fn anthropic_to_gemini(body: Value) -> Result { anthropic_to_gemini_with_shadow(body, None, None, None) } diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index c7a1a0bb2..8fa55a2ff 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -1,24 +1,30 @@ -//! 流式健康检查服务 +//! 供应商连通性检查服务(reachability) //! -//! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。 +//! 仅探测供应商 `base_url` 是否可达,**不发送真实大模型请求**: +//! - 收到任意 HTTP 响应(200/4xx/5xx)即判定"可达"(端口通、网关存活); +//! - 仅 DNS / 连接被拒 / TLS / 超时等网络级错误判定"不可达"; +//! - 延迟 = 收到响应头的耗时(TTFB,真实往返)。 +//! +//! ## 设计取舍:可达 ≠ 配置正确 +//! +//! 本检查刻意不验证鉴权或模型,因此不会被第三方供应商的鉴权拦截 / 模型校验 +//! 误判为"不可用"。代价是它无法告诉你鉴权对不对、模型存不存在。 +//! +//! ## 与故障转移的关系(重要不变量) +//! +//! 连通性检查 **绝不** 触碰故障转移熔断器:一个返回 403/401 的供应商在本检查里 +//! 算"可达",但它对真实流量是坏的。熔断器只由 `proxy/forwarder.rs` 转发真实流量 +//! 的成败驱动(被动)。两者职责分离——可达性回答"能不能到",真实流量回答"能不能用"。 -use futures::StreamExt; +use reqwest::header::HeaderValue; use reqwest::Client; use serde::{Deserialize, Serialize}; -use serde_json::json; use std::time::Instant; use crate::app_config::AppType; use crate::error::AppError; use crate::provider::Provider; -use crate::proxy::gemini_url::{normalize_gemini_model_id, resolve_gemini_native_url}; -use crate::proxy::providers::copilot_auth; -use crate::proxy::providers::transform::anthropic_to_openai; -use crate::proxy::providers::transform_gemini::anthropic_to_gemini; -use crate::proxy::providers::transform_responses::anthropic_to_responses; -use crate::proxy::providers::{ - get_adapter, AuthInfo, AuthStrategy, ClaudeAdapter, ProviderAdapter, -}; +use crate::proxy::providers::{get_adapter, ClaudeAdapter, ProviderAdapter}; /// 健康状态枚举 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -29,43 +35,31 @@ pub enum HealthStatus { Failed, } -/// 流式检查配置 +/// 连通性检查配置 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StreamCheckConfig { + /// 单次探测超时(秒) pub timeout_secs: u64, + /// 超时类失败的最大重试次数 pub max_retries: u32, + /// 降级阈值(毫秒):可达但 TTFB 超过该值判定为"较慢" pub degraded_threshold_ms: u64, - /// Claude 测试模型 - pub claude_model: String, - /// Codex 测试模型 - pub codex_model: String, - /// Gemini 测试模型 - pub gemini_model: String, - /// 检查提示词 - #[serde(default = "default_test_prompt")] - pub test_prompt: String, -} - -fn default_test_prompt() -> String { - "Who are you?".to_string() } impl Default for StreamCheckConfig { fn default() -> Self { + // 可达性探测打的是 base_url 的小请求(仅读响应头),不等待模型生成, + // 因此超时与阈值都远小于旧的真实请求检查(45s / 6000ms)。 Self { - timeout_secs: 45, - max_retries: 2, - degraded_threshold_ms: 6000, - claude_model: "claude-haiku-4-5-20251001".to_string(), - codex_model: "gpt-5.5@low".to_string(), - gemini_model: "gemini-3.5-flash".to_string(), - test_prompt: default_test_prompt(), + timeout_secs: 8, + max_retries: 1, + degraded_threshold_ms: 1500, } } } -/// 流式检查结果 +/// 连通性检查结果 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StreamCheckResult { @@ -74,70 +68,59 @@ pub struct StreamCheckResult { pub message: String, pub response_time_ms: Option, pub http_status: Option, + /// 保留字段以兼容 `stream_check_logs` 表结构;连通性检查恒为空串。 pub model_used: String, pub tested_at: i64, pub retry_count: u32, - /// 细粒度错误分类(如 "modelNotFound"),前端据此渲染专门的文案 + /// 细粒度错误分类;连通性检查不再细分,恒为 None。 #[serde(skip_serializing_if = "Option::is_none")] pub error_category: Option, } -/// 流式健康检查服务 +/// 连通性检查服务 pub struct StreamCheckService; impl StreamCheckService { - /// 执行流式健康检查(带重试) + /// 执行连通性检查(仅对超时类失败重试)。 /// - /// 如果 Provider 配置了单独的测试配置(meta.testConfig),则使用该配置覆盖全局配置 + /// `base_url_override`:用于 Copilot 等需要从 OAuth 管理器动态解析端点的供应商, + /// 由命令层预先解析后传入;其余供应商传 `None`,由本服务从 `settings_config` 提取。 pub async fn check_with_retry( app_type: &AppType, provider: &Provider, config: &StreamCheckConfig, - auth_override: Option, base_url_override: Option, - claude_api_format_override: Option, ) -> Result { - // 合并供应商单独配置和全局配置 - let effective_config = Self::merge_provider_config(provider, config); - let mut last_result = None; + let effective = Self::merge_provider_config(provider, config); - for attempt in 0..=effective_config.max_retries { + let mut last_result: Option = None; + for attempt in 0..=effective.max_retries { + let start = Instant::now(); let result = Self::check_once( app_type, provider, - &effective_config, - auth_override.clone(), + &effective, base_url_override.clone(), - claude_api_format_override.clone(), + start, ) - .await; + .await?; - match &result { - Ok(r) if r.success => { - return Ok(StreamCheckResult { - retry_count: attempt, - ..r.clone() - }); - } - Ok(r) => { - // 失败但非异常,判断是否重试 - if Self::should_retry(&r.message) && attempt < effective_config.max_retries { - last_result = Some(r.clone()); - continue; - } - return Ok(StreamCheckResult { - retry_count: attempt, - ..r.clone() - }); - } - Err(e) => { - if Self::should_retry(&e.to_string()) && attempt < effective_config.max_retries - { - continue; - } - return Err(AppError::Message(e.to_string())); - } + if result.success { + return Ok(StreamCheckResult { + retry_count: attempt, + ..result + }); } + + // 仅超时 / abort 类网络抖动值得重试;连接被拒、DNS 失败等立即返回。 + if Self::should_retry(&result.message) && attempt < effective.max_retries { + last_result = Some(result); + continue; + } + return Ok(StreamCheckResult { + retry_count: attempt, + ..result + }); } Ok(last_result.unwrap_or_else(|| StreamCheckResult { @@ -148,872 +131,241 @@ impl StreamCheckService { http_status: None, model_used: String::new(), tested_at: chrono::Utc::now().timestamp(), - retry_count: effective_config.max_retries, + retry_count: effective.max_retries, error_category: None, })) } - /// 合并供应商单独配置和全局配置 - /// - /// 如果供应商配置了 meta.testConfig 且 enabled 为 true,则使用供应商配置覆盖全局配置 - fn merge_provider_config( - provider: &Provider, - global_config: &StreamCheckConfig, - ) -> StreamCheckConfig { - let test_config = provider + /// 合并供应商单独配置(`meta.testConfig`,仅当 `enabled`)与全局配置。 + fn merge_provider_config(provider: &Provider, global: &StreamCheckConfig) -> StreamCheckConfig { + let tc = provider .meta .as_ref() .and_then(|m| m.test_config.as_ref()) .filter(|tc| tc.enabled); - match test_config { + match tc { Some(tc) => StreamCheckConfig { - timeout_secs: tc.timeout_secs.unwrap_or(global_config.timeout_secs), - max_retries: tc.max_retries.unwrap_or(global_config.max_retries), + timeout_secs: tc.timeout_secs.unwrap_or(global.timeout_secs), + max_retries: tc.max_retries.unwrap_or(global.max_retries), degraded_threshold_ms: tc .degraded_threshold_ms - .unwrap_or(global_config.degraded_threshold_ms), - claude_model: tc - .test_model - .clone() - .unwrap_or_else(|| global_config.claude_model.clone()), - codex_model: tc - .test_model - .clone() - .unwrap_or_else(|| global_config.codex_model.clone()), - gemini_model: tc - .test_model - .clone() - .unwrap_or_else(|| global_config.gemini_model.clone()), - test_prompt: tc - .test_prompt - .clone() - .unwrap_or_else(|| global_config.test_prompt.clone()), + .unwrap_or(global.degraded_threshold_ms), }, - None => global_config.clone(), + None => global.clone(), } } - /// 单次流式检查 + /// 单次连通性探测。 async fn check_once( app_type: &AppType, provider: &Provider, config: &StreamCheckConfig, - auth_override: Option, base_url_override: Option, - claude_api_format_override: Option, + start: Instant, ) -> Result { - let start = Instant::now(); - - // OpenCode / OpenClaw 的 settings_config 结构与 Claude/Codex/Gemini 不同 - // (baseUrl / apiKey 直接作为根字段而非嵌套在 env),并且协议由 `api` - // 或 `npm` 字段显式指定。它们不走 get_adapter 路径,而是直接分发。 - if matches!( - app_type, - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes - ) { - return Self::check_once_without_adapter(app_type, provider, config, start).await; - } - - let adapter: Box = if matches!(app_type, AppType::ClaudeDesktop) { - Box::new(ClaudeAdapter::new()) - } else { - get_adapter(app_type) - }; - let base_url = match base_url_override { - Some(base_url) => base_url, - None => adapter - .extract_base_url(provider) - .map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?, + Some(b) => b, + None => Self::resolve_base_url(app_type, provider)?, }; - let auth = auth_override - .or_else(|| adapter.extract_auth(provider)) - .ok_or_else(|| AppError::Message("API Key not found".to_string()))?; - - // 获取 HTTP 客户端 let client = crate::proxy::http_client::get(); - let request_timeout = std::time::Duration::from_secs(config.timeout_secs); - - let model_to_test = Self::resolve_test_model(app_type, provider, config); - let test_prompt = &config.test_prompt; - - let result = match app_type { - AppType::Claude | AppType::ClaudeDesktop => { - Self::check_claude_stream( - &client, - &base_url, - &auth, - &model_to_test, - test_prompt, - request_timeout, - provider, - claude_api_format_override.as_deref(), - None, - ) - .await - } - AppType::Codex => { - Self::check_codex_stream( - &client, - &base_url, - &auth, - &model_to_test, - test_prompt, - request_timeout, - provider, - ) - .await - } - AppType::Gemini => { - Self::check_gemini_stream( - &client, - &base_url, - &auth, - &model_to_test, - test_prompt, - request_timeout, - None, - ) - .await - } - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { - // Already handled via early dispatch above - unreachable!("OpenCode/OpenClaw/Hermes 已通过 check_once_without_adapter 处理") - } - }; + let timeout = std::time::Duration::from_secs(config.timeout_secs); + let ua = Self::custom_user_agent(provider); + let result = Self::probe_reachability(&client, &base_url, timeout, ua).await; let response_time = start.elapsed().as_millis() as u64; - Ok(Self::build_stream_check_result( + Ok(Self::build_result( result, response_time, config.degraded_threshold_ms, - &model_to_test, )) } - /// Provider 级自定义 User-Agent(`meta.customUserAgent`),经 `parse_custom_user_agent` 校验。 + /// 解析供应商 `base_url`。 /// - /// 与 forwarder 转发路径(`RequestForwarder::forward`)、model_fetch 共用单一口径:trim、 - /// 空串视为未设置、**非法值静默忽略**(返回 `None`,不报错)。Stream Check 必须复用同一个 - /// UA 去探测,否则会与真实流量用不同的 User-Agent(例如 Kimi Coding Plan 的 UA 白名单), - /// 导致"检测失败但代理可用"或反之的分歧——非法 UA 时尤甚(转发静默丢弃、检测却会因 - /// reqwest 非法头在 `.send()` 报错)。 - fn custom_user_agent(provider: &Provider) -> Option { + /// 连通性探测只需打到 base(origin 或用户配置的 base 路径)即可——任何 HTTP + /// 响应都证明端口可达,因此无需像旧的真实请求检查那样解析具体 API 路径 + /// (`/v1/messages` vs `/chat/completions` vs `:streamGenerateContent`)。 + fn resolve_base_url(app_type: &AppType, provider: &Provider) -> Result { + match app_type { + // 累加模式应用的 settings_config 结构与 Claude/Codex/Gemini 不同, + // 不走 adapter,直接按各自约定提取 base_url。 + AppType::OpenCode => { + let npm = Self::extract_opencode_npm(provider); + Self::resolve_opencode_base_url(provider, npm.as_deref()) + } + AppType::OpenClaw => Self::extract_openclaw_base_url(provider), + AppType::Hermes => Self::extract_hermes_base_url(provider), + AppType::ClaudeDesktop => Self::base_url_or_official_fallback( + ClaudeAdapter::new().extract_base_url(provider), + app_type, + provider, + ), + _ => Self::base_url_or_official_fallback( + get_adapter(app_type).extract_base_url(provider), + app_type, + provider, + ), + } + } + + /// adapter 提取成功(且非空)→ 直接用;否则: + /// - 官方供应商(`category == "official"`)base_url 故意留空——客户端在 base_url + /// 未设时默认走官方端点,因此回退到官方默认端点,使可达性探测探的就是客户端 + /// 实际会连的地址; + /// - 非官方且提取失败 → 保留"缺 base_url"错误(真实配置缺失;若也回退到官方端点, + /// 会给忘填 base_url 的第三方中转误显绿灯)。 + fn base_url_or_official_fallback( + extracted: Result, + app_type: &AppType, + provider: &Provider, + ) -> Result { + if let Ok(url) = &extracted { + if !url.trim().is_empty() { + return Ok(url.clone()); + } + } + + if Self::is_official(provider) { + if let Some(endpoint) = Self::official_fallback_endpoint(app_type) { + return Ok(endpoint.to_string()); + } + } + + match extracted { + Ok(_) => Err(AppError::Message("缺少 base_url".to_string())), + Err(e) => Err(AppError::Message(format!( + "Failed to extract base_url: {e}" + ))), + } + } + + /// 官方供应商判定:单一事实源是 `category == "official"`(与前端一致); + /// 内置官方种子入库时由 `init_default_official_providers` 标注该值。 + fn is_official(provider: &Provider) -> bool { + provider.category.as_deref() == Some("official") + } + + /// 官方模式的默认探测端点——即客户端在 base_url 未设时实际会连的官方地址。 + /// Codex 官方走 ChatGPT Plus/Pro OAuth 后端,故指向 chatgpt.com 而非 api.openai.com。 + /// + /// **ClaudeDesktop 故意返回 None**:Claude Desktop 官方 = 原生 1P 模式(切换时写 + /// `deploymentMode=1p`、删 3P profile,见 `claude_desktop_config.rs`),应用走 claude.ai + /// 消费端后端而非 api.anthropic.com 开发者 API,cc-switch 不在请求路径上,也没有可靠的 + /// 1P 端点常量可探。回退到 api.anthropic.com 会探错目标(误报可达/不可达),故不回退 + /// (前端同步隐藏其检测按钮)。 + fn official_fallback_endpoint(app_type: &AppType) -> Option<&'static str> { + match app_type { + AppType::Claude => Some("https://api.anthropic.com"), + AppType::Codex => Some("https://chatgpt.com/backend-api/codex"), + AppType::Gemini => Some("https://generativelanguage.googleapis.com"), + AppType::ClaudeDesktop | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + None + } + } + } + + /// 轻量可达性探测:GET `base_url`,收到任意 HTTP 响应即可达。 + /// + /// - `send()` 在收到响应头时即返回,故计时天然是 TTFB;不读 body。 + /// - reqwest 对任何 HTTP 状态码都返回 `Ok`,只有网络级错误进 `Err`—— + /// 这正是"任何响应都算可达、只有连不上才算失败"的语义。 + async fn probe_reachability( + client: &Client, + base_url: &str, + timeout: std::time::Duration, + custom_ua: Option, + ) -> Result { + let url = base_url.trim(); + if url.is_empty() { + return Err(AppError::Message("base_url 为空".to_string())); + } + + let mut req = client + .get(url) + .timeout(timeout) + .header("accept", "*/*") + .header("accept-encoding", "identity"); + // 复用供应商自定义 UA(部分网关按 UA 白名单放行),与转发路径口径一致。 + if let Some(ua) = custom_ua { + req = req.header("user-agent", ua); + } + + match req.send().await { + Ok(resp) => Ok(resp.status().as_u16()), + Err(e) => Err(Self::map_request_error(e)), + } + } + + /// 将探测原始结果包装成 `StreamCheckResult`。 + fn build_result( + result: Result, + response_time: u64, + degraded_threshold_ms: u64, + ) -> StreamCheckResult { + let tested_at = chrono::Utc::now().timestamp(); + match result { + Ok(status) => StreamCheckResult { + status: Self::determine_status(response_time, degraded_threshold_ms), + success: true, + message: "Reachable".to_string(), + response_time_ms: Some(response_time), + http_status: Some(status), + model_used: String::new(), + tested_at, + retry_count: 0, + error_category: None, + }, + Err(e) => StreamCheckResult { + status: HealthStatus::Failed, + success: false, + message: e.to_string(), + response_time_ms: Some(response_time), + http_status: None, + model_used: String::new(), + tested_at, + retry_count: 0, + error_category: None, + }, + } + } + + fn determine_status(latency_ms: u64, threshold: u64) -> HealthStatus { + if latency_ms <= threshold { + HealthStatus::Operational + } else { + HealthStatus::Degraded + } + } + + fn should_retry(msg: &str) -> bool { + let lower = msg.to_lowercase(); + lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out") + } + + fn map_request_error(e: reqwest::Error) -> AppError { + if e.is_timeout() { + AppError::Message("Request timeout".to_string()) + } else if e.is_connect() { + AppError::Message(format!("Connection failed: {e}")) + } else { + AppError::Message(e.to_string()) + } + } + + /// Provider 级自定义 User-Agent(`meta.customUserAgent`),与转发路径共用单一口径: + /// trim、空串视为未设置、非法值静默忽略(返回 `None`)。 + fn custom_user_agent(provider: &Provider) -> Option { provider .meta .as_ref() .and_then(|meta| meta.custom_user_agent_header().ok().flatten()) } - /// Claude 流式检查 - /// - /// 根据供应商的 api_format 选择请求格式: - /// - "anthropic" (默认): Anthropic Messages API (/v1/messages) - /// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions) - /// - "openai_responses": OpenAI Responses API (/v1/responses) - /// - "gemini_native": Gemini Native streamGenerateContent - /// - /// `extra_headers` 是一个可选的供应商级自定义 header 集合(从 OpenClaw - /// 的 `settings_config.headers` 或 OpenCode 的 `settings_config.options.headers` - /// 读取),在所有内置 header 之后追加,用于覆盖或补充(例如自定义 User-Agent)。 - #[allow(clippy::too_many_arguments)] - async fn check_claude_stream( - client: &Client, - base_url: &str, - auth: &AuthInfo, - model: &str, - test_prompt: &str, - timeout: std::time::Duration, - provider: &Provider, - claude_api_format_override: Option<&str>, - extra_headers: Option<&serde_json::Map>, - ) -> Result<(u16, String), AppError> { - let base = base_url.trim_end_matches('/'); - let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot; - - // Detect api_format: meta.api_format > settings_config.api_format > default "anthropic" - let api_format = provider - .meta - .as_ref() - .and_then(|m| m.api_format.as_deref()) - .or_else(|| { - provider - .settings_config - .get("api_format") - .and_then(|v| v.as_str()) - }) - .unwrap_or("anthropic"); - - let effective_api_format = claude_api_format_override.unwrap_or(api_format); - - let is_full_url = provider - .meta - .as_ref() - .and_then(|meta| meta.is_full_url) - .unwrap_or(false); - let is_openai_chat = effective_api_format == "openai_chat"; - let is_openai_responses = effective_api_format == "openai_responses"; - let is_gemini_native = effective_api_format == "gemini_native"; - let url = Self::resolve_claude_stream_url( - base, - auth.strategy, - effective_api_format, - is_full_url, - model, - ); - - let max_tokens = if is_openai_responses { 16 } else { 1 }; - - // Build from Anthropic-native shape first, then convert for configured targets. - let anthropic_body = json!({ - "model": model, - "max_tokens": max_tokens, - "messages": [{ "role": "user", "content": test_prompt }], - "stream": true - }); - // Codex OAuth (ChatGPT Plus/Pro 反代) 需要 store:false + include 标记, - // 否则 Stream Check 会和生产路径一样被服务端 400 拒绝。 - let is_codex_oauth = provider.is_codex_oauth(); - let codex_fast_mode = provider.codex_fast_mode_enabled(); - - let body = if is_openai_responses { - anthropic_to_responses( - anthropic_body, - Some(&provider.id), - is_codex_oauth, - codex_fast_mode, - ) - .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? - } else if is_gemini_native { - anthropic_to_gemini(anthropic_body) - .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? - } else if is_openai_chat { - anthropic_to_openai(anthropic_body) - .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? - } else { - anthropic_body - }; - - let mut request_builder = client.post(&url); - - if is_github_copilot { - // 生成请求追踪 ID - let request_id = uuid::Uuid::new_v4().to_string(); - request_builder = request_builder - .header("authorization", format!("Bearer {}", auth.api_key)) - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .header("accept-encoding", "identity") - .header("user-agent", copilot_auth::COPILOT_USER_AGENT) - .header("editor-version", copilot_auth::COPILOT_EDITOR_VERSION) - .header( - "editor-plugin-version", - copilot_auth::COPILOT_PLUGIN_VERSION, - ) - .header( - "copilot-integration-id", - copilot_auth::COPILOT_INTEGRATION_ID, - ) - .header("x-github-api-version", copilot_auth::COPILOT_API_VERSION) - // 260401 新增copilot 的关键 headers - .header("openai-intent", "conversation-agent") - .header("x-initiator", "user") - .header("x-interaction-type", "conversation-agent") - .header("x-vscode-user-agent-library-version", "electron-fetch") - .header("x-request-id", &request_id) - .header("x-agent-task-id", &request_id); - } else if is_gemini_native { - request_builder = match auth.strategy { - AuthStrategy::GoogleOAuth => { - let token = auth.access_token.as_ref().unwrap_or(&auth.api_key); - request_builder - .header("authorization", format!("Bearer {token}")) - .header("x-goog-api-client", "GeminiCLI/1.0") - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .header("accept-encoding", "identity") - } - _ => request_builder - .header("x-goog-api-key", &auth.api_key) - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .header("accept-encoding", "identity"), - }; - } else if is_openai_chat || is_openai_responses { - // OpenAI-compatible targets: Bearer auth + SSE headers only - request_builder = request_builder - .header("authorization", format!("Bearer {}", auth.api_key)) - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .header("accept-encoding", "identity"); - } else { - // Anthropic native: full Claude CLI headers - let os_name = Self::get_os_name(); - let arch_name = Self::get_arch_name(); - - // 鉴权头复用 ClaudeAdapter::get_auth_headers,与代理路径(forwarder)保持单一真理来源。 - // - AuthStrategy::Anthropic → x-api-key - // - AuthStrategy::ClaudeAuth → Authorization: Bearer - // - AuthStrategy::Bearer → Authorization: Bearer - // 避免之前"无条件 Bearer + 条件 x-api-key 双发"导致的假阴性 / auth conflict。 - let auth_headers = ClaudeAdapter::new() - .get_auth_headers(auth) - .map_err(|e| AppError::Message(format!("stream check 构造鉴权头失败: {e}")))?; - for (name, value) in auth_headers { - request_builder = request_builder.header(name, value); - } - - request_builder = request_builder - // Anthropic required headers - .header("anthropic-version", "2023-06-01") - .header( - "anthropic-beta", - "claude-code-20250219,interleaved-thinking-2025-05-14", - ) - .header("anthropic-dangerous-direct-browser-access", "true") - // Content type headers - .header("content-type", "application/json") - .header("accept", "application/json") - .header("accept-encoding", "identity") - .header("accept-language", "*") - // Client identification headers - .header("user-agent", "claude-cli/2.1.2 (external, cli)") - .header("x-app", "cli") - // x-stainless SDK headers (dynamic local system info) - .header("x-stainless-lang", "js") - .header("x-stainless-package-version", "0.70.0") - .header("x-stainless-os", os_name) - .header("x-stainless-arch", arch_name) - .header("x-stainless-runtime", "node") - .header("x-stainless-runtime-version", "v22.20.0") - .header("x-stainless-retry-count", "0") - .header("x-stainless-timeout", "600") - // Other headers - .header("sec-fetch-mode", "cors"); - } - - // 供应商自定义 headers 最后追加,允许覆盖内置默认值(例如 user-agent) - if let Some(headers) = extra_headers { - for (key, value) in headers { - if let Some(v) = value.as_str() { - request_builder = request_builder.header(key.as_str(), v); - } - } - } - - // Provider 级自定义 User-Agent(meta.customUserAgent)覆盖默认 UA,与 forwarder - // 转发路径口径一致;Copilot 指纹 UA 不可被覆盖。 - if !is_github_copilot { - if let Some(ua) = Self::custom_user_agent(provider) { - request_builder = request_builder.header("user-agent", ua); - } - } - - let response = request_builder - .timeout(timeout) - .json(&body) - .send() - .await - .map_err(Self::map_request_error)?; - - let status = response.status().as_u16(); - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - return Err(Self::http_status_error(status, error_text)); - } - - // 流式读取:只需首个 chunk - let mut stream = response.bytes_stream(); - if let Some(chunk) = stream.next().await { - match chunk { - Ok(_) => Ok((status, model.to_string())), - Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))), - } - } else { - Err(AppError::Message("No response data received".to_string())) - } - } - - /// Codex 流式检查 - /// - /// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API) - async fn check_codex_stream( - client: &Client, - base_url: &str, - auth: &AuthInfo, - model: &str, - test_prompt: &str, - timeout: std::time::Duration, - provider: &Provider, - ) -> Result<(u16, String), AppError> { - let is_full_url = provider - .meta - .as_ref() - .and_then(|meta| meta.is_full_url) - .unwrap_or(false); - // 当 provider 的 api_format 标记为 openai_chat 时,上游不接受 Responses API; - // 必须改打 /chat/completions 并发送 Chat 格式 body,否则 Stream Check 与代理路径不一致, - // 会把"实际可用"的供应商误报为不可用(典型如 DeepSeek、MiniMax、Kimi 等 Chat 兼容厂商)。 - let uses_chat = crate::proxy::providers::codex_provider_uses_chat_completions(provider); - let urls = if uses_chat { - Self::resolve_codex_chat_stream_urls(base_url, is_full_url) - } else { - Self::resolve_codex_stream_urls(base_url, is_full_url) - }; - - // 解析模型名和推理等级 (支持 model@level 或 model#level 格式) - let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model); - - // 获取本地系统信息 - let os_name = Self::get_os_name(); - let arch_name = Self::get_arch_name(); - - // Provider 级自定义 User-Agent(meta.customUserAgent)覆盖默认 codex UA,与 forwarder - // 转发路径口径一致——否则 Stream Check 会用与真实流量不同的 UA 探测(如 Kimi UA 白名单)。 - let user_agent = Self::custom_user_agent(provider).unwrap_or_else(|| { - reqwest::header::HeaderValue::from_str(&format!( - "codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal" - )) - .unwrap_or_else(|_| reqwest::header::HeaderValue::from_static("codex_cli_rs/0.80.0")) - }); - - let mut body = if uses_chat { - // Chat Completions 请求体(与 transform_codex_chat::responses_to_chat_completions 对齐) - json!({ - "model": actual_model, - "messages": [{ "role": "user", "content": test_prompt }], - "max_tokens": 1, - "stream": true - }) - } else { - // Responses API 请求体格式 (input 必须是数组) - json!({ - "model": actual_model, - "input": [{ "role": "user", "content": test_prompt }], - "stream": true - }) - }; - - // Chat 路径只对 OpenAI o-series 透传 reasoning_effort,与 transform_codex_chat - // 一致;非 o-series(DeepSeek、Kimi 等)收到未知字段会 400。 - if let Some(effort) = reasoning_effort { - if uses_chat - && crate::proxy::providers::transform::supports_reasoning_effort(&actual_model) - { - body["reasoning_effort"] = json!(effort); - } else if !uses_chat { - body["reasoning"] = json!({ "effort": effort }); - } - } - - for (i, url) in urls.iter().enumerate() { - // 严格按照 Codex CLI 请求格式设置 headers - let response = client - .post(url) - .header("authorization", format!("Bearer {}", auth.api_key)) - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .header("accept-encoding", "identity") - .header("user-agent", user_agent.clone()) - .header("originator", "codex_cli_rs") - .timeout(timeout) - .json(&body) - .send() - .await - .map_err(Self::map_request_error)?; - - let status = response.status().as_u16(); - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - // 回退策略:仅当首选 URL 返回 404 时尝试下一个 - if i == 0 && status == 404 && urls.len() > 1 { - continue; - } - return Err(Self::http_status_error(status, error_text)); - } - - let mut stream = response.bytes_stream(); - if let Some(chunk) = stream.next().await { - match chunk { - Ok(_) => return Ok((status, actual_model)), - Err(e) => return Err(AppError::Message(format!("Stream read failed: {e}"))), - } - } - - return Err(AppError::Message("No response data received".to_string())); - } - - Err(AppError::Message( - "No valid Codex responses endpoint found".to_string(), - )) - } - - /// Gemini 流式检查 - /// - /// 使用 Gemini 原生 API 格式 (streamGenerateContent) - async fn check_gemini_stream( - client: &Client, - base_url: &str, - auth: &AuthInfo, - model: &str, - test_prompt: &str, - timeout: std::time::Duration, - extra_headers: Option<&serde_json::Map>, - ) -> Result<(u16, String), AppError> { - let base = base_url.trim_end_matches('/'); - // Strip `models/` resource-name prefix from the model id — see - // `normalize_gemini_model_id` for rationale. - let normalized_model = normalize_gemini_model_id(model); - // Gemini 原生 API: /v1beta/models/{model}:streamGenerateContent?alt=sse - // 智能处理 /v1beta 路径:如果 base_url 不包含版本路径,则添加 /v1beta - // alt=sse 参数使 API 返回 SSE 格式(text/event-stream)而非 JSON 数组 - let url = if base.contains("/v1beta") || base.contains("/v1/") { - format!("{base}/models/{normalized_model}:streamGenerateContent?alt=sse") - } else { - format!("{base}/v1beta/models/{normalized_model}:streamGenerateContent?alt=sse") - }; - - // Gemini 原生请求体格式 - let body = json!({ - "contents": [{ - "role": "user", - "parts": [{ "text": test_prompt }] - }] - }); - - let mut request_builder = client - .post(&url) - .header("x-goog-api-key", &auth.api_key) - .header("Content-Type", "application/json") - .header("Accept", "text/event-stream"); - - // 供应商自定义 headers 最后追加 - if let Some(headers) = extra_headers { - for (key, value) in headers { - if let Some(v) = value.as_str() { - request_builder = request_builder.header(key.as_str(), v); - } - } - } - - let response = request_builder - .timeout(timeout) - .json(&body) - .send() - .await - .map_err(Self::map_request_error)?; - - let status = response.status().as_u16(); - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - return Err(Self::http_status_error(status, error_text)); - } - - let mut stream = response.bytes_stream(); - if let Some(chunk) = stream.next().await { - match chunk { - Ok(_) => Ok((status, model.to_string())), - Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))), - } - } else { - Err(AppError::Message("No response data received".to_string())) - } - } - - /// OpenCode / OpenClaw 的独立分发入口(绕过 `get_adapter`) - /// - /// 这两个应用的 `settings_config` 与 Claude/Codex/Gemini 完全不同: - /// - OpenClaw: `{ baseUrl, apiKey, api, models: [...] }`,`api` 字段标识协议 - /// - OpenCode: `{ npm, options: { baseURL, apiKey }, models: {...} }`,`npm` 字段标识协议 - /// - /// 因此不能复用 `get_adapter`(会 fallback 到 CodexAdapter 而提取失败), - /// 改为独立解析 base_url/api_key/协议,再分发到现有的 check_*_stream 函数。 - async fn check_once_without_adapter( - app_type: &AppType, - provider: &Provider, - config: &StreamCheckConfig, - start: Instant, - ) -> Result { - // 获取 HTTP 客户端 - let client = crate::proxy::http_client::get(); - let request_timeout = std::time::Duration::from_secs(config.timeout_secs); - - let model_to_test = Self::resolve_test_model(app_type, provider, config); - let test_prompt = &config.test_prompt; - - let result = match app_type { - AppType::OpenClaw => { - Self::check_additive_app_stream( - &client, - provider, - &model_to_test, - test_prompt, - request_timeout, - ) - .await - } - AppType::OpenCode => { - Self::check_opencode_stream( - &client, - provider, - &model_to_test, - test_prompt, - request_timeout, - ) - .await - } - AppType::Hermes => { - Self::check_hermes_stream( - &client, - provider, - &model_to_test, - test_prompt, - request_timeout, - ) - .await - } - _ => unreachable!("check_once_without_adapter 只处理 OpenCode/OpenClaw/Hermes"), - }; - - let response_time = start.elapsed().as_millis() as u64; - Ok(Self::build_stream_check_result( - result, - response_time, - config.degraded_threshold_ms, - &model_to_test, - )) - } - - /// 将 check_*_stream 的原始结果包装成 StreamCheckResult - /// - /// 抽取自 check_once 的末尾逻辑,以便 OpenCode/OpenClaw 的独立分支复用。 - /// - /// `model_tested` 是本次探测使用的模型名,用于在失败场景下仍能把模型信息透传给前端, - /// 方便针对"模型不存在 / 已下架"这类错误渲染专门的提示。 - fn build_stream_check_result( - result: Result<(u16, String), AppError>, - response_time: u64, - degraded_threshold_ms: u64, - model_tested: &str, - ) -> StreamCheckResult { - let tested_at = chrono::Utc::now().timestamp(); - match result { - Ok((status_code, model)) => StreamCheckResult { - status: Self::determine_status(response_time, degraded_threshold_ms), - success: true, - message: "Check succeeded".to_string(), - response_time_ms: Some(response_time), - http_status: Some(status_code), - model_used: model, - tested_at, - retry_count: 0, - error_category: None, - }, - Err(e) => { - let (http_status, message, error_category) = match &e { - AppError::HttpStatus { status, body } => { - let category = Self::detect_error_category(*status, body); - ( - Some(*status), - Self::classify_http_status(*status).to_string(), - category.map(|s| s.to_string()), - ) - } - _ => (None, e.to_string(), None), - }; - StreamCheckResult { - status: HealthStatus::Failed, - success: false, - message, - response_time_ms: Some(response_time), - http_status, - model_used: model_tested.to_string(), - tested_at, - retry_count: 0, - error_category, - } - } - } - } - - /// 基于 HTTP 状态码和响应体识别细粒度错误分类。 - /// - /// 目前仅识别"模型不存在 / 已下架":各厂商该类错误通常返回 4xx,body 中会包含 - /// 如 `model_not_found`(OpenAI)、`does not exist`、`invalid model`、`not_found_error` - /// + `model` 字样(Anthropic)等标记。 - pub(crate) fn detect_error_category(status: u16, body: &str) -> Option<&'static str> { - // 只检查 4xx;5xx 的错误信息里可能巧合出现"model"之类的词,容易误判 - if !(400..500).contains(&status) { - return None; - } - let lower = body.to_lowercase(); - let qianfan_quota_indicators = [ - "coding_plan_hour_quota_exceeded", - "coding_plan_week_quota_exceeded", - "coding_plan_month_quota_exceeded", - ]; - if qianfan_quota_indicators.iter().any(|s| lower.contains(s)) { - return Some("quotaExceeded"); - } - - // 必须提到 "model",避免通用 404 / 400 被误判 - if !lower.contains("model") { - return None; - } - let indicators = [ - "model_not_found", - "model not found", - "does not exist", - "invalid_model", - "invalid model", - "unknown_model", - "unknown model", - "is not a valid model", - "not_found_error", // Anthropic 的 type 字段 - ]; - if indicators.iter().any(|s| lower.contains(s)) { - return Some("modelNotFound"); - } - None - } - - /// OpenClaw 流式检查分发器 - /// - /// 根据 `settings_config.api` 字段分发到对应协议的检查器。 - /// 取值参见 `openclawApiProtocols` (前端 openclawProviderPresets.ts): - /// - `openai-completions` → check_claude_stream + api_format="openai_chat" - /// - `openai-responses` → check_claude_stream + api_format="openai_responses" - /// - `anthropic-messages` → check_claude_stream + api_format="anthropic" (ClaudeAuth 策略) - /// - `google-generative-ai` → check_gemini_stream (Google API Key 策略) - /// - `bedrock-converse-stream` → 不支持(需要 AWS SigV4 签名) - async fn check_additive_app_stream( - client: &Client, - provider: &Provider, - model: &str, - test_prompt: &str, - timeout: std::time::Duration, - ) -> Result<(u16, String), AppError> { - // 自定义认证头(如 Longcat 的 `apikey` 头)不走标准 Bearer, - // 具体头名由 OpenClaw 网关内部决定,cc-switch 无法准确构造, - // 因此直接返回友好错误而不是让用户看到一个误导性的 401。 - if Self::additive_app_uses_auth_header(provider) { - return Err(AppError::localized( - "openclaw_auth_header_not_supported", - "该供应商使用自定义认证头,暂不支持流式健康检查。建议直接通过 OpenClaw 测试。", - "This provider uses a custom auth header; stream health check is not supported. Please test it directly via OpenClaw.", - )); - } - - let base_url = Self::extract_openclaw_base_url(provider)?; - let api_key = Self::extract_openclaw_api_key(provider)?; - let api = Self::extract_openclaw_protocol(provider); - let extra_headers = Self::extract_openclaw_headers(provider); - - match api.as_deref() { - Some("openai-completions") => { - let auth = AuthInfo::new(api_key, AuthStrategy::Bearer); - Self::check_claude_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - provider, - Some("openai_chat"), - extra_headers, - ) - .await - } - Some("openai-responses") => { - let auth = AuthInfo::new(api_key, AuthStrategy::Bearer); - Self::check_claude_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - provider, - Some("openai_responses"), - extra_headers, - ) - .await - } - Some("anthropic-messages") => { - // 使用 ClaudeAuth(Bearer-only)以兼容 Claude 中转服务。 - // 某些中转同时收到 Authorization 和 x-api-key 会报错,ClaudeAuth - // 策略保证只下发 Bearer。官方 Anthropic 也接受纯 Bearer。 - let auth = AuthInfo::new(api_key, AuthStrategy::ClaudeAuth); - Self::check_claude_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - provider, - Some("anthropic"), - extra_headers, - ) - .await - } - Some("google-generative-ai") => { - let auth = AuthInfo::new(api_key, AuthStrategy::Google); - Self::check_gemini_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - extra_headers, - ) - .await - } - Some("bedrock-converse-stream") => Err(AppError::localized( - "openclaw_bedrock_not_supported", - "AWS Bedrock 需要 SigV4 签名,当前不支持健康检查。请通过 AWS 控制台或 OpenClaw 验证连通性。", - "AWS Bedrock requires SigV4 signing and is not supported by stream health check. Please verify connectivity via AWS console or OpenClaw.", - )), - Some(other) => Err(AppError::localized( - "openclaw_protocol_not_yet_supported", - format!("OpenClaw 暂不支持协议: {other}"), - format!("OpenClaw protocol not yet supported: {other}"), - )), - None => Err(AppError::localized( - "openclaw_protocol_missing", - "OpenClaw 供应商缺少 api 字段", - "OpenClaw provider is missing the `api` field", - )), - } - } - - /// 判断 additive-mode 供应商是否使用自定义认证头(`authHeader: true`) - fn additive_app_uses_auth_header(provider: &Provider) -> bool { - provider - .settings_config - .get("authHeader") - .and_then(|v| v.as_bool()) - .unwrap_or(false) - } - - /// 提取 OpenClaw 供应商的自定义 headers(来自 `settings_config.headers`) - fn extract_openclaw_headers( - provider: &Provider, - ) -> Option<&serde_json::Map> { - provider - .settings_config - .get("headers") - .and_then(|v| v.as_object()) - .filter(|m| !m.is_empty()) - } + // ===== 各应用 base_url 提取(settings_config 结构互不相同)===== + /// OpenClaw: `{ baseUrl, apiKey, api, ... }`(camelCase) fn extract_openclaw_base_url(provider: &Provider) -> Result { provider .settings_config @@ -1030,34 +382,7 @@ impl StreamCheckService { }) } - fn extract_openclaw_api_key(provider: &Provider) -> Result { - provider - .settings_config - .get("apiKey") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| { - AppError::localized( - "openclaw_api_key_missing", - "OpenClaw 供应商缺少 apiKey", - "OpenClaw provider is missing `apiKey`", - ) - }) - } - - fn extract_openclaw_protocol(provider: &Provider) -> Option { - provider - .settings_config - .get("api") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - } - - // Hermes 的 settings_config 用 snake_case(base_url / api_key / api_mode), - // 与 OpenClaw 的 camelCase(baseUrl / apiKey / api)是两套独立命名。 - // 见 src/config/hermesProviderPresets.ts 的 HermesProviderSettingsConfig。 + /// Hermes: `{ base_url, api_key, api_mode }`(snake_case) fn extract_hermes_base_url(provider: &Provider) -> Result { provider .settings_config @@ -1074,206 +399,10 @@ impl StreamCheckService { }) } - fn extract_hermes_api_key(provider: &Provider) -> Result { - provider - .settings_config - .get("api_key") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| { - AppError::localized( - "hermes_api_key_missing", - "Hermes 供应商缺少 api_key", - "Hermes provider is missing `api_key`", - ) - }) - } - - fn extract_hermes_api_mode(provider: &Provider) -> Option { - provider - .settings_config - .get("api_mode") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - } - - /// Hermes 流式检查分发器 + /// OpenCode: `{ npm, options: { baseURL, apiKey }, ... }` /// - /// Hermes 以 `api_mode` 字段显式指定协议,取值来自 - /// `HermesApiMode`(hermesProviderPresets.ts): - /// - `chat_completions` → check_claude_stream + api_format="openai_chat"(Bearer) - /// - `anthropic_messages` → check_claude_stream + api_format="anthropic"(ClaudeAuth,与 OpenClaw 的 anthropic-messages 同策略) - /// - `codex_responses` → check_claude_stream + api_format="openai_responses"(Bearer) - /// - `bedrock_converse` → 不支持(需要 AWS SigV4 签名) - async fn check_hermes_stream( - client: &Client, - provider: &Provider, - model: &str, - test_prompt: &str, - timeout: std::time::Duration, - ) -> Result<(u16, String), AppError> { - // 先把 api_mode 路由出协议格式与认证策略。 - // 纯错误路径(bedrock / 未知 / 缺失)直接 return,避免在用户 - // 选了 bedrock_converse 时被"缺 base_url"的二级错误盖住真正原因。 - let (api_format, auth_strategy) = match Self::extract_hermes_api_mode(provider).as_deref() { - Some("chat_completions") => ("openai_chat", AuthStrategy::Bearer), - Some("anthropic_messages") => ("anthropic", AuthStrategy::ClaudeAuth), - Some("codex_responses") => ("openai_responses", AuthStrategy::Bearer), - Some("bedrock_converse") => { - return Err(AppError::localized( - "hermes_bedrock_not_supported", - "AWS Bedrock 需要 SigV4 签名,当前不支持健康检查。", - "AWS Bedrock requires SigV4 signing and is not supported by stream health check.", - )); - } - Some(other) => { - return Err(AppError::localized( - "hermes_protocol_not_yet_supported", - format!("Hermes 暂不支持协议: {other}"), - format!("Hermes protocol not yet supported: {other}"), - )); - } - None => { - return Err(AppError::localized( - "hermes_api_mode_missing", - "Hermes 供应商缺少 api_mode 字段", - "Hermes provider is missing the `api_mode` field", - )); - } - }; - - let base_url = Self::extract_hermes_base_url(provider)?; - let api_key = Self::extract_hermes_api_key(provider)?; - let auth = AuthInfo::new(api_key, auth_strategy); - Self::check_claude_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - provider, - Some(api_format), - None, - ) - .await - } - - /// OpenCode 流式检查分发器 - /// - /// OpenCode 用 `npm` 字段(AI SDK 包名)隐式指定协议。映射关系参见 - /// `opencodeNpmPackages` (前端 opencodeProviderPresets.ts): - /// - `@ai-sdk/openai-compatible` → check_claude_stream + api_format="openai_chat" - /// - `@ai-sdk/openai` → check_claude_stream + api_format="openai_responses" - /// - `@ai-sdk/anthropic` → check_claude_stream + api_format="anthropic" - /// - `@ai-sdk/google` → check_gemini_stream (Google API Key 策略) - /// - `@ai-sdk/amazon-bedrock` → 不支持(需要 AWS SigV4 签名) - /// - /// URL/API Key 存放在 `settings_config.options.{baseURL,apiKey}`,注意 - /// `baseURL` 大写 L(与 OpenClaw 的 `baseUrl` 首字母小写 u 不同)。 - async fn check_opencode_stream( - client: &Client, - provider: &Provider, - model: &str, - test_prompt: &str, - timeout: std::time::Duration, - ) -> Result<(u16, String), AppError> { - let npm = Self::extract_opencode_npm(provider); - // 若用户未显式填 baseURL,则根据 npm 回退到 AI SDK 包自带的默认端点 - let base_url = Self::resolve_opencode_base_url(provider, npm.as_deref())?; - let api_key = Self::extract_opencode_api_key(provider)?; - let extra_headers = Self::extract_opencode_headers(provider); - - match npm.as_deref() { - Some("@ai-sdk/openai-compatible") => { - let auth = AuthInfo::new(api_key, AuthStrategy::Bearer); - Self::check_claude_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - provider, - Some("openai_chat"), - extra_headers, - ) - .await - } - Some("@ai-sdk/openai") => { - let auth = AuthInfo::new(api_key, AuthStrategy::Bearer); - Self::check_claude_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - provider, - Some("openai_responses"), - extra_headers, - ) - .await - } - Some("@ai-sdk/anthropic") => { - // 见 check_additive_app_stream 对 anthropic-messages 的处理: - // 用 ClaudeAuth(Bearer-only)兼容中转服务。 - let auth = AuthInfo::new(api_key, AuthStrategy::ClaudeAuth); - Self::check_claude_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - provider, - Some("anthropic"), - extra_headers, - ) - .await - } - Some("@ai-sdk/google") => { - let auth = AuthInfo::new(api_key, AuthStrategy::Google); - Self::check_gemini_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - extra_headers, - ) - .await - } - Some("@ai-sdk/amazon-bedrock") => Err(AppError::localized( - "opencode_bedrock_not_supported", - "AWS Bedrock 需要 SigV4 签名,当前不支持健康检查。请通过 AWS 控制台或 OpenCode 验证连通性。", - "AWS Bedrock requires SigV4 signing and is not supported by stream health check. Please verify connectivity via AWS console or OpenCode.", - )), - Some(other) => Err(AppError::localized( - "opencode_npm_not_yet_supported", - format!("OpenCode 暂不支持 SDK 包: {other}"), - format!("OpenCode SDK package not yet supported: {other}"), - )), - None => Err(AppError::localized( - "opencode_npm_missing", - "OpenCode 供应商缺少 npm 字段", - "OpenCode provider is missing the `npm` field", - )), - } - } - - /// 按 OpenCode 的实际 SDK 包特性确定 baseURL: - /// - 用户显式填写的 `options.baseURL` 总是优先 - /// - 否则根据 `npm` 返回 AI SDK 包自带的默认端点 - /// - `@ai-sdk/openai-compatible` 没有默认端点,必须显式填 - /// - /// 注意:这里的默认端点对应 AI SDK 包的行为(例如 `@ai-sdk/openai` - /// 自带 `/v1` 路径后缀),与 `proxy/providers/mod.rs` 里的 - /// `ProviderType::default_endpoint()` 语义不同——后者是代理层的上游 - /// 默认值,不带 `/v1`。两者维护的是不同系统的默认值,不能简单共享。 + /// 用户未显式填 `options.baseURL` 时,按 `npm`(AI SDK 包)回退到包自带默认端点。 + /// `@ai-sdk/openai-compatible` 无默认端点,必须显式填。 fn resolve_opencode_base_url( provider: &Provider, npm: Option<&str>, @@ -1308,35 +437,6 @@ impl StreamCheckService { .filter(|s| !s.is_empty()) } - /// 提取 OpenCode 供应商的自定义 headers(来自 `settings_config.options.headers`) - fn extract_opencode_headers( - provider: &Provider, - ) -> Option<&serde_json::Map> { - provider - .settings_config - .get("options") - .and_then(|v| v.get("headers")) - .and_then(|v| v.as_object()) - .filter(|m| !m.is_empty()) - } - - fn extract_opencode_api_key(provider: &Provider) -> Result { - provider - .settings_config - .get("options") - .and_then(|v| v.get("apiKey")) - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| { - AppError::localized( - "opencode_api_key_missing", - "OpenCode 供应商缺少 options.apiKey", - "OpenCode provider is missing `options.apiKey`", - ) - }) - } - fn extract_opencode_npm(provider: &Provider) -> Option { provider .settings_config @@ -1345,283 +445,6 @@ impl StreamCheckService { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) } - - fn determine_status(latency_ms: u64, threshold: u64) -> HealthStatus { - if latency_ms <= threshold { - HealthStatus::Operational - } else { - HealthStatus::Degraded - } - } - - /// 解析模型名和推理等级 (支持 model@level 或 model#level 格式) - /// 返回 (实际模型名, Option<推理等级>) - fn parse_model_with_effort(model: &str) -> (String, Option) { - if let Some(pos) = model.find('@').or_else(|| model.find('#')) { - let actual_model = model[..pos].to_string(); - let effort = model[pos + 1..].to_string(); - if !effort.is_empty() { - return (actual_model, Some(effort)); - } - } - (model.to_string(), None) - } - - fn should_retry(msg: &str) -> bool { - let lower = msg.to_lowercase(); - lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out") - } - - fn map_request_error(e: reqwest::Error) -> AppError { - if e.is_timeout() { - AppError::Message("Request timeout".to_string()) - } else if e.is_connect() { - AppError::Message(format!("Connection failed: {e}")) - } else { - AppError::Message(e.to_string()) - } - } - - /// 构造 HTTP 状态码错误,截断过长的响应体 - fn http_status_error(status: u16, body: String) -> AppError { - let body = if body.len() > 200 { - // 安全截断:找到 200 字节内最近的 char 边界 - let mut end = 200; - while end > 0 && !body.is_char_boundary(end) { - end -= 1; - } - format!("{}…", &body[..end]) - } else { - body - }; - AppError::HttpStatus { status, body } - } - - /// 将 HTTP 状态码映射为简短的分类标签 - pub(crate) fn classify_http_status(status: u16) -> &'static str { - match status { - 400 => "Bad request (400)", - 401 => "Auth rejected (401)", - 402 => "Payment required (402)", - 403 => "Access denied (403)", - 404 => "Not found (404)", - 429 => "Rate limited (429)", - 500 => "Internal server error (500)", - 502 => "Bad gateway (502)", - 503 => "Service unavailable (503)", - 504 => "Gateway timeout (504)", - s if (500..600).contains(&s) => "Server error", - _ => "HTTP error", - } - } - - fn resolve_test_model( - app_type: &AppType, - provider: &Provider, - config: &StreamCheckConfig, - ) -> String { - match app_type { - AppType::Claude | AppType::ClaudeDesktop => { - Self::extract_env_model(provider, "ANTHROPIC_MODEL") - .unwrap_or_else(|| config.claude_model.clone()) - } - AppType::Codex => { - Self::extract_codex_model(provider).unwrap_or_else(|| config.codex_model.clone()) - } - AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL") - .unwrap_or_else(|| config.gemini_model.clone()), - AppType::OpenCode => { - // OpenCode uses models map in settings_config - // Try to extract first model from the models object - Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string()) - } - AppType::OpenClaw | AppType::Hermes => { - // OpenClaw/Hermes use models array in settings_config - // Try to extract first model from the models array - Self::extract_openclaw_model(provider).unwrap_or_else(|| "gpt-4o".to_string()) - } - } - } - - fn extract_opencode_model(provider: &Provider) -> Option { - let models = provider - .settings_config - .get("models") - .and_then(|m| m.as_object())?; - - // Return the first model ID from the models map - models.keys().next().map(|s| s.to_string()) - } - - fn extract_openclaw_model(provider: &Provider) -> Option { - // OpenClaw uses models array: [{ "id": "model-id", "name": "Model Name" }] - let models = provider - .settings_config - .get("models") - .and_then(|m| m.as_array())?; - - // Return the first model ID from the models array - models - .first() - .and_then(|m| m.get("id")) - .and_then(|id| id.as_str()) - .map(|s| s.to_string()) - } - - fn extract_env_model(provider: &Provider, key: &str) -> Option { - provider - .settings_config - .get("env") - .and_then(|env| env.get(key)) - .and_then(|value| value.as_str()) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - } - - fn extract_codex_model(provider: &Provider) -> Option { - let config_text = provider - .settings_config - .get("config") - .and_then(|value| value.as_str())?; - if config_text.trim().is_empty() { - return None; - } - - let table = toml::from_str::(config_text).ok()?; - table - .get("model") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|value| !value.is_empty()) - } - - /// 获取操作系统名称(映射为 Claude CLI 使用的格式) - fn get_os_name() -> &'static str { - match std::env::consts::OS { - "macos" => "MacOS", - "linux" => "Linux", - "windows" => "Windows", - other => other, - } - } - - /// 获取 CPU 架构名称(映射为 Claude CLI 使用的格式) - fn get_arch_name() -> &'static str { - match std::env::consts::ARCH { - "aarch64" => "arm64", - "x86_64" => "x86_64", - "x86" => "x86", - other => other, - } - } - - fn resolve_claude_stream_url( - base_url: &str, - auth_strategy: AuthStrategy, - api_format: &str, - is_full_url: bool, - model: &str, - ) -> String { - if api_format == "gemini_native" { - // Strip an optional `models/` resource-name prefix so that model - // identifiers copied from Gemini SDK outputs (e.g. - // `models/gemini-2.5-pro`) don't produce a doubled - // `/v1beta/models/models/...` URL. - let normalized_model = normalize_gemini_model_id(model); - let endpoint = - format!("/v1beta/models/{normalized_model}:streamGenerateContent?alt=sse"); - return resolve_gemini_native_url(base_url, &endpoint, is_full_url); - } - - if is_full_url { - return base_url.to_string(); - } - - let base = base_url.trim_end_matches('/'); - let is_github_copilot = auth_strategy == AuthStrategy::GitHubCopilot; - - if is_github_copilot && api_format == "openai_responses" { - format!("{base}/v1/responses") - } else if is_github_copilot { - format!("{base}/chat/completions") - } else if api_format == "openai_responses" { - if base.ends_with("/v1") { - format!("{base}/responses") - } else { - format!("{base}/v1/responses") - } - } else if api_format == "openai_chat" { - if base.ends_with("/v1") { - format!("{base}/chat/completions") - } else { - format!("{base}/v1/chat/completions") - } - } else if base.ends_with("/v1") { - format!("{base}/messages") - } else { - format!("{base}/v1/messages") - } - } - - /// Codex Responses 流式 URL 构造(薄包装,详见 `resolve_codex_endpoint_urls`)。 - fn resolve_codex_stream_urls(base_url: &str, is_full_url: bool) -> Vec { - Self::resolve_codex_endpoint_urls(base_url, is_full_url, "responses") - } - - /// Codex Chat Completions 流式 URL 构造(薄包装,详见 `resolve_codex_endpoint_urls`)。 - fn resolve_codex_chat_stream_urls(base_url: &str, is_full_url: bool) -> Vec { - Self::resolve_codex_endpoint_urls(base_url, is_full_url, "chat/completions") - } - - /// 与 `CodexAdapter::build_url` 优先级对齐的 stream check URL 列表构造。 - /// - /// 纯 origin 在生产路径上会自动补 `/v1`,所以 Stream Check 必须优先走 - /// `/v1/`。否则上游对 bare 路径返回 401/400/405(非 404) - /// 时不会触发循环里的 fallback,会把可用供应商误判为不可用。 - fn resolve_codex_endpoint_urls( - base_url: &str, - is_full_url: bool, - endpoint: &str, - ) -> Vec { - if is_full_url { - return vec![base_url.to_string()]; - } - - let base = base_url.trim_end_matches('/'); - let lower = base.to_ascii_lowercase(); - let endpoint_suffix = format!("/{endpoint}"); - let endpoint_lower = endpoint_suffix.to_ascii_lowercase(); - - // 用户在 base_url 里写了完整 endpoint 但忘开 is_full_url 的兜底 - if lower.ends_with(&endpoint_lower) { - return vec![base.to_string()]; - } - - if base.ends_with("/v1") { - return vec![format!("{base}{endpoint_suffix}")]; - } - - if crate::proxy::providers::is_origin_only_url(base) { - vec![ - format!("{base}/v1{endpoint_suffix}"), - format!("{base}{endpoint_suffix}"), - ] - } else { - vec![ - format!("{base}{endpoint_suffix}"), - format!("{base}/v1{endpoint_suffix}"), - ] - } - } - - pub(crate) fn resolve_effective_test_model( - app_type: &AppType, - provider: &Provider, - config: &StreamCheckConfig, - ) -> String { - let effective_config = Self::merge_provider_config(provider, config); - Self::resolve_test_model(app_type, provider, &effective_config) - } } #[cfg(test)] @@ -1638,71 +461,113 @@ mod tests { } #[test] - fn test_additive_app_uses_auth_header_true() { - let p = make_provider(serde_json::json!({ - "baseUrl": "https://api.longcat.chat/v1", - "apiKey": "k", - "api": "openai-completions", - "authHeader": true, - })); - assert!(StreamCheckService::additive_app_uses_auth_header(&p)); + fn test_default_config_uses_reachability_friendly_values() { + let config = StreamCheckConfig::default(); + assert_eq!(config.timeout_secs, 8); + assert_eq!(config.max_retries, 1); + assert_eq!(config.degraded_threshold_ms, 1500); } #[test] - fn test_additive_app_uses_auth_header_default_false() { - let p = make_provider(serde_json::json!({ - "baseUrl": "https://api.deepseek.com/v1", - "apiKey": "k", - "api": "openai-completions", - })); - assert!(!StreamCheckService::additive_app_uses_auth_header(&p)); - } - - #[test] - fn test_custom_user_agent_trims_and_filters_empty() { - use crate::provider::ProviderMeta; - - let mut p = make_provider(serde_json::json!({ - "baseUrl": "https://api.kimi.com/coding", - "apiKey": "k", - })); - - // 未设置 meta → None - assert!(StreamCheckService::custom_user_agent(&p).is_none()); - - // 带首尾空格的 UA → 去空格后返回合法 HeaderValue - p.meta = Some(ProviderMeta { - custom_user_agent: Some(" claude-cli/2.1.161 ".to_string()), - ..Default::default() - }); + fn test_determine_status() { assert_eq!( - StreamCheckService::custom_user_agent(&p) - .unwrap() - .to_str() - .unwrap(), - "claude-cli/2.1.161" + StreamCheckService::determine_status(1000, 1500), + HealthStatus::Operational ); + assert_eq!( + StreamCheckService::determine_status(1500, 1500), + HealthStatus::Operational + ); + assert_eq!( + StreamCheckService::determine_status(1501, 1500), + HealthStatus::Degraded + ); + } - // 纯空白 → 视为未设置(与 forwarder 路径口径一致) - p.meta = Some(ProviderMeta { - custom_user_agent: Some(" ".to_string()), + #[test] + fn test_should_retry_only_on_timeout_like_errors() { + assert!(StreamCheckService::should_retry("Request timeout")); + assert!(StreamCheckService::should_retry("request timed out")); + assert!(StreamCheckService::should_retry("connection abort")); + // 连接被拒 / DNS 失败不重试 + assert!(!StreamCheckService::should_retry( + "Connection failed: dns error" + )); + assert!(!StreamCheckService::should_retry("Reachable")); + } + + #[test] + fn test_build_result_any_http_status_is_reachable() { + // 任何 HTTP 状态码都算可达(success=true) + for status in [200u16, 401, 403, 404, 429, 500, 503] { + let r = StreamCheckService::build_result(Ok(status), 100, 1500); + assert!(r.success, "status {status} should be reachable"); + assert_eq!(r.status, HealthStatus::Operational); + assert_eq!(r.http_status, Some(status)); + assert!(r.model_used.is_empty()); + assert!(r.error_category.is_none()); + } + } + + #[test] + fn test_build_result_network_error_is_unreachable() { + let r = StreamCheckService::build_result( + Err(AppError::Message("Connection failed: refused".to_string())), + 5, + 1500, + ); + assert!(!r.success); + assert_eq!(r.status, HealthStatus::Failed); + assert!(r.http_status.is_none()); + } + + #[test] + fn test_build_result_slow_response_is_degraded() { + let r = StreamCheckService::build_result(Ok(200), 3000, 1500); + assert!(r.success); + assert_eq!(r.status, HealthStatus::Degraded); + } + + #[test] + fn test_merge_provider_config_override_and_default() { + use crate::provider::{ProviderMeta, ProviderTestConfig}; + + let global = StreamCheckConfig::default(); + + // 无 testConfig → 用全局 + let p = make_provider(serde_json::json!({})); + let merged = StreamCheckService::merge_provider_config(&p, &global); + assert_eq!(merged.timeout_secs, global.timeout_secs); + + // testConfig 启用并覆盖部分字段 + let mut p2 = make_provider(serde_json::json!({})); + p2.meta = Some(ProviderMeta { + test_config: Some(ProviderTestConfig { + enabled: true, + timeout_secs: Some(20), + degraded_threshold_ms: Some(3000), + max_retries: None, + }), ..Default::default() }); - assert!(StreamCheckService::custom_user_agent(&p).is_none()); + let merged2 = StreamCheckService::merge_provider_config(&p2, &global); + assert_eq!(merged2.timeout_secs, 20); + assert_eq!(merged2.degraded_threshold_ms, 3000); + assert_eq!(merged2.max_retries, global.max_retries); // 未覆盖 → 全局 - // 非 ASCII 字符其实合法(UTF-8 字节均 ≥ 0x80,HeaderValue 按字节放行)→ 应返回 Some - p.meta = Some(ProviderMeta { - custom_user_agent: Some("claude-cli/2.1.161 \u{4e2d}".to_string()), + // testConfig 存在但未启用 → 忽略,用全局 + let mut p3 = make_provider(serde_json::json!({})); + p3.meta = Some(ProviderMeta { + test_config: Some(ProviderTestConfig { + enabled: false, + timeout_secs: Some(99), + degraded_threshold_ms: None, + max_retries: None, + }), ..Default::default() }); - assert!(StreamCheckService::custom_user_agent(&p).is_some()); - - // 含控制字符(内嵌换行)才非法 → None(静默忽略,与 forwarder 一致,不让 Stream Check 报错) - p.meta = Some(ProviderMeta { - custom_user_agent: Some("claude-cli/2.1.161\nX".to_string()), - ..Default::default() - }); - assert!(StreamCheckService::custom_user_agent(&p).is_none()); + let merged3 = StreamCheckService::merge_provider_config(&p3, &global); + assert_eq!(merged3.timeout_secs, global.timeout_secs); } #[test] @@ -1720,27 +585,17 @@ mod tests { #[test] fn test_resolve_opencode_base_url_falls_back_for_known_npm() { let p = make_provider(serde_json::json!({ - "npm": "@ai-sdk/openai", - "options": { "apiKey": "k" }, - "models": {}, - })); - let resolved = - StreamCheckService::resolve_opencode_base_url(&p, Some("@ai-sdk/openai")).unwrap(); - assert_eq!(resolved, "https://api.openai.com/v1"); - - let p2 = make_provider(serde_json::json!({ "npm": "@ai-sdk/anthropic", "options": { "apiKey": "k" }, "models": {}, })); - let resolved2 = - StreamCheckService::resolve_opencode_base_url(&p2, Some("@ai-sdk/anthropic")).unwrap(); - assert_eq!(resolved2, "https://api.anthropic.com"); + let resolved = + StreamCheckService::resolve_opencode_base_url(&p, Some("@ai-sdk/anthropic")).unwrap(); + assert_eq!(resolved, "https://api.anthropic.com"); } #[test] fn test_resolve_opencode_base_url_errors_for_openai_compatible_without_url() { - // @ai-sdk/openai-compatible 没有默认端点,必须显式填 let p = make_provider(serde_json::json!({ "npm": "@ai-sdk/openai-compatible", "options": { "apiKey": "k" }, @@ -1752,490 +607,70 @@ mod tests { } #[test] - fn test_extract_openclaw_headers_preserves_map() { - let p = make_provider(serde_json::json!({ - "baseUrl": "https://example.com/v1", - "apiKey": "k", - "api": "openai-completions", - "headers": { "User-Agent": "MyBot/1.0", "X-Trace": "abc" }, - })); - let headers = StreamCheckService::extract_openclaw_headers(&p).unwrap(); + fn test_extract_openclaw_base_url_missing_errors() { + let p = make_provider(serde_json::json!({ "apiKey": "k", "api": "openai-completions" })); + assert!(StreamCheckService::extract_openclaw_base_url(&p).is_err()); + + let p2 = make_provider(serde_json::json!({ "baseUrl": "https://api.deepseek.com/v1" })); assert_eq!( - headers.get("User-Agent").and_then(|v| v.as_str()), - Some("MyBot/1.0") + StreamCheckService::extract_openclaw_base_url(&p2).unwrap(), + "https://api.deepseek.com/v1" ); - assert_eq!(headers.get("X-Trace").and_then(|v| v.as_str()), Some("abc")); } #[test] - fn test_extract_openclaw_headers_ignores_empty_map() { - let p = make_provider(serde_json::json!({ - "baseUrl": "https://example.com/v1", - "apiKey": "k", - "api": "openai-completions", - "headers": {}, - })); - assert!(StreamCheckService::extract_openclaw_headers(&p).is_none()); - } - - #[test] - fn test_extract_opencode_headers_from_options() { - let p = make_provider(serde_json::json!({ - "npm": "@ai-sdk/openai-compatible", - "options": { - "baseURL": "https://example.com/v1", - "apiKey": "k", - "headers": { "X-Custom": "yes" }, - }, - "models": {}, - })); - let headers = StreamCheckService::extract_opencode_headers(&p).unwrap(); + fn test_resolve_base_url_official_falls_back_to_default_endpoint() { + // 官方 Claude(category=official,空 env)→ 回退到 api.anthropic.com + let mut claude = make_provider(serde_json::json!({ "env": {} })); + claude.category = Some("official".to_string()); assert_eq!( - headers.get("X-Custom").and_then(|v| v.as_str()), - Some("yes") + StreamCheckService::resolve_base_url(&AppType::Claude, &claude).unwrap(), + "https://api.anthropic.com" ); - } - #[test] - fn test_determine_status() { + // 官方 Codex(空 auth+config)→ 回退到 ChatGPT 后端 + let mut codex = make_provider(serde_json::json!({ "auth": {}, "config": "" })); + codex.category = Some("official".to_string()); assert_eq!( - StreamCheckService::determine_status(3000, 6000), - HealthStatus::Operational + StreamCheckService::resolve_base_url(&AppType::Codex, &codex).unwrap(), + "https://chatgpt.com/backend-api/codex" ); + + // 官方 Gemini(空 env+config)→ 回退到 generativelanguage + let mut gemini = make_provider(serde_json::json!({ "env": {}, "config": {} })); + gemini.category = Some("official".to_string()); assert_eq!( - StreamCheckService::determine_status(6000, 6000), - HealthStatus::Operational + StreamCheckService::resolve_base_url(&AppType::Gemini, &gemini).unwrap(), + "https://generativelanguage.googleapis.com" ); + } + + #[test] + fn test_resolve_base_url_non_official_missing_base_url_still_errors() { + // 非官方且无 base_url → 仍报错,不误回退到官方端点给"绿灯" + let p = make_provider(serde_json::json!({ "env": {} })); + assert!(StreamCheckService::resolve_base_url(&AppType::Claude, &p).is_err()); + } + + #[test] + fn test_resolve_base_url_explicit_wins_over_official_fallback() { + // 官方但显式配了 base_url(少见)→ 用显式值,不回退 + let mut p = make_provider( + serde_json::json!({ "env": { "ANTHROPIC_BASE_URL": "https://relay.example/v1" } }), + ); + p.category = Some("official".to_string()); assert_eq!( - StreamCheckService::determine_status(6001, 6000), - HealthStatus::Degraded + StreamCheckService::resolve_base_url(&AppType::Claude, &p).unwrap(), + "https://relay.example/v1" ); } #[test] - fn test_should_retry() { - assert!(StreamCheckService::should_retry("Request timeout")); - assert!(StreamCheckService::should_retry("request timed out")); - assert!(StreamCheckService::should_retry("connection abort")); - assert!(!StreamCheckService::should_retry("API Key invalid")); - } - - #[test] - fn test_default_config() { - let config = StreamCheckConfig::default(); - assert_eq!(config.timeout_secs, 45); - assert_eq!(config.max_retries, 2); - assert_eq!(config.degraded_threshold_ms, 6000); - } - - #[test] - fn test_parse_model_with_effort() { - // 带 @ 分隔符 - let (model, effort) = StreamCheckService::parse_model_with_effort("gpt-5.1-codex@low"); - assert_eq!(model, "gpt-5.1-codex"); - assert_eq!(effort, Some("low".to_string())); - - // 带 # 分隔符 - let (model, effort) = StreamCheckService::parse_model_with_effort("o1-preview#high"); - assert_eq!(model, "o1-preview"); - assert_eq!(effort, Some("high".to_string())); - - // 无分隔符 - let (model, effort) = StreamCheckService::parse_model_with_effort("gpt-4o-mini"); - assert_eq!(model, "gpt-4o-mini"); - assert_eq!(effort, None); - } - - #[test] - fn test_detect_model_not_found() { - // OpenAI 典型响应:404 + model_not_found 错误码 - let openai_404 = r#"{"error":{"message":"The model `gpt-5.1-codex` does not exist or you do not have access to it","type":"invalid_request_error","param":null,"code":"model_not_found"}}"#; - assert_eq!( - StreamCheckService::detect_error_category(404, openai_404), - Some("modelNotFound") - ); - - // Anthropic 典型响应:404 + not_found_error + 提到 model - let anthropic_404 = r#"{"type":"error","error":{"type":"not_found_error","message":"model: claude-deprecated"}}"#; - assert_eq!( - StreamCheckService::detect_error_category(404, anthropic_404), - Some("modelNotFound") - ); - - // 400 + invalid model 也算 - let bad_req = r#"{"error":{"message":"invalid model specified"}}"#; - assert_eq!( - StreamCheckService::detect_error_category(400, bad_req), - Some("modelNotFound") - ); - - // 通用 404(比如 Base URL 错误),body 里没有 model 字样 → 不应误判 - let generic_404 = r#"{"error":"Not Found"}"#; - assert_eq!( - StreamCheckService::detect_error_category(404, generic_404), - None - ); - - // 5xx 就算 body 里有 "model does not exist" 也不分类(避免误判) - let server_error = r#"{"error":"model does not exist"}"#; - assert_eq!( - StreamCheckService::detect_error_category(500, server_error), - None - ); - - // 401 鉴权错误(body 里没有 model 字样) - let auth_err = r#"{"error":"Invalid API key"}"#; - assert_eq!( - StreamCheckService::detect_error_category(401, auth_err), - None - ); - } - - #[test] - fn test_detect_qianfan_coding_plan_quota_errors() { - let cases = [ - r#"{"error":{"code":"coding_plan_hour_quota_exceeded","message":"hour quota exceeded"}}"#, - r#"{"error":{"code":"coding_plan_week_quota_exceeded","message":"week quota exceeded"}}"#, - r#"{"error":{"code":"coding_plan_month_quota_exceeded","message":"month quota exceeded"}}"#, - ]; - - for body in cases { - assert_eq!( - StreamCheckService::detect_error_category(429, body), - Some("quotaExceeded") - ); - } - } - - #[test] - fn test_get_os_name() { - let os_name = StreamCheckService::get_os_name(); - // 确保返回非空字符串 - assert!(!os_name.is_empty()); - // 在 macOS 上应该返回 "MacOS" - #[cfg(target_os = "macos")] - assert_eq!(os_name, "MacOS"); - // 在 Linux 上应该返回 "Linux" - #[cfg(target_os = "linux")] - assert_eq!(os_name, "Linux"); - // 在 Windows 上应该返回 "Windows" - #[cfg(target_os = "windows")] - assert_eq!(os_name, "Windows"); - } - - #[test] - fn test_get_arch_name() { - let arch_name = StreamCheckService::get_arch_name(); - // 确保返回非空字符串 - assert!(!arch_name.is_empty()); - // 在 ARM64 上应该返回 "arm64" - #[cfg(target_arch = "aarch64")] - assert_eq!(arch_name, "arm64"); - // 在 x86_64 上应该返回 "x86_64" - #[cfg(target_arch = "x86_64")] - assert_eq!(arch_name, "x86_64"); - } - - #[test] - fn test_auth_strategy_imports() { - // 验证 AuthStrategy 枚举可以正常使用 - let anthropic = AuthStrategy::Anthropic; - let claude_auth = AuthStrategy::ClaudeAuth; - let bearer = AuthStrategy::Bearer; - - // 验证不同的策略是不相等的 - assert_ne!(anthropic, claude_auth); - assert_ne!(anthropic, bearer); - assert_ne!(claude_auth, bearer); - - // 验证相同策略是相等的 - assert_eq!(anthropic, AuthStrategy::Anthropic); - assert_eq!(claude_auth, AuthStrategy::ClaudeAuth); - assert_eq!(bearer, AuthStrategy::Bearer); - } - - #[test] - fn test_resolve_claude_stream_url_for_full_url_mode() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://relay.example/v1/chat/completions", - AuthStrategy::Bearer, - "openai_chat", - true, - "gpt-5.5", - ); - - assert_eq!(url, "https://relay.example/v1/chat/completions"); - } - - #[test] - fn test_resolve_claude_stream_url_for_github_copilot() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://api.githubcopilot.com", - AuthStrategy::GitHubCopilot, - "openai_chat", - false, - "gpt-5.5", - ); - - assert_eq!(url, "https://api.githubcopilot.com/chat/completions"); - } - - #[test] - fn test_resolve_claude_stream_url_for_github_copilot_responses() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://api.githubcopilot.com", - AuthStrategy::GitHubCopilot, - "openai_responses", - false, - "gpt-5.5", - ); - - assert_eq!(url, "https://api.githubcopilot.com/v1/responses"); - } - - #[test] - fn test_resolve_claude_stream_url_for_openai_chat() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://example.com/v1", - AuthStrategy::Bearer, - "openai_chat", - false, - "gpt-5.5", - ); - - assert_eq!(url, "https://example.com/v1/chat/completions"); - } - - #[test] - fn test_resolve_claude_stream_url_for_openai_responses() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://example.com/v1", - AuthStrategy::Bearer, - "openai_responses", - false, - "gpt-5.5", - ); - - assert_eq!(url, "https://example.com/v1/responses"); - } - - #[test] - fn test_resolve_claude_stream_url_for_anthropic() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://api.anthropic.com", - AuthStrategy::Anthropic, - "anthropic", - false, - "claude-sonnet-4-6", - ); - - assert_eq!(url, "https://api.anthropic.com/v1/messages"); - } - - #[test] - fn test_resolve_claude_stream_url_for_gemini_native() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://generativelanguage.googleapis.com", - AuthStrategy::Google, - "gemini_native", - false, - "gemini-2.5-flash", - ); - - assert_eq!( - url, - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" - ); - } - - #[test] - fn test_resolve_claude_stream_url_for_gemini_native_full_url_openai_compat_base() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", - AuthStrategy::Google, - "gemini_native", - true, - "gemini-2.5-flash", - ); - - assert_eq!( - url, - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" - ); - } - - #[test] - fn test_resolve_claude_stream_url_for_gemini_native_opaque_full_url() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://relay.example/custom/generate-content", - AuthStrategy::Google, - "gemini_native", - true, - "gemini-2.5-flash", - ); - - assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse"); - } - - #[test] - fn test_resolve_claude_stream_url_for_gemini_native_cloudflare_vertex_full_url() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://gateway.ai.cloudflare.com/v1/account/gateway/google-vertex-ai/v1/projects/project/locations/us-central1/publishers/google/models/gemini-3.5-flash:streamGenerateContent", - AuthStrategy::Google, - "gemini_native", - true, - "gemini-2.5-flash", - ); - - assert_eq!( - url, - "https://gateway.ai.cloudflare.com/v1/account/gateway/google-vertex-ai/v1/projects/project/locations/us-central1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse" - ); - } - - /// Regression: Gemini SDK outputs commonly surface model ids as the - /// resource-name form `models/gemini-2.5-pro`. Interpolating that raw - /// value used to produce `/v1beta/models/models/gemini-2.5-pro:...` - /// which the upstream rejects and the health check records as a - /// false-negative for an otherwise valid provider. - #[test] - fn test_resolve_claude_stream_url_for_gemini_native_strips_models_prefix() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://generativelanguage.googleapis.com", - AuthStrategy::Google, - "gemini_native", - false, - "models/gemini-2.5-pro", - ); - - assert_eq!( - url, - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse" - ); - } - - #[test] - fn test_resolve_codex_stream_urls_for_full_url_mode() { - let urls = StreamCheckService::resolve_codex_stream_urls( - "https://relay.example/custom/responses", - true, - ); - - assert_eq!(urls, vec!["https://relay.example/custom/responses"]); - } - - #[test] - fn test_resolve_codex_stream_urls_for_v1_base() { - let urls = - StreamCheckService::resolve_codex_stream_urls("https://api.openai.com/v1", false); - - assert_eq!(urls, vec!["https://api.openai.com/v1/responses"]); - } - - /// 纯 origin 必须优先 /v1/responses(与 CodexAdapter::build_url 一致)。 - /// OpenAI 官方 /responses 实际挂在 /v1 下,bare 路径只在用户配置错误的 - /// 反代上才可能命中,作为 fallback 保留即可。 - #[test] - fn test_resolve_codex_stream_urls_for_origin_base_prioritizes_v1() { - let urls = StreamCheckService::resolve_codex_stream_urls("https://api.openai.com", false); - - assert_eq!( - urls, - vec![ - "https://api.openai.com/v1/responses", - "https://api.openai.com/responses", - ] - ); - } - - /// 自定义前缀(如 /openai)生产路径不会自动补 /v1,Stream Check 应先打 - /// 不带 /v1 的版本与之对齐;/v1 作为 misconfigured 兜底。 - #[test] - fn test_resolve_codex_stream_urls_for_custom_prefix() { - let urls = - StreamCheckService::resolve_codex_stream_urls("https://relay.example/openai", false); - - assert_eq!( - urls, - vec![ - "https://relay.example/openai/responses", - "https://relay.example/openai/v1/responses", - ] - ); - } - - #[test] - fn test_resolve_codex_stream_urls_recognizes_full_endpoint_without_flag() { - let urls = StreamCheckService::resolve_codex_stream_urls( - "https://api.openai.com/v1/responses", - false, - ); - - assert_eq!(urls, vec!["https://api.openai.com/v1/responses"]); - } - - #[test] - fn test_resolve_codex_chat_stream_urls_for_v1_base() { - let urls = StreamCheckService::resolve_codex_chat_stream_urls( - "https://api.deepseek.com/v1", - false, - ); - - assert_eq!(urls, vec!["https://api.deepseek.com/v1/chat/completions"]); - } - - /// 纯 origin 必须优先 /v1/chat/completions,与 CodexAdapter::build_url 一致; - /// bare /chat/completions 仅作为 fallback。如果颠倒了优先级,上游对 bare - /// 路径返回 401/400/405 时(非 404)fallback 不会触发,会误判为不可用。 - #[test] - fn test_resolve_codex_chat_stream_urls_for_origin_base_prioritizes_v1() { - let urls = - StreamCheckService::resolve_codex_chat_stream_urls("https://api.deepseek.com", false); - - assert_eq!( - urls, - vec![ - "https://api.deepseek.com/v1/chat/completions", - "https://api.deepseek.com/chat/completions", - ] - ); - } - - /// 自定义前缀(路径中已经包含段如 `/openai`)生产路径不会自动补 /v1。 - /// Stream Check 应先打不带 /v1 的版本,与 build_url 行为一致。 - #[test] - fn test_resolve_codex_chat_stream_urls_for_custom_prefix() { - let urls = - StreamCheckService::resolve_codex_chat_stream_urls("https://example.com/openai", false); - - assert_eq!( - urls, - vec![ - "https://example.com/openai/chat/completions", - "https://example.com/openai/v1/chat/completions", - ] - ); - } - - #[test] - fn test_resolve_codex_chat_stream_urls_for_full_url_mode() { - let urls = StreamCheckService::resolve_codex_chat_stream_urls( - "https://relay.example/custom/chat/completions", - true, - ); - - assert_eq!(urls, vec!["https://relay.example/custom/chat/completions"]); - } - - /// 用户在 base_url 里直接写完整 chat/completions endpoint 但忘开 is_full_url 时, - /// 不应该再追加 `/chat/completions` 后缀。 - #[test] - fn test_resolve_codex_chat_stream_urls_recognizes_full_endpoint_without_flag() { - let urls = StreamCheckService::resolve_codex_chat_stream_urls( - "https://api.deepseek.com/v1/chat/completions", - false, - ); - - assert_eq!(urls, vec!["https://api.deepseek.com/v1/chat/completions"]); + fn test_resolve_base_url_claude_desktop_official_does_not_fall_back() { + // Claude Desktop 官方 = 原生 1P 模式,走 claude.ai 而非 api.anthropic.com, + // 无可靠探测目标 → 不回退(提取失败即报错;前端也隐藏其检测按钮)。 + let mut desktop = make_provider(serde_json::json!({ "env": {} })); + desktop.category = Some("official".to_string()); + assert!(StreamCheckService::resolve_base_url(&AppType::ClaudeDesktop, &desktop).is_err()); } } diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index 4e9911450..e62d1584f 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -1,4 +1,5 @@ import { + Activity, BarChart3, Check, Copy, @@ -8,7 +9,6 @@ import { Play, Plus, Terminal, - TestTube2, Trash2, Zap, } from "lucide-react"; @@ -310,7 +310,7 @@ export function ProviderActions({ variant="ghost" onClick={onTest || undefined} disabled={isTesting} - title={t("modelTest.testProvider", "测试模型")} + title={t("provider.connectivityCheck", "检测连通")} className={cn( iconButtonClass, !onTest && "opacity-40 cursor-not-allowed text-muted-foreground", @@ -319,7 +319,7 @@ export function ProviderActions({ {isTesting ? ( ) : ( - + )} diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 827134cbf..fd53b797b 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -232,9 +232,6 @@ export function ProviderCard({ provider.meta?.apiFormat, (provider.settingsConfig as Record)?.config, ]); - const isClaudeThirdParty = - appId === "claude" && provider.category === "third_party"; - // 获取用量数据以判断是否有多套餐 // 累加模式应用(OpenCode/OpenClaw/Hermes):使用 isInConfig 代替 isCurrent const shouldAutoQuery = @@ -551,11 +548,13 @@ export function ProviderCard({ onEdit={() => onEdit(provider)} onDuplicate={() => onDuplicate(provider)} onTest={ + // 连通检测对所有供应商开放(官方会回退到客户端实际会连的官方端点), + // 唯独 Claude Desktop 官方除外:它是原生 1P 模式(走 claude.ai,cc-switch + // 不在请求路径上),没有可靠的探测目标,故隐藏其检测按钮。 onTest && - !isOfficial && - !isCopilot && - !isCodexOauth && - !isClaudeThirdParty + !( + appId === "claude-desktop" && provider.category === "official" + ) ? () => onTest(provider) : undefined } diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index e312d063b..a991a5564 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -45,8 +45,6 @@ import { import { useCallback } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { settingsApi } from "@/lib/api/settings"; interface ProviderListProps { providers: Record; @@ -192,9 +190,6 @@ export function ProviderList({ const [searchTerm, setSearchTerm] = useState(""); const [isSearchOpen, setIsSearchOpen] = useState(false); const searchInputRef = useRef(null); - const [showStreamCheckConfirm, setShowStreamCheckConfirm] = useState(false); - const [pendingTestProvider, setPendingTestProvider] = - useState(null); const { data: claudeDesktopStatus } = useQuery({ queryKey: ["claudeDesktopStatus"], queryFn: () => providersApi.getClaudeDesktopStatus(), @@ -202,41 +197,14 @@ export function ProviderList({ refetchInterval: appId === "claude-desktop" ? 5000 : false, }); - // Query settings for streamCheckConfirmed flag - const { data: settings } = useQuery({ - queryKey: ["settings"], - queryFn: () => settingsApi.get(), - }); - + // 连通性检查不发真实请求、无封号/计费风险,直接执行(无需确认弹窗)。 const handleTest = useCallback( (provider: Provider) => { - if (!settings?.streamCheckConfirmed) { - setPendingTestProvider(provider); - setShowStreamCheckConfirm(true); - } else { - checkProvider(provider.id, provider.name); - } + checkProvider(provider.id, provider.name); }, - [checkProvider, settings?.streamCheckConfirmed], + [checkProvider], ); - const handleStreamCheckConfirm = async () => { - setShowStreamCheckConfirm(false); - try { - if (settings) { - const { webdavSync: _, ...rest } = settings; - await settingsApi.save({ ...rest, streamCheckConfirmed: true }); - await queryClient.invalidateQueries({ queryKey: ["settings"] }); - } - } catch (error) { - console.error("Failed to save stream check confirmed:", error); - } - if (pendingTestProvider) { - checkProvider(pendingTestProvider.id, pendingTestProvider.name); - setPendingTestProvider(null); - } - }; - // Import current live config as default provider const queryClient = useQueryClient(); const importMutation = useMutation({ @@ -558,19 +526,6 @@ export function ProviderList({ ) : ( renderProviderList() )} - - void handleStreamCheckConfirm()} - onCancel={() => { - setShowStreamCheckConfirm(false); - setPendingTestProvider(null); - }} - />
    ); } diff --git a/src/components/providers/forms/ProviderAdvancedConfig.tsx b/src/components/providers/forms/ProviderAdvancedConfig.tsx index 8801ae873..cccc208f8 100644 --- a/src/components/providers/forms/ProviderAdvancedConfig.tsx +++ b/src/components/providers/forms/ProviderAdvancedConfig.tsx @@ -61,7 +61,7 @@ export function ProviderAdvancedConfig({ {t("providerAdvanced.testConfig", { - defaultValue: "模型测试配置", + defaultValue: "连通检测配置", })}
    @@ -106,31 +106,10 @@ export function ProviderAdvancedConfig({

    {t("providerAdvanced.testConfigDesc", { defaultValue: - "为此供应商配置单独的模型测试参数,不启用时使用全局配置。", + "为此供应商配置单独的连通检测参数(超时/阈值/重试),不启用时使用全局配置。", })}

    -
    - - - onTestConfigChange({ - ...testConfig, - testModel: e.target.value || undefined, - }) - } - placeholder={t("providerAdvanced.testModelPlaceholder", { - defaultValue: "留空使用全局配置", - })} - disabled={!testConfig.enabled} - /> -
    -
    - - - onTestConfigChange({ - ...testConfig, - testPrompt: e.target.value || undefined, - }) - } - placeholder="Who are you?" + placeholder="8" disabled={!testConfig.enabled} />
    @@ -184,7 +144,7 @@ export function ProviderAdvancedConfig({ id="degraded-threshold" type="number" min={100} - max={60000} + max={10000} value={testConfig.degradedThresholdMs || ""} onChange={(e) => onTestConfigChange({ @@ -194,7 +154,7 @@ export function ProviderAdvancedConfig({ : undefined, }) } - placeholder="6000" + placeholder="1500" disabled={!testConfig.enabled} />
    @@ -208,7 +168,7 @@ export function ProviderAdvancedConfig({ id="max-retries" type="number" min={0} - max={10} + max={5} value={testConfig.maxRetries ?? ""} onChange={(e) => onTestConfigChange({ @@ -218,7 +178,7 @@ export function ProviderAdvancedConfig({ : undefined, }) } - placeholder="2" + placeholder="1" disabled={!testConfig.enabled} />
    diff --git a/src/components/usage/ModelTestConfigPanel.tsx b/src/components/usage/ModelTestConfigPanel.tsx index 39981a948..4d5c46bdf 100644 --- a/src/components/usage/ModelTestConfigPanel.tsx +++ b/src/components/usage/ModelTestConfigPanel.tsx @@ -2,10 +2,9 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Save, Loader2 } from "lucide-react"; +import { Save, Loader2, Info } from "lucide-react"; import { toast } from "sonner"; import { getStreamCheckConfig, @@ -20,13 +19,9 @@ export function ModelTestConfigPanel() { const [error, setError] = useState(null); // 使用字符串状态以支持完全清空数字输入框 const [config, setConfig] = useState({ - timeoutSecs: "45", - maxRetries: "2", - degradedThresholdMs: "6000", - claudeModel: "claude-haiku-4-5-20251001", - codexModel: "gpt-5.5@low", - geminiModel: "gemini-3.5-flash", - testPrompt: "Who are you?", + timeoutSecs: "8", + maxRetries: "1", + degradedThresholdMs: "1500", }); useEffect(() => { @@ -42,10 +37,6 @@ export function ModelTestConfigPanel() { timeoutSecs: String(data.timeoutSecs), maxRetries: String(data.maxRetries), degradedThresholdMs: String(data.degradedThresholdMs), - claudeModel: data.claudeModel, - codexModel: data.codexModel, - geminiModel: data.geminiModel, - testPrompt: data.testPrompt || "Who are you?", }); } catch (e) { setError(String(e)); @@ -63,13 +54,9 @@ export function ModelTestConfigPanel() { try { setIsSaving(true); const parsed: StreamCheckConfig = { - timeoutSecs: parseNum(config.timeoutSecs, 45), - maxRetries: parseNum(config.maxRetries, 2), - degradedThresholdMs: parseNum(config.degradedThresholdMs, 6000), - claudeModel: config.claudeModel, - codexModel: config.codexModel, - geminiModel: config.geminiModel, - testPrompt: config.testPrompt || "Who are you?", + timeoutSecs: parseNum(config.timeoutSecs, 8), + maxRetries: parseNum(config.maxRetries, 1), + degradedThresholdMs: parseNum(config.degradedThresholdMs, 1500), }; await saveStreamCheckConfig(parsed); toast.success(t("streamCheck.configSaved"), { @@ -98,49 +85,16 @@ export function ModelTestConfigPanel() { )} - {/* 测试模型配置 */} -
    -

    - {t("streamCheck.testModels")} -

    -
    -
    - - - setConfig({ ...config, claudeModel: e.target.value }) - } - placeholder="claude-3-5-haiku-latest" - /> -
    - -
    - - - setConfig({ ...config, codexModel: e.target.value }) - } - placeholder="gpt-4o-mini" - /> -
    - -
    - - - setConfig({ ...config, geminiModel: e.target.value }) - } - placeholder="gemini-1.5-flash" - /> -
    -
    -
    + {/* 连通检测语义说明:可达 ≠ 配置正确 */} + + + + {t("streamCheck.connectivityNote", { + defaultValue: + "连通检测仅探测供应商地址是否可达,不发送真实模型请求。收到任意响应即视为“可达”——这不代表鉴权或模型配置一定正确。", + })} + + {/* 检查参数配置 */}
    @@ -153,8 +107,8 @@ export function ModelTestConfigPanel() { setConfig({ ...config, timeoutSecs: e.target.value }) @@ -183,9 +137,9 @@ export function ModelTestConfigPanel() { setConfig({ ...config, degradedThresholdMs: e.target.value }) @@ -193,21 +147,6 @@ export function ModelTestConfigPanel() { />
    - - {/* 检查提示词配置 */} -
    - -