diff --git a/README.md b/README.md
index a173e85d9..19bcf0a76 100644
--- a/README.md
+++ b/README.md
@@ -37,8 +37,8 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e
-
-
Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via this link and complete real-name verification to receive ¥20 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.
+
+
Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via this link, you'll receive an extra 10% bonus credit on your first top-up!
@@ -47,8 +47,8 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e
-
-
Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via this link, you'll receive an extra 10% bonus credit on your first top-up!
+
+
Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via this link and complete real-name verification to receive ¥20 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.
@@ -72,21 +72,21 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
Thanks to Compshare for sponsoring this project! Compshare is UCloud's AI cloud platform, providing stable and comprehensive domestic and international model APIs with just one key. Featuring cost-effective monthly and pay-as-you-go Coding Plan packages at 60-80% off official prices. Supports Claude Code, Codex, and API access. Enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Users who register via this link will receive a free 5 CNY platform trial credit!
-
-
-
Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini. It features a highly cost-effective Codex monthly subscription plan and supports quota rollovers—unused quota from one day can be carried over and used the next day. Invoices are available upon top-up. Enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via this link, and with every top-up you will receive pay-as-you-go credit equivalent to 25% of the amount paid.
-
-
Thanks to AICoding.sh for sponsoring this project! AICoding.sh — Global AI Model API Relay Service at Unbeatable Prices! Claude Code at 19% of original price, GPT at just 1%! Trusted by hundreds of enterprises for cost-effective AI services. Supports Claude Code, GPT, Gemini and major domestic models, with enterprise-grade high concurrency, fast invoicing, and 24/7 dedicated technical support. CC Switch users who register via this link get 10% off their first top-up!
-
+
Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, and more. All models at 55% of official pricing with auto-failover, smart routing, and unlimited concurrency. Crazyrouter offers an exclusive deal for CC Switch users: register via this link to get $2 free credit instantly, plus enter promo code `CCSWITCH` on your first top-up for an extra 30% bonus credit!
+
+
+
Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini. It features a highly cost-effective Codex monthly subscription plan and supports quota rollovers—unused quota from one day can be carried over and used the next day. Invoices are available upon top-up. Enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via this link, and with every top-up you will receive pay-as-you-go credit equivalent to 25% of the amount paid.
+
+
Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, offering high cost-effective official Claude service at just ¥0.5/$ equivalent, supporting monthly and pay-as-you-go billing plans with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via this link to enjoy $10 extra credit on every top-up!
@@ -98,8 +98,8 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
-
-
Thanks to XCodeAPI for sponsoring this project! XCodeAPI offers a special benefit for CC Switch users: register via this link and get an extra 10% credit bonus on your first order! (Contact the site admin to claim)
+
+
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!
SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。このリンクから登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥20 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。
SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。このリンクから登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥20 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。
感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过此链接注册并完成实名认证,即可获得 ¥20 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。
+
+
感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过此链接注册的用户首次充值可以获得额外10%奖励额度!
感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过此链接注册的用户首次充值可以获得额外10%奖励额度!
+
+
感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过此链接注册并完成实名认证,即可获得 ¥20 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。
感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按量的高性价比 Coding Plan 套餐,基于官方2~5折优惠。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过此链接注册的用户,可得免费5元平台体验金!
-
-
-
感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务。主打极高性价比的Codex包月套餐,提供额度转结,套餐当天用不完的额度,第二天还能接着用!充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过此链接注册,每次充值均可获得实付金额25%的按量额度!
-
-
感谢 AICoding.sh 赞助了本项目!AICoding.sh —— 全球大模型 API 超值中转服务!Claude Code 1.9 折,GPT 0.1 折,已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型,企业级高并发、极速开票、7×24 专属技术支持,通过此链接 注册的 CC Switch 用户,首充可享受九折优惠!
-
+
感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI 等。全部模型低至官方定价的 55%,支持自动故障转移、智能路由和无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠:通过此链接注册即可获得 $2 免费额度,首次充值时输入优惠码 `CCSWITCH` 还可获得额外 30% 奖励额度!
+
+
+
感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务。主打极高性价比的Codex包月套餐,提供额度转结,套餐当天用不完的额度,第二天还能接着用!充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过此链接注册,每次充值均可获得实付金额25%的按量额度!
感谢 LemonData 赞助了本项目!LemonData 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 GPT、Claude、Gemini、DeepSeek 等 300+ 模型。所有模型定价为官方价格的 30%-70%,支持自动故障转移、智能路由和无限并发。新用户注册即获 $1 免费额度——通过此链接注册即可领取奖励,立即开始开发!
+
感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击这里注册!
diff --git a/assets/partners/logos/crazyrouter.png b/assets/partners/logos/crazyrouter.png
new file mode 100644
index 000000000..ffa5456df
Binary files /dev/null and b/assets/partners/logos/crazyrouter.png differ
diff --git a/assets/partners/logos/lemondata.png b/assets/partners/logos/lemondata.png
new file mode 100644
index 000000000..e12222540
Binary files /dev/null and b/assets/partners/logos/lemondata.png differ
diff --git a/assets/partners/logos/xcodeapi.png b/assets/partners/logos/xcodeapi.png
deleted file mode 100644
index 9c0b9999f..000000000
Binary files a/assets/partners/logos/xcodeapi.png and /dev/null differ
diff --git a/docs/user-manual/en/4-proxy/4.2-routing.md b/docs/user-manual/en/4-proxy/4.2-routing.md
new file mode 100644
index 000000000..44a39a9a6
--- /dev/null
+++ b/docs/user-manual/en/4-proxy/4.2-routing.md
@@ -0,0 +1,195 @@
+# 4.2 App Routing
+
+## Overview
+
+App routing means letting CC Switch route a specific application's API requests through the local routing service.
+
+When routing is enabled:
+- The app's API requests are forwarded through local routing
+- Request logs and usage statistics can be recorded
+- Failover functionality becomes available
+
+## Prerequisites
+
+The routing service must be started before using the app routing feature.
+
+## Enable Routing
+
+### Location
+
+Settings > Advanced > Routing Service > App Routing area
+
+### Steps
+
+1. Ensure the routing service is started
+2. Find the "App Routing" area
+3. Enable the toggle for the desired apps
+
+### Routing Toggles
+
+| Toggle | Effect |
+|--------|--------|
+| Claude Routing | Route Claude Code requests |
+| Codex Routing | Route Codex requests |
+| Gemini Routing | Route Gemini CLI requests |
+
+Multiple app routings can be enabled simultaneously.
+
+## How Routing Works
+
+### Configuration Changes
+
+When routing is enabled, CC Switch modifies the app's configuration file to point the API endpoint to the local routing service.
+
+**Claude configuration change**:
+
+```json
+// Before routing
+{
+ "env": {
+ "ANTHROPIC_BASE_URL": "https://api.anthropic.com"
+ }
+}
+
+// After routing
+{
+ "env": {
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
+ }
+}
+```
+
+**Codex configuration change**:
+
+```toml
+# Before routing
+base_url = "https://api.openai.com/v1"
+
+# After routing
+base_url = "http://127.0.0.1:15721/v1"
+```
+
+**Gemini configuration change**:
+
+```bash
+# Before routing
+GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
+
+# After routing
+GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
+```
+
+### Request Forwarding
+
+When the routing service receives a request:
+
+1. Identifies the request source (Claude/Codex/Gemini)
+2. Looks up the currently enabled provider for that app
+3. Forwards the request to the provider's actual endpoint
+4. Records the request log
+5. Returns the response to the app
+
+## Routing Status Indicators
+
+### Main Interface Indicators
+
+When routing is enabled, the main interface shows the following changes:
+
+- **Routing logo color**: Changes from colorless to green
+- **Provider cards**: The currently active provider shows a green border
+
+### Provider Card States
+
+| State | Border Color | Description |
+|-------|--------------|-------------|
+| Currently Active | Blue | Provider in the config file (non-routing mode) |
+| Routing Active | Green | Provider actually used by routing |
+| Normal | Default | Unused provider |
+
+## Disable Routing
+
+### Steps
+
+1. Turn off the corresponding app's routing toggle in the routing panel
+2. Or directly stop the routing service
+
+### Configuration Restoration
+
+When disabling routing, CC Switch will:
+
+1. Restore the app configuration to its pre-routing state
+2. Save current request logs
+
+## Routing and Provider Switching
+
+### Switching Providers in Routing Mode
+
+When switching providers in routing mode:
+
+1. Click the "Enable" button on a provider in the main interface
+2. The routing service immediately uses the new provider to forward requests
+3. **No need to restart the CLI tool**
+
+This is a major advantage of routing mode: provider switching takes effect instantly.
+
+### Switching Without Routing
+
+When switching providers without routing:
+
+1. Configuration file is modified
+2. CLI tool must be restarted for changes to take effect
+
+## Multi-app Routing
+
+Multiple apps can be routed simultaneously, each managed independently:
+
+- Independent provider configurations
+- Independent failover queues
+- Independent request statistics
+
+## Use Cases
+
+### Scenario 1: Usage Monitoring
+
+Enable routing + log recording to monitor API usage.
+
+### Scenario 2: Quick Switching
+
+With routing enabled, switching providers does not require restarting CLI tools.
+
+### Scenario 3: Failover
+
+Enabling routing is a prerequisite for using the failover feature.
+
+## Notes
+
+### Performance Impact
+
+Routing adds minimal latency (typically < 10ms), negligible for most scenarios.
+
+### Network Requirements
+
+In routing mode, CLI tools must be able to access the local routing address.
+
+### Configuration Backup
+
+Before enabling routing, CC Switch backs up the original configuration and restores it when disabled.
+
+## FAQ
+
+### Requests Fail After Enabling Routing
+
+Check:
+- Is the routing service running normally
+- Is the provider configuration correct
+- Is the network working properly
+
+### Configuration Not Restored After Disabling Routing
+
+Possible causes:
+- Routing service exited abnormally
+- Configuration file was modified by another program
+
+Solutions:
+- Manually edit the provider and re-save
+- Or re-enable and then disable routing
diff --git a/docs/user-manual/en/4-proxy/4.2-takeover.md b/docs/user-manual/en/4-proxy/4.2-takeover.md
deleted file mode 100644
index 4107f9bfc..000000000
--- a/docs/user-manual/en/4-proxy/4.2-takeover.md
+++ /dev/null
@@ -1,195 +0,0 @@
-# 4.2 App Takeover
-
-## Overview
-
-App takeover means letting CC Switch's proxy intercept and forward a specific application's API requests.
-
-When takeover is enabled:
-- The app's API requests are forwarded through the local proxy
-- Request logs and usage statistics can be recorded
-- Failover functionality becomes available
-
-## Prerequisites
-
-The proxy service must be started before using the app takeover feature.
-
-## Enable Takeover
-
-### Location
-
-Settings > Advanced > Proxy Service > App Takeover area
-
-### Steps
-
-1. Ensure the proxy service is started
-2. Find the "App Takeover" area
-3. Enable the toggle for the desired apps
-
-### Takeover Toggles
-
-| Toggle | Effect |
-|--------|--------|
-| Claude Takeover | Intercept Claude Code requests |
-| Codex Takeover | Intercept Codex requests |
-| Gemini Takeover | Intercept Gemini CLI requests |
-
-Multiple app takeovers can be enabled simultaneously.
-
-## How Takeover Works
-
-### Configuration Changes
-
-When takeover is enabled, CC Switch modifies the app's configuration file to point the API endpoint to the local proxy.
-
-**Claude configuration change**:
-
-```json
-// Before takeover
-{
- "env": {
- "ANTHROPIC_BASE_URL": "https://api.anthropic.com"
- }
-}
-
-// After takeover
-{
- "env": {
- "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
- }
-}
-```
-
-**Codex configuration change**:
-
-```toml
-# Before takeover
-base_url = "https://api.openai.com/v1"
-
-# After takeover
-base_url = "http://127.0.0.1:15721/v1"
-```
-
-**Gemini configuration change**:
-
-```bash
-# Before takeover
-GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
-
-# After takeover
-GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
-```
-
-### Request Forwarding
-
-When the proxy receives a request:
-
-1. Identifies the request source (Claude/Codex/Gemini)
-2. Looks up the currently enabled provider for that app
-3. Forwards the request to the provider's actual endpoint
-4. Records the request log
-5. Returns the response to the app
-
-## Takeover Status Indicators
-
-### Main Interface Indicators
-
-When takeover is enabled, the main interface shows the following changes:
-
-- **Proxy logo color**: Changes from colorless to green
-- **Provider cards**: The currently active provider shows a green border
-
-### Provider Card States
-
-| State | Border Color | Description |
-|-------|--------------|-------------|
-| Currently Active | Blue | Provider in the config file (non-proxy mode) |
-| Proxy Active | Green | Provider actually used by the proxy |
-| Normal | Default | Unused provider |
-
-## Disable Takeover
-
-### Steps
-
-1. Turn off the corresponding app's takeover toggle in the proxy panel
-2. Or directly stop the proxy service
-
-### Configuration Restoration
-
-When disabling takeover, CC Switch will:
-
-1. Restore the app configuration to its pre-takeover state
-2. Save current request logs
-
-## Takeover and Provider Switching
-
-### Switching Providers in Takeover Mode
-
-When switching providers in takeover mode:
-
-1. Click the "Enable" button on a provider in the main interface
-2. The proxy immediately uses the new provider to forward requests
-3. **No need to restart the CLI tool**
-
-This is a major advantage of takeover mode: provider switching takes effect instantly.
-
-### Switching Without Takeover
-
-When switching providers without takeover:
-
-1. Configuration file is modified
-2. CLI tool must be restarted for changes to take effect
-
-## Multi-app Takeover
-
-Multiple apps can be taken over simultaneously, each managed independently:
-
-- Independent provider configurations
-- Independent failover queues
-- Independent request statistics
-
-## Use Cases
-
-### Scenario 1: Usage Monitoring
-
-Enable takeover + log recording to monitor API usage.
-
-### Scenario 2: Quick Switching
-
-With takeover enabled, switching providers does not require restarting CLI tools.
-
-### Scenario 3: Failover
-
-Enabling takeover is a prerequisite for using the failover feature.
-
-## Notes
-
-### Performance Impact
-
-The proxy adds minimal latency (typically < 10ms), negligible for most scenarios.
-
-### Network Requirements
-
-In takeover mode, CLI tools must be able to access the local proxy address.
-
-### Configuration Backup
-
-Before enabling takeover, CC Switch backs up the original configuration and restores it when disabled.
-
-## FAQ
-
-### Requests Fail After Takeover
-
-Check:
-- Is the proxy service running normally
-- Is the provider configuration correct
-- Is the network working properly
-
-### Configuration Not Restored After Disabling Takeover
-
-Possible causes:
-- Proxy exited abnormally
-- Configuration file was modified by another program
-
-Solutions:
-- Manually edit the provider and re-save
-- Or re-enable and then disable takeover
diff --git a/docs/user-manual/en/README.md b/docs/user-manual/en/README.md
index acd6c3615..94f163b6c 100644
--- a/docs/user-manual/en/README.md
+++ b/docs/user-manual/en/README.md
@@ -79,7 +79,7 @@ CC Switch User Manual
| File | Description |
|------|-------------|
| [4.1-service.md](./4-proxy/4.1-service.md) | Start proxy, configuration, running status |
-| [4.2-takeover.md](./4-proxy/4.2-takeover.md) | App takeover, configuration changes, status indicators |
+| [4.2-routing.md](./4-proxy/4.2-routing.md) | App routing, configuration changes, status indicators |
| [4.3-failover.md](./4-proxy/4.3-failover.md) | Failover queue, circuit breaker, health status |
| [4.4-usage.md](./4-proxy/4.4-usage.md) | Usage statistics, trend charts, pricing configuration |
| [4.5-model-test.md](./4-proxy/4.5-model-test.md) | Model test, health check, latency testing |
diff --git a/docs/user-manual/ja/4-proxy/4.2-routing.md b/docs/user-manual/ja/4-proxy/4.2-routing.md
new file mode 100644
index 000000000..8a3fed7c0
--- /dev/null
+++ b/docs/user-manual/ja/4-proxy/4.2-routing.md
@@ -0,0 +1,195 @@
+# 4.2 アプリケーションルーティング
+
+## 機能説明
+
+アプリケーションルーティングとは、CC Switch のルーティングサービスが特定アプリの API リクエストをルーティングすることです。
+
+ルーティングを有効にすると:
+- アプリの API リクエストがローカルルーティング経由で転送される
+- リクエストログと使用量の統計を記録できる
+- フェイルオーバー機能を使用できる
+
+## 前提条件
+
+アプリケーションルーティング機能を使用する前に、ルーティングサービスを起動する必要があります。
+
+## ルーティングの有効化
+
+### 操作場所
+
+設定 → 詳細 → ルーティングサービス → アプリケーションルーティングエリア
+
+### 操作手順
+
+1. ルーティングサービスが起動していることを確認
+2. 「アプリケーションルーティング」エリアを見つける
+3. 必要なアプリのスイッチをオンにする
+
+### ルーティングスイッチ
+
+| スイッチ | 作用 |
+|------|------|
+| Claude ルーティング | Claude Code のリクエストをルーティング |
+| Codex ルーティング | Codex のリクエストをルーティング |
+| Gemini ルーティング | Gemini CLI のリクエストをルーティング |
+
+複数のアプリのルーティングを同時に有効にできます。
+
+## ルーティングの仕組み
+
+### 設定の変更
+
+ルーティングを有効にすると、CC Switch はアプリの設定ファイルを変更し、API エンドポイントをローカルルーティングに向けます。
+
+**Claude 設定の変更**:
+
+```json
+// ルーティング前
+{
+ "env": {
+ "ANTHROPIC_BASE_URL": "https://api.anthropic.com"
+ }
+}
+
+// ルーティング後
+{
+ "env": {
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
+ }
+}
+```
+
+**Codex 設定の変更**:
+
+```toml
+# ルーティング前
+base_url = "https://api.openai.com/v1"
+
+# ルーティング後
+base_url = "http://127.0.0.1:15721/v1"
+```
+
+**Gemini 設定の変更**:
+
+```bash
+# ルーティング前
+GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
+
+# ルーティング後
+GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
+```
+
+### リクエストの転送
+
+ルーティングサービスがリクエストを受信すると:
+
+1. リクエスト元を識別(Claude/Codex/Gemini)
+2. そのアプリで現在有効なプロバイダーを検索
+3. プロバイダーの実際のエンドポイントにリクエストを転送
+4. リクエストログを記録
+5. アプリにレスポンスを返却
+
+## ルーティングステータスの表示
+
+### メイン画面の表示
+
+ルーティングを有効にすると、メイン画面に以下の変化があります:
+
+- **ルーティング Logo の色**:無色から緑に変化
+- **プロバイダーカード**:現在アクティブなプロバイダーに緑の枠が表示
+
+### プロバイダーカードの状態
+
+| 状態 | 枠の色 | 説明 |
+|------|----------|------|
+| 現在有効 | 青 | 設定ファイル内のプロバイダー(非ルーティングモード) |
+| ルーティングアクティブ | 緑 | ルーティングが実際に使用しているプロバイダー |
+| 通常 | デフォルト | 使用されていないプロバイダー |
+
+## ルーティングの無効化
+
+### 操作手順
+
+1. ルーティングパネルで対応するアプリのルーティングスイッチをオフにする
+2. またはルーティングサービスを直接停止
+
+### 設定の復元
+
+ルーティングを無効にすると、CC Switch は以下を実行します:
+
+1. アプリの設定をルーティング前の状態に復元
+2. 現在のリクエストログを保存
+
+## ルーティングとプロバイダーの切り替え
+
+### ルーティングモードでのプロバイダー切り替え
+
+ルーティングモードでプロバイダーを切り替える場合:
+
+1. メイン画面でプロバイダーの「有効化」ボタンをクリック
+2. ルーティングサービスが新しいプロバイダーを使用してリクエストを即座に転送
+3. **CLI ツールの再起動は不要**
+
+これがルーティングモードの大きなメリットです:プロバイダーの切り替えが即座に反映されます。
+
+### 非ルーティングモードでのプロバイダー切り替え
+
+非ルーティングモードでプロバイダーを切り替える場合:
+
+1. 設定ファイルを変更
+2. CLI ツールの再起動が必要
+
+## 複数アプリのルーティング
+
+複数のアプリを同時にルーティングでき、それぞれ独立して管理されます:
+
+- 独立したプロバイダー設定
+- 独立したフェイルオーバーキュー
+- 独立したリクエスト統計
+
+## 使用シーン
+
+### シーン 1:使用量の監視
+
+ルーティング + ログ記録を有効にして、API の使用状況を監視します。
+
+### シーン 2:素早い切り替え
+
+ルーティングを有効にすると、プロバイダーの切り替えに CLI ツールの再起動が不要になります。
+
+### シーン 3:フェイルオーバー
+
+ルーティングの有効化はフェイルオーバー機能を使用するための前提条件です。
+
+## 注意事項
+
+### パフォーマンスへの影響
+
+ルーティングにより少量のレイテンシ(通常 < 10ms)が追加されますが、ほとんどのシーンでは無視できます。
+
+### ネットワーク要件
+
+ルーティングモードでは、CLI ツールがローカルルーティングアドレスにアクセスできる必要があります。
+
+### 設定のバックアップ
+
+ルーティングを有効にする前に、CC Switch は元の設定をバックアップし、無効化時に復元します。
+
+## よくある質問
+
+### ルーティング後にリクエストが失敗する
+
+確認事項:
+- ルーティングサービスが正常に実行されているか
+- プロバイダーの設定が正しいか
+- ネットワークが正常か
+
+### ルーティングを無効にしても設定が復元されない
+
+考えられる原因:
+- ルーティングサービスの異常終了
+- 設定ファイルが他のプログラムに変更された
+
+解決方法:
+- プロバイダーを手動で編集して保存し直す
+- または再度ルーティングを有効にしてから無効にする
diff --git a/docs/user-manual/ja/4-proxy/4.2-takeover.md b/docs/user-manual/ja/4-proxy/4.2-takeover.md
deleted file mode 100644
index 51da39d34..000000000
--- a/docs/user-manual/ja/4-proxy/4.2-takeover.md
+++ /dev/null
@@ -1,195 +0,0 @@
-# 4.2 アプリケーション接管
-
-## 機能説明
-
-アプリケーション接管とは、CC Switch のプロキシが特定アプリの API リクエストを接管することです。
-
-接管を有効にすると:
-- アプリの API リクエストがローカルプロキシ経由で転送される
-- リクエストログと使用量の統計を記録できる
-- フェイルオーバー機能を使用できる
-
-## 前提条件
-
-アプリケーション接管機能を使用する前に、プロキシサービスを起動する必要があります。
-
-## 接管の有効化
-
-### 操作場所
-
-設定 → 詳細 → プロキシサービス → アプリケーション接管エリア
-
-### 操作手順
-
-1. プロキシサービスが起動していることを確認
-2. 「アプリケーション接管」エリアを見つける
-3. 必要なアプリのスイッチをオンにする
-
-### 接管スイッチ
-
-| スイッチ | 作用 |
-|------|------|
-| Claude 接管 | Claude Code のリクエストを接管 |
-| Codex 接管 | Codex のリクエストを接管 |
-| Gemini 接管 | Gemini CLI のリクエストを接管 |
-
-複数のアプリの接管を同時に有効にできます。
-
-## 接管の仕組み
-
-### 設定の変更
-
-接管を有効にすると、CC Switch はアプリの設定ファイルを変更し、API エンドポイントをローカルプロキシに向けます。
-
-**Claude 設定の変更**:
-
-```json
-// 接管前
-{
- "env": {
- "ANTHROPIC_BASE_URL": "https://api.anthropic.com"
- }
-}
-
-// 接管後
-{
- "env": {
- "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
- }
-}
-```
-
-**Codex 設定の変更**:
-
-```toml
-# 接管前
-base_url = "https://api.openai.com/v1"
-
-# 接管後
-base_url = "http://127.0.0.1:15721/v1"
-```
-
-**Gemini 設定の変更**:
-
-```bash
-# 接管前
-GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
-
-# 接管後
-GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
-```
-
-### リクエストの転送
-
-プロキシがリクエストを受信すると:
-
-1. リクエスト元を識別(Claude/Codex/Gemini)
-2. そのアプリで現在有効なプロバイダーを検索
-3. プロバイダーの実際のエンドポイントにリクエストを転送
-4. リクエストログを記録
-5. アプリにレスポンスを返却
-
-## 接管ステータスの表示
-
-### メイン画面の表示
-
-接管を有効にすると、メイン画面に以下の変化があります:
-
-- **プロキシ Logo の色**:無色から緑に変化
-- **プロバイダーカード**:現在アクティブなプロバイダーに緑の枠が表示
-
-### プロバイダーカードの状態
-
-| 状態 | 枠の色 | 説明 |
-|------|----------|------|
-| 現在有効 | 青 | 設定ファイル内のプロバイダー(非プロキシモード) |
-| プロキシアクティブ | 緑 | プロキシが実際に使用しているプロバイダー |
-| 通常 | デフォルト | 使用されていないプロバイダー |
-
-## 接管の無効化
-
-### 操作手順
-
-1. プロキシパネルで対応するアプリの接管スイッチをオフにする
-2. またはプロキシサービスを直接停止
-
-### 設定の復元
-
-接管を無効にすると、CC Switch は以下を実行します:
-
-1. アプリの設定を接管前の状態に復元
-2. 現在のリクエストログを保存
-
-## 接管とプロバイダーの切り替え
-
-### 接管モードでのプロバイダー切り替え
-
-接管モードでプロバイダーを切り替える場合:
-
-1. メイン画面でプロバイダーの「有効化」ボタンをクリック
-2. プロキシが新しいプロバイダーを使用してリクエストを即座に転送
-3. **CLI ツールの再起動は不要**
-
-これが接管モードの大きなメリットです:プロバイダーの切り替えが即座に反映されます。
-
-### 非接管モードでのプロバイダー切り替え
-
-非接管モードでプロバイダーを切り替える場合:
-
-1. 設定ファイルを変更
-2. CLI ツールの再起動が必要
-
-## 複数アプリの接管
-
-複数のアプリを同時に接管でき、それぞれ独立して管理されます:
-
-- 独立したプロバイダー設定
-- 独立したフェイルオーバーキュー
-- 独立したリクエスト統計
-
-## 使用シーン
-
-### シーン 1:使用量の監視
-
-接管 + ログ記録を有効にして、API の使用状況を監視します。
-
-### シーン 2:素早い切り替え
-
-接管を有効にすると、プロバイダーの切り替えに CLI ツールの再起動が不要になります。
-
-### シーン 3:フェイルオーバー
-
-接管の有効化はフェイルオーバー機能を使用するための前提条件です。
-
-## 注意事項
-
-### パフォーマンスへの影響
-
-プロキシにより少量のレイテンシ(通常 < 10ms)が追加されますが、ほとんどのシーンでは無視できます。
-
-### ネットワーク要件
-
-接管モードでは、CLI ツールがローカルプロキシアドレスにアクセスできる必要があります。
-
-### 設定のバックアップ
-
-接管を有効にする前に、CC Switch は元の設定をバックアップし、無効化時に復元します。
-
-## よくある質問
-
-### 接管後にリクエストが失敗する
-
-確認事項:
-- プロキシサービスが正常に実行されているか
-- プロバイダーの設定が正しいか
-- ネットワークが正常か
-
-### 接管を無効にしても設定が復元されない
-
-考えられる原因:
-- プロキシの異常終了
-- 設定ファイルが他のプログラムに変更された
-
-解決方法:
-- プロバイダーを手動で編集して保存し直す
-- または接管を再度有効にしてから無効にする
diff --git a/docs/user-manual/ja/README.md b/docs/user-manual/ja/README.md
index 7856be42e..1b8d03c35 100644
--- a/docs/user-manual/ja/README.md
+++ b/docs/user-manual/ja/README.md
@@ -79,7 +79,7 @@ CC Switch ユーザーマニュアル
| ファイル | 内容 |
|------|------|
| [4.1-service.md](./4-proxy/4.1-service.md) | プロキシの起動、設定項目、実行状態 |
-| [4.2-takeover.md](./4-proxy/4.2-takeover.md) | アプリケーション接管、設定変更、ステータス表示 |
+| [4.2-routing.md](./4-proxy/4.2-routing.md) | アプリケーションルーティング、設定変更、ステータス表示 |
| [4.3-failover.md](./4-proxy/4.3-failover.md) | フェイルオーバーキュー、サーキットブレーカー、ヘルスステータス |
| [4.4-usage.md](./4-proxy/4.4-usage.md) | 使用量統計、トレンドグラフ、料金設定 |
| [4.5-model-test.md](./4-proxy/4.5-model-test.md) | モデルテスト、ヘルスチェック、レイテンシテスト |
diff --git a/docs/user-manual/zh/4-proxy/4.2-routing.md b/docs/user-manual/zh/4-proxy/4.2-routing.md
new file mode 100644
index 000000000..92ceb3daa
--- /dev/null
+++ b/docs/user-manual/zh/4-proxy/4.2-routing.md
@@ -0,0 +1,195 @@
+# 4.2 应用路由
+
+## 功能说明
+
+应用路由是指让 CC Switch 路由特定应用的 API 请求。
+
+开启路由后:
+- 应用的 API 请求会通过本地路由转发
+- 可以记录请求日志和统计用量
+- 可以使用故障转移功能
+
+## 前提条件
+
+使用应用路由功能前,需要先启动路由服务。
+
+## 开启路由
+
+### 操作位置
+
+设置 → 高级 → 路由服务 → 应用路由区域
+
+### 操作步骤
+
+1. 确保路由服务已启动
+2. 找到「应用路由」区域
+3. 为需要的应用开启开关
+
+### 路由开关
+
+| 开关 | 作用 |
+|------|------|
+| Claude 路由 | 路由 Claude Code 的请求 |
+| Codex 路由 | 路由 Codex 的请求 |
+| Gemini 路由 | 路由 Gemini CLI 的请求 |
+
+可以同时开启多个应用的路由。
+
+## 路由原理
+
+### 配置修改
+
+开启路由后,CC Switch 会修改应用的配置文件,将 API 端点指向本地路由。
+
+**Claude 配置变更**:
+
+```json
+// 路由前
+{
+ "env": {
+ "ANTHROPIC_BASE_URL": "https://api.anthropic.com"
+ }
+}
+
+// 路由后
+{
+ "env": {
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
+ }
+}
+```
+
+**Codex 配置变更**:
+
+```toml
+# 路由前
+base_url = "https://api.openai.com/v1"
+
+# 路由后
+base_url = "http://127.0.0.1:15721/v1"
+```
+
+**Gemini 配置变更**:
+
+```bash
+# 路由前
+GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
+
+# 路由后
+GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
+```
+
+### 请求转发
+
+路由收到请求后:
+
+1. 识别请求来源(Claude/Codex/Gemini)
+2. 查找该应用当前启用的供应商
+3. 将请求转发到供应商的实际端点
+4. 记录请求日志
+5. 返回响应给应用
+
+## 路由状态指示
+
+### 主界面指示
+
+开启路由后,主界面会有以下变化:
+
+- **路由 Logo 颜色**:从无色变为绿色
+- **供应商卡片**:当前活跃的供应商显示绿色边框
+
+### 供应商卡片状态
+
+| 状态 | 边框颜色 | 说明 |
+|------|----------|------|
+| 当前启用 | 蓝色 | 配置文件中的供应商(非路由模式) |
+| 路由活跃 | 绿色 | 路由实际使用的供应商 |
+| 普通 | 默认 | 未使用的供应商 |
+
+## 关闭路由
+
+### 操作步骤
+
+1. 在路由面板中关闭对应应用的路由开关
+2. 或直接停止路由服务
+
+### 配置恢复
+
+关闭路由时,CC Switch 会:
+
+1. 将应用配置恢复到路由前的状态
+2. 保存当前的请求日志
+
+## 路由与供应商切换
+
+### 路由模式下切换供应商
+
+在路由模式下切换供应商:
+
+1. 在主界面点击供应商的「启用」按钮
+2. 路由立即使用新供应商转发请求
+3. **无需重启 CLI 工具**
+
+这是路由模式的一大优势:切换供应商即时生效。
+
+### 非路由模式下切换
+
+在非路由模式下切换供应商:
+
+1. 修改配置文件
+2. 需要重启 CLI 工具才能生效
+
+## 多应用路由
+
+可以同时路由多个应用,每个应用独立管理:
+
+- 独立的供应商配置
+- 独立的故障转移队列
+- 独立的请求统计
+
+## 使用场景
+
+### 场景一:用量监控
+
+开启路由 + 日志记录,监控 API 使用情况。
+
+### 场景二:快速切换
+
+开启路由后,切换供应商无需重启 CLI 工具。
+
+### 场景三:故障转移
+
+开启路由是使用故障转移功能的前提。
+
+## 注意事项
+
+### 性能影响
+
+路由会增加少量延迟(通常 < 10ms),对于大多数场景可以忽略。
+
+### 网络要求
+
+路由模式下,CLI 工具需要能够访问本地路由地址。
+
+### 配置备份
+
+开启路由前,CC Switch 会备份原始配置,关闭时恢复。
+
+## 常见问题
+
+### 路由后请求失败
+
+检查:
+- 路由服务是否正常运行
+- 供应商配置是否正确
+- 网络是否正常
+
+### 关闭路由后配置未恢复
+
+可能原因:
+- 路由异常退出
+- 配置文件被其他程序修改
+
+解决方法:
+- 手动编辑供应商,重新保存
+- 或重新启用再关闭路由
diff --git a/docs/user-manual/zh/4-proxy/4.2-takeover.md b/docs/user-manual/zh/4-proxy/4.2-takeover.md
deleted file mode 100644
index 9054dcd44..000000000
--- a/docs/user-manual/zh/4-proxy/4.2-takeover.md
+++ /dev/null
@@ -1,195 +0,0 @@
-# 4.2 应用接管
-
-## 功能说明
-
-应用接管是指让 CC Switch 代理接管特定应用的 API 请求。
-
-开启接管后:
-- 应用的 API 请求会通过本地代理转发
-- 可以记录请求日志和统计用量
-- 可以使用故障转移功能
-
-## 前提条件
-
-使用应用接管功能前,需要先启动代理服务。
-
-## 开启接管
-
-### 操作位置
-
-设置 → 高级 → 代理服务 → 应用接管区域
-
-### 操作步骤
-
-1. 确保代理服务已启动
-2. 找到「应用接管」区域
-3. 为需要的应用开启开关
-
-### 接管开关
-
-| 开关 | 作用 |
-|------|------|
-| Claude 接管 | 接管 Claude Code 的请求 |
-| Codex 接管 | 接管 Codex 的请求 |
-| Gemini 接管 | 接管 Gemini CLI 的请求 |
-
-可以同时开启多个应用的接管。
-
-## 接管原理
-
-### 配置修改
-
-开启接管后,CC Switch 会修改应用的配置文件,将 API 端点指向本地代理。
-
-**Claude 配置变更**:
-
-```json
-// 接管前
-{
- "env": {
- "ANTHROPIC_BASE_URL": "https://api.anthropic.com"
- }
-}
-
-// 接管后
-{
- "env": {
- "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
- }
-}
-```
-
-**Codex 配置变更**:
-
-```toml
-# 接管前
-base_url = "https://api.openai.com/v1"
-
-# 接管后
-base_url = "http://127.0.0.1:15721/v1"
-```
-
-**Gemini 配置变更**:
-
-```bash
-# 接管前
-GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
-
-# 接管后
-GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
-```
-
-### 请求转发
-
-代理收到请求后:
-
-1. 识别请求来源(Claude/Codex/Gemini)
-2. 查找该应用当前启用的供应商
-3. 将请求转发到供应商的实际端点
-4. 记录请求日志
-5. 返回响应给应用
-
-## 接管状态指示
-
-### 主界面指示
-
-开启接管后,主界面会有以下变化:
-
-- **代理 Logo 颜色**:从无色变为绿色
-- **供应商卡片**:当前活跃的供应商显示绿色边框
-
-### 供应商卡片状态
-
-| 状态 | 边框颜色 | 说明 |
-|------|----------|------|
-| 当前启用 | 蓝色 | 配置文件中的供应商(非代理模式) |
-| 代理活跃 | 绿色 | 代理实际使用的供应商 |
-| 普通 | 默认 | 未使用的供应商 |
-
-## 关闭接管
-
-### 操作步骤
-
-1. 在代理面板中关闭对应应用的接管开关
-2. 或直接停止代理服务
-
-### 配置恢复
-
-关闭接管时,CC Switch 会:
-
-1. 将应用配置恢复到接管前的状态
-2. 保存当前的请求日志
-
-## 接管与供应商切换
-
-### 接管模式下切换供应商
-
-在接管模式下切换供应商:
-
-1. 在主界面点击供应商的「启用」按钮
-2. 代理立即使用新供应商转发请求
-3. **无需重启 CLI 工具**
-
-这是接管模式的一大优势:切换供应商即时生效。
-
-### 非接管模式下切换
-
-在非接管模式下切换供应商:
-
-1. 修改配置文件
-2. 需要重启 CLI 工具才能生效
-
-## 多应用接管
-
-可以同时接管多个应用,每个应用独立管理:
-
-- 独立的供应商配置
-- 独立的故障转移队列
-- 独立的请求统计
-
-## 使用场景
-
-### 场景一:用量监控
-
-开启接管 + 日志记录,监控 API 使用情况。
-
-### 场景二:快速切换
-
-开启接管后,切换供应商无需重启 CLI 工具。
-
-### 场景三:故障转移
-
-开启接管是使用故障转移功能的前提。
-
-## 注意事项
-
-### 性能影响
-
-代理会增加少量延迟(通常 < 10ms),对于大多数场景可以忽略。
-
-### 网络要求
-
-接管模式下,CLI 工具需要能够访问本地代理地址。
-
-### 配置备份
-
-开启接管前,CC Switch 会备份原始配置,关闭时恢复。
-
-## 常见问题
-
-### 接管后请求失败
-
-检查:
-- 代理服务是否正常运行
-- 供应商配置是否正确
-- 网络是否正常
-
-### 关闭接管后配置未恢复
-
-可能原因:
-- 代理异常退出
-- 配置文件被其他程序修改
-
-解决方法:
-- 手动编辑供应商,重新保存
-- 或重新启用再关闭接管
diff --git a/docs/user-manual/zh/README.md b/docs/user-manual/zh/README.md
index 1f6efba15..7a483face 100644
--- a/docs/user-manual/zh/README.md
+++ b/docs/user-manual/zh/README.md
@@ -79,7 +79,7 @@
| 文件 | 内容 |
|------|------|
| [4.1-service.md](./4-proxy/4.1-service.md) | 启动代理、配置项、运行状态 |
-| [4.2-takeover.md](./4-proxy/4.2-takeover.md) | 应用接管、配置修改、状态指示 |
+| [4.2-routing.md](./4-proxy/4.2-routing.md) | 应用路由、配置修改、状态指示 |
| [4.3-failover.md](./4-proxy/4.3-failover.md) | 故障转移队列、熔断器、健康状态 |
| [4.4-usage.md](./4-proxy/4.4-usage.md) | 用量统计、趋势图表、定价配置 |
| [4.5-model-test.md](./4-proxy/4.5-model-test.md) | 模型检查、健康检测、延迟测试 |
diff --git a/package.json b/package.json
index 5301a971a..d07ecbf96 100644
--- a/package.json
+++ b/package.json
@@ -67,6 +67,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tanstack/react-query": "^5.90.3",
+ "@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-process": "^2.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6c791d5e8..9fe22cfc0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -89,6 +89,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.90.3
version: 5.90.3(react@18.3.1)
+ '@tanstack/react-virtual':
+ specifier: ^3.13.23
+ version: 3.13.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tauri-apps/api':
specifier: ^2.8.0
version: 2.8.0
@@ -1468,6 +1471,15 @@ packages:
peerDependencies:
react: ^18 || ^19
+ '@tanstack/react-virtual@3.13.23':
+ resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@tanstack/virtual-core@3.13.23':
+ resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
+
'@tauri-apps/api@2.8.0':
resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==}
@@ -4275,6 +4287,14 @@ snapshots:
'@tanstack/query-core': 5.90.3
react: 18.3.1
+ '@tanstack/react-virtual@3.13.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@tanstack/virtual-core': 3.13.23
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ '@tanstack/virtual-core@3.13.23': {}
+
'@tauri-apps/api@2.8.0': {}
'@tauri-apps/cli-darwin-arm64@2.8.1':
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 5614cb25e..8132efe91 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -11,6 +11,11 @@
"updater:default",
"core:window:allow-set-skip-taskbar",
"core:window:allow-start-dragging",
+ "core:window:allow-minimize",
+ "core:window:allow-toggle-maximize",
+ "core:window:allow-is-maximized",
+ "core:window:allow-close",
+ "core:window:allow-set-decorations",
"process:allow-restart",
"dialog:default"
]
diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs
index c0daa601c..fbab52858 100644
--- a/src-tauri/src/commands/proxy.rs
+++ b/src-tauri/src/commands/proxy.rs
@@ -253,6 +253,19 @@ pub async fn switch_proxy_provider(
app_type: String,
provider_id: String,
) -> Result<(), String> {
+ // Block official providers during proxy takeover
+ let provider = state
+ .db
+ .get_provider_by_id(&provider_id, &app_type)
+ .map_err(|e| format!("读取供应商失败: {e}"))?
+ .ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
+ if provider.category.as_deref() == Some("official") {
+ return Err(
+ "代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
+ .to_string(),
+ );
+ }
+
state
.proxy_service
.switch_proxy_target(&app_type, &provider_id)
diff --git a/src-tauri/src/commands/stream_check.rs b/src-tauri/src/commands/stream_check.rs
index b7d5ea0b8..be0de3f12 100644
--- a/src-tauri/src/commands/stream_check.rs
+++ b/src-tauri/src/commands/stream_check.rs
@@ -116,15 +116,24 @@ pub async fn stream_check_all_providers(
claude_api_format_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,
+ .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,
+ }
});
let _ = state
diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs
index 1e5f1fe23..1c7618072 100644
--- a/src-tauri/src/database/mod.rs
+++ b/src-tauri/src/database/mod.rs
@@ -44,7 +44,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
-pub(crate) const SCHEMA_VERSION: i32 = 8;
+pub(crate) const SCHEMA_VERSION: i32 = 9;
/// 安全地序列化 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 d755116fd..295641457 100644
--- a/src-tauri/src/database/schema.rs
+++ b/src-tauri/src/database/schema.rs
@@ -418,6 +418,11 @@ impl Database {
Self::migrate_v7_to_v8(conn)?;
Self::set_user_version(conn, 8)?;
}
+ 8 => {
+ log::info!("迁移数据库从 v8 到 v9(全面补充模型定价)");
+ Self::migrate_v8_to_v9(conn)?;
+ Self::set_user_version(conn, 9)?;
+ }
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -1144,6 +1149,25 @@ impl Database {
Ok(())
}
+ /// v8 → v9: 全面补充模型定价(清空 + 重新 seed)
+ fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> {
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS model_pricing (
+ model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
+ input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
+ cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
+ cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
+ )",
+ [],
+ )
+ .map_err(|e| AppError::Database(format!("创建 model_pricing 表失败: {e}")))?;
+ conn.execute("DELETE FROM model_pricing", [])
+ .map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
+ Self::seed_model_pricing(conn)?;
+ log::info!("v8 -> v9 迁移完成:已刷新全部模型定价数据");
+ Ok(())
+ }
+
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
@@ -1491,6 +1515,38 @@ impl Database {
"0.02",
"0",
),
+ (
+ "doubao-seed-2-0-pro",
+ "Doubao Seed 2.0 Pro",
+ "0.47",
+ "2.37",
+ "0",
+ "0",
+ ),
+ (
+ "doubao-seed-2-0-code",
+ "Doubao Seed 2.0 Code",
+ "0.47",
+ "2.37",
+ "0",
+ "0",
+ ),
+ (
+ "doubao-seed-2-0-lite",
+ "Doubao Seed 2.0 Lite",
+ "0.25",
+ "2",
+ "0",
+ "0",
+ ),
+ (
+ "doubao-seed-2-0-mini",
+ "Doubao Seed 2.0 Mini",
+ "0.03",
+ "0.31",
+ "0",
+ "0",
+ ),
// DeepSeek 系列
(
"deepseek-v3.2",
@@ -1512,17 +1568,17 @@ impl Database {
(
"deepseek-chat",
"DeepSeek Chat",
- "0.28",
- "0.42",
- "0.028",
+ "0.27",
+ "1.10",
+ "0.07",
"0",
),
(
"deepseek-reasoner",
"DeepSeek Reasoner",
- "0.28",
- "0.42",
- "0.028",
+ "0.55",
+ "2.19",
+ "0.14",
"0",
),
// Kimi (月之暗面)
@@ -1543,7 +1599,7 @@ impl Database {
"0.14",
"0",
),
- ("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"),
+ ("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"),
// MiniMax 系列
("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
(
@@ -1555,35 +1611,211 @@ 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-lightning",
+ "MiniMax M2.5 Lightning",
+ "0.30",
+ "2.40",
+ "0.03",
+ "0",
+ ),
+ (
+ "minimax-m2.7",
+ "MiniMax M2.7",
+ "0.30",
+ "1.20",
+ "0.06",
+ "0.375",
+ ),
+ (
+ "minimax-m2.7-highspeed",
+ "MiniMax M2.7 Highspeed",
+ "0.60",
+ "2.40",
+ "0.06",
+ "0.375",
+ ),
// 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"),
- // Mimo (小米)
+ ("glm-5", "GLM-5", "0.72", "2.30", "0", "0"),
+ ("glm-5.1", "GLM-5.1", "0.95", "3.15", "0", "0"),
+ // MiMo (小米)
(
"mimo-v2-flash",
- "Mimo V2 Flash",
+ "MiMo V2 Flash",
"0.09",
"0.29",
"0.009",
"0",
),
+ ("mimo-v2-pro", "MiMo V2 Pro", "1", "3", "0", "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-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
+ (
+ "qwen3-235b-a22b",
+ "Qwen3 235B-A22B",
+ "0.70",
+ "8.40",
+ "0",
+ "0",
+ ),
+ (
+ "qwen3-coder-plus",
+ "Qwen3 Coder Plus",
+ "0.65",
+ "3.25",
+ "0",
+ "0",
+ ),
+ (
+ "qwen3-coder-flash",
+ "Qwen3 Coder Flash",
+ "0.195",
+ "0.975",
+ "0",
+ "0",
+ ),
+ (
+ "qwen3-coder-next",
+ "Qwen3 Coder Next",
+ "0.12",
+ "0.75",
+ "0",
+ "0",
+ ),
+ ("qwq-plus", "QwQ Plus", "0.80", "2.40", "0", "0"),
+ ("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"),
+ ("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
+ // Grok 系列 (xAI)
+ (
+ "grok-4.20-0309-reasoning",
+ "Grok 4.20 Reasoning",
+ "2",
+ "6",
+ "0.20",
+ "0",
+ ),
+ (
+ "grok-4.20-0309-non-reasoning",
+ "Grok 4.20",
+ "2",
+ "6",
+ "0.20",
+ "0",
+ ),
+ (
+ "grok-4-1-fast-reasoning",
+ "Grok 4.1 Fast Reasoning",
+ "0.20",
+ "0.50",
+ "0.05",
+ "0",
+ ),
+ (
+ "grok-4-1-fast-non-reasoning",
+ "Grok 4.1 Fast",
+ "0.20",
+ "0.50",
+ "0.05",
+ "0",
+ ),
+ ("grok-4", "Grok 4", "3", "15", "0.75", "0"),
+ (
+ "grok-code-fast-1",
+ "Grok Code Fast",
+ "0.20",
+ "1.50",
+ "0.02",
+ "0",
+ ),
+ ("grok-3", "Grok 3", "3", "15", "0.75", "0"),
+ ("grok-3-mini", "Grok 3 Mini", "0.25", "0.50", "0.075", "0"),
+ // Mistral 系列
+ ("codestral-2508", "Codestral", "0.30", "0.90", "0.03", "0"),
+ (
+ "devstral-small-1.1",
+ "Devstral Small 1.1",
+ "0.07",
+ "0.28",
+ "0.01",
+ "0",
+ ),
+ ("devstral-2-2512", "Devstral 2", "0.40", "0.90", "0.04", "0"),
+ (
+ "devstral-medium",
+ "Devstral Medium",
+ "0.40",
+ "2",
+ "0.04",
+ "0",
+ ),
+ (
+ "mistral-large-3-2512",
+ "Mistral Large 3",
+ "0.50",
+ "1.50",
+ "0.05",
+ "0",
+ ),
+ (
+ "mistral-medium-3.1",
+ "Mistral Medium 3.1",
+ "0.40",
+ "2",
+ "0.04",
+ "0",
+ ),
+ (
+ "mistral-small-3.2-24b",
+ "Mistral Small 3.2",
+ "0.075",
+ "0.20",
+ "0.01",
+ "0",
+ ),
+ ("magistral-medium", "Magistral Medium", "2", "5", "0", "0"),
+ // Cohere 系列
+ ("command-a", "Cohere Command A", "2.50", "10", "0", "0"),
+ (
+ "command-r-plus",
+ "Cohere Command R+",
+ "2.50",
+ "10",
+ "0",
+ "0",
+ ),
+ ("command-r", "Cohere Command R", "0.15", "0.60", "0", "0"),
+ // OpenAI 补充
+ ("o3-pro", "OpenAI o3-pro", "20", "80", "0", "0"),
+ ("o3-mini", "OpenAI o3-mini", "0.55", "2.20", "0.55", "0"),
+ ("o1", "OpenAI o1", "15", "60", "7.50", "0"),
+ ("o1-mini", "OpenAI o1-mini", "0.55", "2.20", "0.55", "0"),
+ ("codex-mini", "Codex Mini", "0.75", "3", "0.025", "0"),
+ ("gpt-5-mini", "GPT-5 Mini", "0.25", "2", "0.025", "0"),
+ ("gpt-5-nano", "GPT-5 Nano", "0.05", "0.40", "0.005", "0"),
];
- for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
- conn.execute(
+ let mut stmt = conn
+ .prepare(
"INSERT OR IGNORE INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
- rusqlite::params![
- model_id,
- display_name,
- input,
- output,
- cache_read,
- cache_creation
- ],
)
+ .map_err(|e| AppError::Database(format!("准备模型定价语句失败: {e}")))?;
+ for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
+ stmt.execute(rusqlite::params![
+ model_id,
+ display_name,
+ input,
+ output,
+ cache_read,
+ cache_creation
+ ])
.map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?;
}
diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs
index 4c5790654..04509626a 100644
--- a/src-tauri/src/error.rs
+++ b/src-tauri/src/error.rs
@@ -44,6 +44,8 @@ pub enum AppError {
McpValidation(String),
#[error("{0}")]
Message(String),
+ #[error("HTTP {status}: {body}")]
+ HttpStatus { status: u16, body: String },
#[error("{zh} ({en})")]
Localized {
key: &'static str,
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 7f17c0b43..dbfb63eae 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -971,6 +971,10 @@ pub fn run() {
// 静默启动:根据设置决定是否显示主窗口
let settings = crate::settings::get_settings();
if let Some(window) = app.get_webview_window("main") {
+ // 在窗口首次显示前同步装饰状态,避免前端加载后再切换导致标题栏闪烁
+ // 仅 Linux 生效:解决 Wayland 下系统窗口按钮不可用的问题
+ #[cfg(target_os = "linux")]
+ let _ = window.set_decorations(!settings.use_app_window_controls);
if settings.silent_startup {
// 静默启动模式:保持窗口隐藏
let _ = window.hide();
diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs
index beb43b607..4e659e874 100644
--- a/src-tauri/src/opencode_config.rs
+++ b/src-tauri/src/opencode_config.rs
@@ -61,7 +61,12 @@ pub fn read_opencode_config() -> Result {
}
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
- serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
+ json5::from_str(&content).map_err(|e| {
+ AppError::Config(format!(
+ "Failed to parse OpenCode config: {}: {e}",
+ path.display()
+ ))
+ })
}
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs
index 67616853c..af3af3f4b 100644
--- a/src-tauri/src/provider.rs
+++ b/src-tauri/src/provider.rs
@@ -172,29 +172,6 @@ pub struct ProviderTestConfig {
pub max_retries: Option,
}
-/// 供应商单独的代理配置
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct ProviderProxyConfig {
- /// 是否启用单独配置(false 时使用全局/系统代理)
- #[serde(default)]
- pub enabled: bool,
- /// 代理类型:http, https, socks5
- #[serde(rename = "proxyType", skip_serializing_if = "Option::is_none")]
- pub proxy_type: Option,
- /// 代理主机
- #[serde(rename = "proxyHost", skip_serializing_if = "Option::is_none")]
- pub proxy_host: Option,
- /// 代理端口
- #[serde(rename = "proxyPort", skip_serializing_if = "Option::is_none")]
- pub proxy_port: Option,
- /// 代理用户名(可选)
- #[serde(rename = "proxyUsername", skip_serializing_if = "Option::is_none")]
- pub proxy_username: Option,
- /// 代理密码(可选)
- #[serde(rename = "proxyPassword", skip_serializing_if = "Option::is_none")]
- pub proxy_password: Option,
-}
-
/// 认证绑定来源
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
@@ -262,9 +239,6 @@ pub struct ProviderMeta {
/// 供应商单独的模型测试配置
#[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")]
pub test_config: Option,
- /// 供应商单独的代理配置
- #[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
- pub proxy_config: Option,
/// Claude API 格式(仅 Claude 供应商使用)
/// - "anthropic": 原生 Anthropic Messages API,直接透传
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
@@ -282,9 +256,9 @@ pub struct ProviderMeta {
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
pub is_full_url: Option,
- /// Prompt cache key for OpenAI-compatible endpoints.
- /// When set, injected into converted requests to improve cache hit rate.
- /// If not set, provider ID is used automatically during format conversion.
+ /// Prompt cache key for OpenAI Responses-compatible endpoints.
+ /// When set, injected into converted Responses requests to improve cache hit rate.
+ /// If not set, provider ID is used automatically during Claude -> Responses conversion.
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option,
/// 累加模式应用中,该 provider 是否已写入 live config。
diff --git a/src-tauri/src/proxy/copilot_optimizer.rs b/src-tauri/src/proxy/copilot_optimizer.rs
index 0092e02a5..a58d1ef26 100644
--- a/src-tauri/src/proxy/copilot_optimizer.rs
+++ b/src-tauri/src/proxy/copilot_optimizer.rs
@@ -8,6 +8,8 @@
//!
//! 参考实现: https://github.com/caozhiyuan/copilot-api
+use std::collections::HashSet;
+
use serde_json::Value;
use sha2::{Digest, Sha256};
use uuid::Uuid;
@@ -21,6 +23,9 @@ pub struct CopilotClassification {
pub is_warmup: bool,
/// 是否为上下文压缩请求
pub is_compact: bool,
+ /// 是否为 Claude Code 子代理请求(Agent tool 生成的 subagent)
+ /// 子代理请求应设置 x-interaction-type=conversation-subagent,不计 premium interaction
+ pub is_subagent: bool,
}
/// 分类 Anthropic 格式的请求体,决定 Copilot 请求头。
@@ -38,12 +43,17 @@ pub struct CopilotClassification {
///
/// `compact_detection`:是否启用 compact 检测。为 false 时跳过,
/// 确保 `CopilotOptimizerConfig.compact_detection` 开关真正生效。
+///
+/// `subagent_detection`:是否启用子代理检测。为 true 时,会扫描首条用户消息
+/// 中的 `__SUBAGENT_MARKER__` 标记,将子代理请求标记为不计费。
pub fn classify_request(
body: &Value,
has_anthropic_beta: bool,
compact_detection: bool,
+ subagent_detection: bool,
) -> CopilotClassification {
let is_compact = compact_detection && is_compact_request(body);
+ let is_subagent = subagent_detection && detect_subagent(body);
let messages = match body.get("messages").and_then(|m| m.as_array()) {
Some(msgs) if !msgs.is_empty() => msgs,
@@ -52,6 +62,7 @@ pub fn classify_request(
initiator: "user",
is_warmup: is_warmup_request(body, has_anthropic_beta, false),
is_compact: false,
+ is_subagent,
}
}
};
@@ -62,29 +73,33 @@ pub fn classify_request(
// 只有 role=user 的消息需要细分
if role != "user" {
return CopilotClassification {
- initiator: "user",
+ initiator: if is_subagent { "agent" } else { "user" },
is_warmup: false,
is_compact,
+ is_subagent,
};
}
- // 参考实现的判定逻辑(Messages API 路径):
- // 如果 content 是数组,检查是否有非 tool_result 的 block
- // 有 → "user",全是 tool_result → "agent"
- // 如果 content 是字符串 → "user"
+ // 判定逻辑(与 copilot-api 的 merge-then-classify 效果对齐):
+ // 只要 content 数组中包含 tool_result → 视为工具续写 → agent
+ // 这覆盖了 skill/edit hook/plan follow-up 等常见场景,
+ // 它们的 content 通常是 [tool_result, text] 混合形态。
+ // copilot-api 通过先 merge(text 吸收进 tool_result)再 classify 实现同等效果;
+ // 直接在分类层处理更稳健,不依赖 merge 启用状态和执行顺序。
let is_user_initiated = match last_msg.get("content") {
Some(content) if content.is_array() => {
let blocks = content.as_array().unwrap();
- // 存在非 tool_result block → 用户发起
- blocks
+ // 含有 tool_result → 工具续写(agent),否则 → 用户发起(user)
+ !blocks
.iter()
- .any(|block| block.get("type").and_then(|t| t.as_str()) != Some("tool_result"))
+ .any(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_result"))
}
Some(content) if content.is_string() => true,
_ => false,
};
- let initiator = if !is_user_initiated || is_compact {
+ // 子代理请求始终标记为 agent(即使首条消息包含用户文本)
+ let initiator = if is_subagent || !is_user_initiated || is_compact {
"agent"
} else {
"user"
@@ -94,6 +109,7 @@ pub fn classify_request(
initiator,
is_warmup: initiator == "user" && is_warmup_request(body, has_anthropic_beta, is_compact),
is_compact,
+ is_subagent,
}
}
@@ -123,23 +139,11 @@ fn is_warmup_request(body: &Value, has_anthropic_beta: bool, is_compact: bool) -
fn is_compact_request(body: &Value) -> bool {
// 信号 1: system prompt 以 Claude Code compact 专用前缀开头
// 用户在 Claude Code 中无法直接控制 system prompt,这是最可靠的信号
- if let Some(system) = body.get("system") {
- let system_text = if let Some(s) = system.as_str() {
- s.to_string()
- } else if let Some(arr) = system.as_array() {
- arr.iter()
- .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
- .collect::>()
- .join(" ")
- } else {
- String::new()
- };
-
- if system_text
- .starts_with("You are a helpful AI assistant tasked with summarizing conversations")
- {
- return true;
- }
+ let system_text = extract_system_text(body);
+ if system_text
+ .starts_with("You are a helpful AI assistant tasked with summarizing conversations")
+ {
+ return true;
}
// 信号 2 & 3: 检查最后一条用户消息中的机器生成特征
@@ -259,8 +263,9 @@ pub fn merge_tool_results(mut body: Value) -> Value {
/// 基于最后一条用户消息内容生成确定性 Request ID。
///
-/// 与参考实现对齐:
+/// CC Switch 额外策略(参考项目 copilot-api 使用随机 UUID):
/// - 哈希输入: sessionId + lastUserContent(排除 tool_result 和 cache_control)
+/// - 相同内容产生相同 ID,可能帮助 Copilot 去重
/// - 找不到用户内容时退化为随机 UUID
/// - 使用 UUID v4 格式
pub fn deterministic_request_id(body: &Value, session_id: &str) -> String {
@@ -285,8 +290,176 @@ pub fn deterministic_request_id(body: &Value, session_id: &str) -> String {
}
}
+/// 基于 session ID 生成稳定的 Interaction ID。
+///
+/// 与参考实现(copilot-api session.ts)对齐:
+/// - 同一主对话的所有请求共享同一个 interaction ID
+/// - 哈希输入: 仅 session ID(不包含消息内容,与 request ID 不同)
+/// - Copilot 用此 ID 将请求聚合为同一个 "interaction",影响 premium 计费归属
+/// - 空 session ID 时返回 None(不应注入随机值,避免 interaction 碎片化)
+pub fn deterministic_interaction_id(session_id: &str) -> Option {
+ if session_id.is_empty() {
+ return None;
+ }
+
+ let mut hasher = Sha256::new();
+ hasher.update(b"interaction:");
+ hasher.update(session_id.as_bytes());
+ let result = hasher.finalize();
+
+ let mut bytes = [0u8; 16];
+ bytes.copy_from_slice(&result[..16]);
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
+
+ Some(Uuid::from_bytes(bytes).to_string())
+}
+
+/// 检测请求是否来自 Claude Code 子代理(Agent tool 生成的 subagent)。
+///
+/// Claude Code 的 Agent tool 会在子代理首条用户消息的 `` 标签中
+/// 注入 `__SUBAGENT_MARKER__` JSON 标记,格式如:
+/// ```json
+/// {"__SUBAGENT_MARKER__": {"session_id": "...", "agent_id": "...", "agent_type": "..."}}
+/// ```
+///
+/// 扫描策略(与 copilot-api 的 subagent-marker.ts 对齐):
+/// 1. 遍历所有 user 消息(不仅是第一条,因为 context 压缩可能重排消息)
+/// 2. 在消息文本中查找 `__SUBAGENT_MARKER__` 关键字
+/// 3. 找到即判定为子代理请求
+fn detect_subagent(body: &Value) -> bool {
+ // 信号 1: 显式 __SUBAGENT_MARKER__(Claude Code 2.x+ 自动注入)
+ if extract_system_text(body).contains("__SUBAGENT_MARKER__") {
+ return true;
+ }
+
+ if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) {
+ for msg in messages {
+ if msg.get("role").and_then(|r| r.as_str()) != Some("user") {
+ continue;
+ }
+ let text = extract_text_from_message(msg);
+ if text.contains("__SUBAGENT_MARKER__") {
+ return true;
+ }
+ }
+ }
+
+ // 信号 2(fallback): metadata.user_id 包含子代理标识
+ // Claude Code 的 Agent tool 会将 subagent session 标记为
+ // "parentSessionId_agent_agentId" 格式,检测 "_agent_" 后缀
+ if let Some(user_id) = body.pointer("/metadata/user_id").and_then(|v| v.as_str()) {
+ // "_agent_" 是 Claude Code Agent tool 的内部标记
+ if user_id.contains("_agent_") {
+ return true;
+ }
+ }
+
+ // 信号 3(fallback): system prompt 包含 Claude Code 子代理的典型框架文本
+ // Agent tool 生成的子代理会在 system prompt 中包含由 Agent tool 注入的任务描述,
+ // 但主对话的 system prompt 由 Claude Code CLI 直接生成,两者格式不同
+ // 这个信号不够可靠(用户 prompt 也可能包含这些词),因此只作为辅助判据
+ // 暂不启用,预留接口
+
+ false
+}
+
+/// 清理孤立的 tool_result — 没有对应 tool_use 的 tool_result 转为 text block。
+///
+/// 场景:上下文压缩、消息截断等可能导致 assistant 消息中的 tool_use 被删除,
+/// 但后续 user 消息中的 tool_result 仍在。上游 API 可能因不匹配而报错/重试。
+///
+/// 与 copilot-api 的 `sanitizeOrphanToolResults` 对齐。
+pub fn sanitize_orphan_tool_results(mut body: Value) -> Value {
+ let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
+ Some(msgs) if msgs.len() >= 2 => msgs,
+ _ => return body,
+ };
+
+ // Anthropic 协议要求 tool_result 紧跟其对应 tool_use 所在的 assistant turn。
+ // 只检查 messages[i-1](紧邻上一条 assistant)来判定是否 orphan,
+ // 与参考实现 sanitizeOrphanToolResults 对齐。
+ for i in 1..messages.len() {
+ if messages[i].get("role").and_then(|r| r.as_str()) != Some("user") {
+ continue;
+ }
+
+ // 收集紧邻上一条 assistant 的 tool_use id
+ let prev_tool_use_ids: HashSet =
+ if messages[i - 1].get("role").and_then(|r| r.as_str()) == Some("assistant") {
+ messages[i - 1]
+ .get("content")
+ .and_then(|c| c.as_array())
+ .map(|blocks| {
+ blocks
+ .iter()
+ .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
+ .filter_map(|b| b.get("id").and_then(|i| i.as_str()).map(String::from))
+ .collect()
+ })
+ .unwrap_or_default()
+ } else {
+ // 上一条不是 assistant → 这条 user 中的所有 tool_result 都是 orphan
+ HashSet::new()
+ };
+
+ let content = match messages[i]
+ .get_mut("content")
+ .and_then(|c| c.as_array_mut())
+ {
+ Some(blocks) => blocks,
+ None => continue,
+ };
+
+ for block in content.iter_mut() {
+ if block.get("type").and_then(|t| t.as_str()) != Some("tool_result") {
+ continue;
+ }
+ let tool_use_id = block
+ .get("tool_use_id")
+ .and_then(|id| id.as_str())
+ .unwrap_or("");
+ // 空 tool_use_id 或不在紧邻 assistant 的 tool_use 中 → orphan
+ if tool_use_id.is_empty() || !prev_tool_use_ids.contains(tool_use_id) {
+ let content_text = match block.get("content") {
+ Some(c) if c.is_string() => c.as_str().unwrap_or("").to_string(),
+ Some(c) if c.is_array() => c
+ .as_array()
+ .unwrap()
+ .iter()
+ .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
+ .collect::>()
+ .join("\n"),
+ _ => String::new(),
+ };
+ *block = serde_json::json!({
+ "type": "text",
+ "text": format!("[Tool result for {}]: {}", tool_use_id, content_text)
+ });
+ }
+ }
+ }
+
+ body
+}
+
// ─── 内部辅助 ─────────────────────────────────
+/// 从请求体的 `system` 字段提取文本(处理 string/array 两种格式)。
+fn extract_system_text(body: &Value) -> String {
+ match body.get("system") {
+ Some(s) if s.is_string() => s.as_str().unwrap_or("").to_string(),
+ Some(arr) if arr.is_array() => arr
+ .as_array()
+ .unwrap()
+ .iter()
+ .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
+ .collect::>()
+ .join(" "),
+ _ => String::new(),
+ }
+}
+
/// 查找最后一条 user 消息的非 tool_result 文本内容。
///
/// 与参考实现的 `findLastUserContent` 对齐:
@@ -433,7 +606,7 @@ mod tests {
{"role": "user", "content": "Hello, please help me write some code"}
]
});
- let result = classify_request(&body, false, true);
+ let result = classify_request(&body, false, true, false);
assert_eq!(result.initiator, "user");
assert!(!result.is_compact);
}
@@ -448,7 +621,7 @@ mod tests {
]}
]
});
- let result = classify_request(&body, false, true);
+ let result = classify_request(&body, false, true, false);
assert_eq!(result.initiator, "user");
}
@@ -468,15 +641,16 @@ mod tests {
]}
]
});
- let result = classify_request(&body, true, true);
+ let result = classify_request(&body, true, true, false);
assert_eq!(result.initiator, "agent");
assert!(!result.is_warmup);
}
#[test]
fn test_classify_tool_result_with_text_block() {
- // 参考实现的关键场景:tool_result + text block
- // 有非 tool_result block → 仍然是 "user"
+ // tool_result + text block(skill/edit hook/plan follow-up 的常见形态)
+ // 含有 tool_result → 视为工具续写 → agent
+ // 与 copilot-api 的 merge-then-classify 效果对齐
let body = json!({
"model": "claude-sonnet-4-20250514",
"messages": [
@@ -486,8 +660,8 @@ mod tests {
]}
]
});
- let result = classify_request(&body, false, true);
- assert_eq!(result.initiator, "user");
+ let result = classify_request(&body, false, true, false);
+ assert_eq!(result.initiator, "agent");
}
#[test]
@@ -496,14 +670,14 @@ mod tests {
"model": "claude-sonnet-4-20250514",
"messages": []
});
- let result = classify_request(&body, false, true);
+ let result = classify_request(&body, false, true, false);
assert_eq!(result.initiator, "user");
}
#[test]
fn test_classify_no_messages() {
let body = json!({"model": "claude-sonnet-4-20250514"});
- let result = classify_request(&body, false, true);
+ let result = classify_request(&body, false, true, false);
assert_eq!(result.initiator, "user");
}
@@ -517,7 +691,7 @@ mod tests {
{"role": "user", "content": "Here is the conversation history to summarize..."}
]
});
- let result = classify_request(&body, false, true);
+ let result = classify_request(&body, false, true, false);
assert_eq!(result.initiator, "agent");
assert!(result.is_compact);
}
@@ -533,7 +707,7 @@ mod tests {
]}
]
});
- let result = classify_request(&body, false, true);
+ let result = classify_request(&body, false, true, false);
assert_eq!(result.initiator, "agent");
assert!(result.is_compact);
}
@@ -548,7 +722,7 @@ mod tests {
{"role": "user", "content": "Summarize"}
]
});
- let result = classify_request(&body, false, false); // compact_detection=false
+ let result = classify_request(&body, false, false, false); // compact_detection=false
assert_eq!(result.initiator, "user"); // 不被标记为 agent
assert!(!result.is_compact);
}
@@ -562,7 +736,7 @@ mod tests {
{"role": "user", "content": "Please summarize the conversation so far into a concise summary."}
]
});
- let result = classify_request(&body, false, true);
+ let result = classify_request(&body, false, true, false);
// 没有 system prompt 强特征,也没有 CRITICAL 指令 → 不是 compact → user
assert_eq!(result.initiator, "user");
assert!(!result.is_compact);
@@ -579,7 +753,7 @@ mod tests {
]
});
// has_anthropic_beta=true, 无 tools → warmup
- let result = classify_request(&body, true, true);
+ let result = classify_request(&body, true, true, false);
assert!(result.is_warmup);
}
@@ -592,7 +766,7 @@ mod tests {
]
});
// has_anthropic_beta=false → 不是 warmup
- let result = classify_request(&body, false, true);
+ let result = classify_request(&body, false, true, false);
assert!(!result.is_warmup);
}
@@ -606,7 +780,7 @@ mod tests {
]
});
// 有 tools → 不是 warmup(即使有 anthropic-beta)
- let result = classify_request(&body, true, true);
+ let result = classify_request(&body, true, true, false);
assert!(!result.is_warmup);
}
@@ -621,7 +795,7 @@ mod tests {
]}
]
});
- let result = classify_request(&body, true, true);
+ let result = classify_request(&body, true, true, false);
assert_eq!(result.initiator, "agent");
assert!(!result.is_warmup);
}
@@ -831,6 +1005,44 @@ mod tests {
assert!(Uuid::parse_str(&id).is_ok());
}
+ // === deterministic_interaction_id 测试 ===
+
+ #[test]
+ fn test_interaction_id_stable_for_same_session() {
+ let id1 = deterministic_interaction_id("session_abc");
+ let id2 = deterministic_interaction_id("session_abc");
+ assert_eq!(id1, id2);
+ }
+
+ #[test]
+ fn test_interaction_id_differs_across_sessions() {
+ let id1 = deterministic_interaction_id("session_abc");
+ let id2 = deterministic_interaction_id("session_def");
+ assert_ne!(id1, id2);
+ }
+
+ #[test]
+ fn test_interaction_id_differs_from_request_id() {
+ let body = json!({
+ "messages": [{"role": "user", "content": "Hello"}]
+ });
+ let interaction = deterministic_interaction_id("session_abc").unwrap();
+ let request = deterministic_request_id(&body, "session_abc");
+ assert_ne!(interaction, request);
+ }
+
+ #[test]
+ fn test_interaction_id_empty_session_is_none() {
+ // 无 session 时不应生成 interaction ID(避免碎片化)
+ assert!(deterministic_interaction_id("").is_none());
+ }
+
+ #[test]
+ fn test_interaction_id_is_valid_uuid() {
+ let id = deterministic_interaction_id("test_session").unwrap();
+ assert!(Uuid::parse_str(&id).is_ok());
+ }
+
// === compact 检测增强测试 ===
#[test]
@@ -898,4 +1110,265 @@ mod tests {
});
assert!(is_compact_request(&body));
}
+
+ // === detect_subagent 测试 ===
+
+ #[test]
+ fn test_detect_subagent_with_marker_in_user_message() {
+ let body = json!({
+ "messages": [
+ {"role": "user", "content": [
+ {"type": "text", "text": "\n{\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc123\",\"agent_id\":\"explore-1\",\"agent_type\":\"Explore\"}}\n\nPlease search the codebase for auth handlers"}
+ ]}
+ ]
+ });
+ assert!(detect_subagent(&body));
+ }
+
+ #[test]
+ fn test_detect_subagent_with_marker_in_system() {
+ let body = json!({
+ "system": "You are an agent. {\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc\",\"agent_id\":\"plan-1\",\"agent_type\":\"Plan\"}}",
+ "messages": [
+ {"role": "user", "content": "Design the implementation plan"}
+ ]
+ });
+ assert!(detect_subagent(&body));
+ }
+
+ #[test]
+ fn test_detect_subagent_no_marker() {
+ let body = json!({
+ "messages": [
+ {"role": "user", "content": "Hello, please help me write code"}
+ ]
+ });
+ assert!(!detect_subagent(&body));
+ }
+
+ #[test]
+ fn test_detect_subagent_via_metadata_user_id() {
+ // fallback 信号: metadata.user_id 包含 "_agent_" 标记
+ let body = json!({
+ "metadata": {
+ "user_id": "session_abc123_agent_explore-1"
+ },
+ "messages": [
+ {"role": "user", "content": "Search for files"}
+ ]
+ });
+ assert!(detect_subagent(&body));
+ }
+
+ #[test]
+ fn test_detect_subagent_normal_user_id_not_matched() {
+ // 普通 session ID 不应被误判
+ let body = json!({
+ "metadata": {
+ "user_id": "session_abc123"
+ },
+ "messages": [
+ {"role": "user", "content": "Hello"}
+ ]
+ });
+ assert!(!detect_subagent(&body));
+ }
+
+ #[test]
+ fn test_classify_subagent_sets_agent_initiator() {
+ let body = json!({
+ "messages": [
+ {"role": "user", "content": [
+ {"type": "text", "text": "\n{\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc\",\"agent_id\":\"explore-1\",\"agent_type\":\"Explore\"}}\n\nSearch for files"}
+ ]}
+ ]
+ });
+ let result = classify_request(&body, false, true, true);
+ assert_eq!(result.initiator, "agent");
+ assert!(result.is_subagent);
+ }
+
+ #[test]
+ fn test_classify_subagent_disabled_flag() {
+ let body = json!({
+ "messages": [
+ {"role": "user", "content": [
+ {"type": "text", "text": "\n{\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc\",\"agent_id\":\"explore-1\",\"agent_type\":\"Explore\"}}\n\nSearch for files"}
+ ]}
+ ]
+ });
+ // subagent_detection=false → 不检测子代理
+ let result = classify_request(&body, false, true, false);
+ assert_eq!(result.initiator, "user");
+ assert!(!result.is_subagent);
+ }
+
+ // === sanitize_orphan_tool_results 测试 ===
+
+ #[test]
+ fn test_sanitize_orphan_tool_results_converts_orphans() {
+ let body = json!({
+ "messages": [
+ {"role": "user", "content": "Help me"},
+ {"role": "assistant", "content": [
+ {"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {}}
+ ]},
+ {"role": "user", "content": [
+ {"type": "tool_result", "tool_use_id": "tool_1", "content": "file contents"},
+ {"type": "tool_result", "tool_use_id": "tool_orphan", "content": "orphan data"}
+ ]}
+ ]
+ });
+ let result = sanitize_orphan_tool_results(body);
+ let msgs = result["messages"].as_array().unwrap();
+ let last_content = msgs[2]["content"].as_array().unwrap();
+ // tool_1 保留为 tool_result
+ assert_eq!(last_content[0]["type"], "tool_result");
+ // tool_orphan 转为 text
+ assert_eq!(last_content[1]["type"], "text");
+ assert!(last_content[1]["text"]
+ .as_str()
+ .unwrap()
+ .contains("tool_orphan"));
+ }
+
+ #[test]
+ fn test_sanitize_orphan_tool_results_no_orphans() {
+ let body = json!({
+ "messages": [
+ {"role": "assistant", "content": [
+ {"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {}}
+ ]},
+ {"role": "user", "content": [
+ {"type": "tool_result", "tool_use_id": "tool_1", "content": "ok"}
+ ]}
+ ]
+ });
+ let result = sanitize_orphan_tool_results(body.clone());
+ // 无孤立 tool_result,不应有变化
+ assert_eq!(result["messages"][1]["content"][0]["type"], "tool_result");
+ }
+
+ #[test]
+ fn test_sanitize_orphan_non_adjacent_assistant_tool_use_is_orphan() {
+ // tool_use 在更早的 assistant 中,但 tool_result 的紧邻上一条是另一个 assistant
+ // → 对 Anthropic 协议来说这个 tool_result 是 orphan
+ let body = json!({
+ "messages": [
+ {"role": "user", "content": "step 1"},
+ {"role": "assistant", "content": [
+ {"type": "tool_use", "id": "old_tool", "name": "search", "input": {}}
+ ]},
+ {"role": "user", "content": [
+ {"type": "tool_result", "tool_use_id": "old_tool", "content": "found it"}
+ ]},
+ {"role": "assistant", "content": [
+ {"type": "text", "text": "OK, now let me think..."}
+ ]},
+ {"role": "user", "content": [
+ {"type": "tool_result", "tool_use_id": "old_tool", "content": "stale ref"}
+ ]}
+ ]
+ });
+ let result = sanitize_orphan_tool_results(body);
+ let msgs = result["messages"].as_array().unwrap();
+ // messages[2]: 紧邻 assistant 有 old_tool → 保留
+ assert_eq!(msgs[2]["content"][0]["type"], "tool_result");
+ // messages[4]: 紧邻 assistant 无 tool_use → orphan → text
+ assert_eq!(msgs[4]["content"][0]["type"], "text");
+ }
+
+ #[test]
+ fn test_sanitize_orphan_prev_not_assistant() {
+ // tool_result 紧邻上一条是 user(非 assistant)→ 全部 orphan
+ let body = json!({
+ "messages": [
+ {"role": "user", "content": "first"},
+ {"role": "user", "content": [
+ {"type": "tool_result", "tool_use_id": "t1", "content": "data"}
+ ]}
+ ]
+ });
+ let result = sanitize_orphan_tool_results(body);
+ assert_eq!(result["messages"][1]["content"][0]["type"], "text");
+ }
+
+ /// 关键场景:orphan tool_result(上下文压缩丢失了紧邻 tool_use)
+ /// 在分类时仍应被视为 agent continuation,不能因为后续的 sanitize
+ /// 将其转为 text 而变成 user 请求。
+ ///
+ /// 这个测试验证 classify_request 在原始(未 sanitize)的 body 上
+ /// 正确识别 orphan tool_result 为 agent。
+ #[test]
+ fn test_orphan_tool_result_classified_as_agent_before_sanitize() {
+ // 场景:最后一条 user 消息全是 tool_result,但紧邻的 assistant
+ // 消息里没有对应的 tool_use(因上下文压缩丢失了)
+ let body = json!({
+ "messages": [
+ {"role": "assistant", "content": "I'll help you with that."},
+ {"role": "user", "content": [
+ {"type": "tool_result", "tool_use_id": "orphan_tool_1", "content": "file contents here"},
+ {"type": "tool_result", "tool_use_id": "orphan_tool_2", "content": "another result"}
+ ]}
+ ]
+ });
+ // 在原始 body 上分类 → 全是 tool_result → agent
+ let classification = classify_request(&body, false, false, false);
+ assert_eq!(classification.initiator, "agent");
+
+ // sanitize 后 → tool_result 变为 text → 如果再分类就会变成 user
+ let sanitized = sanitize_orphan_tool_results(body);
+ let classification_after = classify_request(&sanitized, false, false, false);
+ assert_eq!(
+ classification_after.initiator, "user",
+ "sanitize 后 orphan tool_result 变为 text,分类变成 user — \
+ 这就是为什么分类必须在 sanitize 之前执行"
+ );
+ }
+
+ /// orphan tool_result + text 混合场景:
+ /// 分类器直接识别含 tool_result 的消息为 agent(无论是否有 text block),
+ /// 不依赖 merge 的执行顺序。即使 orphan tool_result 后续被 sanitize 转为 text,
+ /// 分类结果在此之前已经确定为 agent。
+ #[test]
+ fn test_orphan_tool_result_with_text_classified_as_agent() {
+ let body = json!({
+ "messages": [
+ {"role": "assistant", "content": "Processing..."},
+ {"role": "user", "content": [
+ {"type": "tool_result", "tool_use_id": "orphan_1", "content": "result data"},
+ {"type": "text", "text": "Here's the output from the tool"}
+ ]}
+ ]
+ });
+ // 含有 tool_result → agent(无论是否有 text block)
+ let classification = classify_request(&body, false, false, false);
+ assert_eq!(classification.initiator, "agent");
+
+ // sanitize 后 orphan tool_result 变为 text → 纯 text → 分类会变成 user
+ // 但正确的执行顺序是先分类再 sanitize,所以这不是问题
+ let sanitized = sanitize_orphan_tool_results(body);
+ let classification_after = classify_request(&sanitized, false, false, false);
+ assert_eq!(classification_after.initiator, "user");
+ }
+
+ #[test]
+ fn test_sanitize_orphan_empty_tool_use_id_is_orphan() {
+ // tool_use_id 为空或缺失 → 无法匹配任何 tool_use → orphan
+ let body = json!({
+ "messages": [
+ {"role": "assistant", "content": [
+ {"type": "tool_use", "id": "tool_1", "name": "read", "input": {}}
+ ]},
+ {"role": "user", "content": [
+ {"type": "tool_result", "tool_use_id": "", "content": "empty id"},
+ {"type": "tool_result", "content": "missing id field"}
+ ]}
+ ]
+ });
+ let result = sanitize_orphan_tool_results(body);
+ let content = result["messages"][1]["content"].as_array().unwrap();
+ assert_eq!(content[0]["type"], "text");
+ assert_eq!(content[1]["type"], "text");
+ }
}
diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs
index 6fa7f43d0..a8f10adf7 100644
--- a/src-tauri/src/proxy/forwarder.rs
+++ b/src-tauri/src/proxy/forwarder.rs
@@ -786,32 +786,42 @@ impl RequestForwarder {
== Some("github_copilot")
|| base_url.contains("githubcopilot.com");
- // --- Copilot 优化器:请求体优化 + 分类(在格式转换之前执行) ---
+ // --- Copilot 优化器:分类 + 请求体优化(在格式转换之前执行) ---
// 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move
+ //
+ // 执行顺序(与 copilot-api 对齐):
+ // 1. 先在原始 body 上分类(保留 tool_result 语义,避免误判为 user)
+ // 2. 再清洗孤立 tool_result(防止上游 API 报错)
+ // 3. 再合并 tool_result + text(减少 premium 计费)
let copilot_optimization = if is_copilot && self.copilot_optimizer_config.enabled {
- // 1. Tool result 合并 — 必须在分类之前执行
- // 合并将 [tool_result, text] 变为 [tool_result(含text)],
- // 分类才能正确识别为 agent(全是 tool_result)而非 user(有 text block)
- if self.copilot_optimizer_config.tool_result_merging {
- mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body);
- }
-
- // 2. 在合并后的 body 上进行分类
+ // 1. 在原始 body 上分类 — 必须在清洗/合并之前执行
+ // 孤立 tool_result 仍保持 tool_result 类型,分类能正确识别为 agent
let has_anthropic_beta = headers.contains_key("anthropic-beta");
let classification = super::copilot_optimizer::classify_request(
&mapped_body,
has_anthropic_beta,
self.copilot_optimizer_config.compact_detection,
+ self.copilot_optimizer_config.subagent_detection,
);
log::debug!(
- "[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}",
+ "[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}, is_subagent={}",
classification.initiator,
classification.is_warmup,
- classification.is_compact
+ classification.is_compact,
+ classification.is_subagent
);
- // 3. Warmup 小模型降级
+ // 2. 孤立 tool_result 清理 — 分类完成后再清洗
+ // 防止上游 API 因不匹配的 tool_result 报错导致重试/重复计费
+ mapped_body = super::copilot_optimizer::sanitize_orphan_tool_results(mapped_body);
+
+ // 3. Tool result 合并 — 将 [tool_result, text] 变为 [tool_result(含text)]
+ if self.copilot_optimizer_config.tool_result_merging {
+ mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body);
+ }
+
+ // 4. Warmup 小模型降级
if self.copilot_optimizer_config.warmup_downgrade && classification.is_warmup {
log::info!(
"[Copilot] Warmup 请求降级到模型: {}",
@@ -822,22 +832,52 @@ impl RequestForwarder {
}
// 预计算确定性 Request ID(在 body 被 move 之前)
- // 使用 session_id 从 body.metadata.user_id 或请求头提取
- let session_id = body
- .pointer("/metadata/user_id")
+ // Session 提取优先级(与 session.rs extract_from_metadata 对齐):
+ // 1. metadata.user_id 中的 _session_ 后缀
+ // 2. metadata.session_id(直接字段)
+ // 3. raw metadata.user_id(整串 fallback)
+ // 4. x-session-id header
+ let metadata = body.get("metadata");
+ let session_id = metadata
+ .and_then(|m| m.get("user_id"))
.and_then(|v| v.as_str())
- .or_else(|| headers.get("x-session-id").and_then(|v| v.to_str().ok()))
- .unwrap_or("");
+ .and_then(super::session::parse_session_from_user_id)
+ .or_else(|| {
+ metadata
+ .and_then(|m| m.get("session_id"))
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string())
+ })
+ .or_else(|| {
+ metadata
+ .and_then(|m| m.get("user_id"))
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string())
+ })
+ .or_else(|| {
+ headers
+ .get("x-session-id")
+ .and_then(|v| v.to_str().ok())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string())
+ })
+ .unwrap_or_default();
let det_request_id = if self.copilot_optimizer_config.deterministic_request_id {
Some(super::copilot_optimizer::deterministic_request_id(
&mapped_body,
- session_id,
+ &session_id,
))
} else {
None
};
- Some((classification, det_request_id))
+ // 从 session ID 派生稳定的 interaction ID(同一主对话共享)
+ let interaction_id =
+ super::copilot_optimizer::deterministic_interaction_id(&session_id);
+
+ Some((classification, det_request_id, interaction_id))
} else {
None
};
@@ -1057,12 +1097,18 @@ impl RequestForwarder {
}
// --- Copilot 优化器:动态 header 注入 ---
- if let Some((ref classification, ref det_request_id)) = copilot_optimization {
+ if let Some((ref classification, ref det_request_id, ref interaction_id)) =
+ copilot_optimization
+ {
for (name, value) in auth_headers.iter_mut() {
match name.as_str() {
"x-initiator" if self.copilot_optimizer_config.request_classification => {
*value = http::HeaderValue::from_static(classification.initiator);
}
+ "x-interaction-type" if classification.is_subagent => {
+ // 子代理请求:conversation-subagent 不计 premium interaction
+ *value = http::HeaderValue::from_static("conversation-subagent");
+ }
"x-request-id" | "x-agent-task-id" => {
if let Some(ref det_id) = det_request_id {
if let Ok(hv) = http::HeaderValue::from_str(det_id) {
@@ -1073,6 +1119,19 @@ impl RequestForwarder {
_ => {}
}
}
+
+ // x-interaction-id:仅在有 session 时注入(不在 get_auth_headers 中)
+ if let Some(ref iid) = interaction_id {
+ if let Ok(hv) = http::HeaderValue::from_str(iid) {
+ auth_headers.push((http::HeaderName::from_static("x-interaction-id"), hv));
+ }
+ }
+
+ if classification.is_subagent {
+ log::info!(
+ "[Copilot] 子代理请求: x-initiator=agent, x-interaction-type=conversation-subagent"
+ );
+ }
}
// Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重)
@@ -1087,6 +1146,7 @@ impl RequestForwarder {
// 新增 headers
"x-initiator",
"x-interaction-type",
+ "x-interaction-id",
"x-vscode-user-agent-library-version",
"x-request-id",
"x-agent-task-id",
@@ -1310,12 +1370,8 @@ impl RequestForwarder {
self.non_streaming_timeout
};
- // 解析上游代理 URL(供应商单独代理 > 全局代理 > 无)
- let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
- let upstream_proxy_url: Option = proxy_config
- .filter(|c| c.enabled)
- .and_then(super::http_client::build_proxy_url_from_config)
- .or_else(super::http_client::get_current_proxy_url);
+ // 获取全局代理 URL
+ let upstream_proxy_url: Option = super::http_client::get_current_proxy_url();
// SOCKS5 代理不支持 CONNECT 隧道,需要用 reqwest
let is_socks_proxy = upstream_proxy_url
@@ -1331,7 +1387,7 @@ impl RequestForwarder {
let response = if is_socks_proxy {
// SOCKS5 代理:只能走 reqwest(不支持 header case 保留)
log::debug!("[Forwarder] Using reqwest for SOCKS5 proxy");
- let client = super::http_client::get_for_provider(proxy_config);
+ let client = super::http_client::get();
let mut request = client.post(&url);
if !self.non_streaming_timeout.is_zero() {
request = request.timeout(self.non_streaming_timeout);
diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs
index b8a233e52..3e4fcc983 100644
--- a/src-tauri/src/proxy/handlers.rs
+++ b/src-tauri/src/proxy/handlers.rs
@@ -622,7 +622,7 @@ async fn log_usage(
model
};
- let request_id = uuid::Uuid::new_v4().to_string();
+ let request_id = usage.dedup_request_id();
if let Err(e) = logger.log_with_calculation(
request_id,
diff --git a/src-tauri/src/proxy/http_client.rs b/src-tauri/src/proxy/http_client.rs
index 897da7fe8..1d3ddb2c9 100644
--- a/src-tauri/src/proxy/http_client.rs
+++ b/src-tauri/src/proxy/http_client.rs
@@ -3,7 +3,6 @@
//! 提供支持全局代理配置的 HTTP 客户端。
//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。
-use crate::provider::ProviderProxyConfig;
use once_cell::sync::OnceCell;
use reqwest::Client;
use std::env;
@@ -334,103 +333,6 @@ pub fn mask_url(url: &str) -> String {
}
}
-/// 根据供应商单独代理配置构建代理 URL
-///
-/// 将 ProviderProxyConfig 转换为代理 URL 字符串
-pub fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option {
- let proxy_type = config.proxy_type.as_deref().unwrap_or("http");
- let host = config.proxy_host.as_deref()?;
- let port = config.proxy_port?;
-
- // 构建带认证的代理 URL
- if let (Some(username), Some(password)) = (&config.proxy_username, &config.proxy_password) {
- if !username.is_empty() && !password.is_empty() {
- return Some(format!(
- "{proxy_type}://{username}:{password}@{host}:{port}"
- ));
- }
- }
-
- Some(format!("{proxy_type}://{host}:{port}"))
-}
-
-/// 根据供应商单独代理配置构建 HTTP 客户端
-///
-/// 如果供应商配置了单独代理(enabled = true),则使用该代理构建客户端;
-/// 否则返回 None,调用方应使用全局客户端。
-///
-/// # Arguments
-/// * `proxy_config` - 供应商的代理配置
-///
-/// # Returns
-/// 如果配置有效则返回 Some(Client),否则返回 None
-pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Option {
- let config = proxy_config.filter(|c| c.enabled)?;
-
- let proxy_url = build_proxy_url_from_config(config)?;
-
- log::debug!(
- "[ProviderProxy] Building client with proxy: {}",
- mask_url(&proxy_url)
- );
-
- // 构建带代理的客户端
- let proxy = match reqwest::Proxy::all(&proxy_url) {
- Ok(p) => p,
- Err(e) => {
- log::error!(
- "[ProviderProxy] Failed to create proxy from '{}': {}",
- mask_url(&proxy_url),
- e
- );
- return None;
- }
- };
-
- match Client::builder()
- .timeout(Duration::from_secs(600))
- .connect_timeout(Duration::from_secs(30))
- .pool_max_idle_per_host(10)
- .tcp_keepalive(Duration::from_secs(60))
- .no_gzip()
- .no_brotli()
- .no_deflate()
- .proxy(proxy)
- .build()
- {
- Ok(client) => {
- log::info!(
- "[ProviderProxy] Client built with proxy: {}",
- mask_url(&proxy_url)
- );
- Some(client)
- }
- Err(e) => {
- log::error!("[ProviderProxy] Failed to build client: {e}");
- None
- }
- }
-}
-
-/// 获取供应商专用的 HTTP 客户端
-///
-/// 优先使用供应商单独代理配置,如果未启用则返回全局客户端。
-///
-/// # Arguments
-/// * `proxy_config` - 供应商的代理配置
-///
-/// # Returns
-/// 返回适合该供应商的 HTTP 客户端
-pub fn get_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Client {
- // 优先使用供应商单独代理
- if let Some(client) = build_client_for_provider(proxy_config) {
- return client;
- }
-
- // 回退到全局客户端
- get()
-}
-
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs
index 3f35f4ac5..c2d87fc0b 100644
--- a/src-tauri/src/proxy/providers/claude.rs
+++ b/src-tauri/src/proxy/providers/claude.rs
@@ -89,12 +89,48 @@ pub fn transform_claude_request_for_api_format(
session_id: Option<&str>,
shadow_store: Option<&super::gemini_shadow::GeminiShadowStore>,
) -> Result {
- let cache_key = provider
+ // Copilot 场景:优先从 metadata.user_id 提取 session ID 作为 cache key
+ // 格式: "uuid_sessionId" → 提取 "_" 后面的部分作为 session 标识
+ // 同一会话的请求共享 cache key,提升 Copilot 缓存命中率
+ let is_copilot = provider
.meta
.as_ref()
- .and_then(|m| m.prompt_cache_key.as_deref())
- .unwrap_or(&provider.id);
+ .and_then(|m| m.provider_type.as_deref())
+ == Some("github_copilot")
+ || provider
+ .settings_config
+ .get("baseUrl")
+ .and_then(|v| v.as_str())
+ .is_some_and(|u| u.contains("githubcopilot.com"));
+ let session_cache_key: Option = if is_copilot {
+ let metadata = body.get("metadata");
+ // Session 提取优先级(与 forwarder 和 session.rs 统一):
+ // 1. metadata.user_id 中的 _session_ 后缀
+ // 2. metadata.session_id(直接字段)
+ metadata
+ .and_then(|m| m.get("user_id"))
+ .and_then(|v| v.as_str())
+ .and_then(super::super::session::parse_session_from_user_id)
+ .or_else(|| {
+ metadata
+ .and_then(|m| m.get("session_id"))
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string())
+ })
+ } else {
+ None
+ };
+ let cache_key = session_cache_key
+ .as_deref()
+ .or_else(|| {
+ provider
+ .meta
+ .as_ref()
+ .and_then(|m| m.prompt_cache_key.as_deref())
+ })
+ .unwrap_or(&provider.id);
match api_format {
"openai_responses" => {
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
@@ -110,7 +146,14 @@ pub fn transform_claude_request_for_api_format(
is_codex_oauth,
)
}
- "openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)),
+ "openai_chat" => {
+ let mut result = super::transform::anthropic_to_openai(body)?;
+ // Inject prompt_cache_key only if explicitly configured in meta
+ if let Some(key) = provider.meta.as_ref().and_then(|m| m.prompt_cache_key.as_deref()) {
+ result["prompt_cache_key"] = serde_json::json!(key);
+ }
+ Ok(result)
+ }
"gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow(
body,
shadow_store,
@@ -506,6 +549,7 @@ impl ProviderAdapter for ClaudeAdapter {
HeaderName::from_static("x-interaction-type"),
HeaderValue::from_static("conversation-agent"),
),
+ // x-interaction-id 由 forwarder 按需注入(仅在有 session 时)
(
HeaderName::from_static("x-vscode-user-agent-library-version"),
HeaderValue::from_static("electron-fetch"),
diff --git a/src-tauri/src/proxy/providers/streaming.rs b/src-tauri/src/proxy/providers/streaming.rs
index 306165a31..d6339e5d2 100644
--- a/src-tauri/src/proxy/providers/streaming.rs
+++ b/src-tauri/src/proxy/providers/streaming.rs
@@ -85,8 +85,16 @@ struct ToolBlockState {
name: String,
started: bool,
pending_args: String,
+ /// 连续空白字符计数 — 用于检测 Copilot 无限换行 bug
+ /// 当 function call 参数中出现连续 20+ 空白字符时,强制终止流
+ consecutive_whitespace: usize,
+ /// 是否已因无限空白 bug 被中止
+ aborted: bool,
}
+/// 无限空白 bug 的连续空白字符阈值
+const INFINITE_WHITESPACE_THRESHOLD: usize = 20;
+
/// 创建 Anthropic SSE 流
pub fn create_anthropic_sse_stream(
stream: impl Stream> + Send + 'static,
@@ -294,9 +302,16 @@ pub fn create_anthropic_sse_stream(
name: String::new(),
started: false,
pending_args: String::new(),
+ consecutive_whitespace: 0,
+ aborted: false,
}
});
+ // 如果此 tool call 已被中止(无限空白 bug),跳过后续处理
+ if state.aborted {
+ continue;
+ }
+
if let Some(id) = &tool_call.id {
state.id = id.clone();
}
@@ -325,7 +340,22 @@ pub fn create_anthropic_sse_stream(
.as_ref()
.and_then(|f| f.arguments.clone());
let immediate_delta = if let Some(args) = args_delta {
- if state.started {
+ // 无限空白 bug 检测:跟踪连续空白字符
+ for ch in args.chars() {
+ if ch.is_whitespace() {
+ state.consecutive_whitespace += 1;
+ } else {
+ state.consecutive_whitespace = 0;
+ }
+ }
+ if state.consecutive_whitespace >= INFINITE_WHITESPACE_THRESHOLD {
+ log::warn!(
+ "[Copilot] 检测到无限空白 bug (tool: {}), 中止此 tool call 流",
+ state.name
+ );
+ state.aborted = true;
+ None
+ } else if state.started {
Some(args)
} else {
state.pending_args.push_str(&args);
diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs
index 7380fb860..72e6b0f02 100644
--- a/src-tauri/src/proxy/providers/transform.rs
+++ b/src-tauri/src/proxy/providers/transform.rs
@@ -71,10 +71,8 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
}
}
-/// Anthropic 请求 → OpenAI 请求
-///
-/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
-pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result {
+/// Anthropic 请求 → OpenAI Chat Completions 请求
+pub fn anthropic_to_openai(body: Value) -> Result {
let mut result = json!({});
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
@@ -175,11 +173,6 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result) {
}
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;
@@ -226,11 +223,28 @@ 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() {
- messages.insert(0, json!({"role": "system", "content": parts.join("\n")}));
+ 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);
}
}
@@ -569,7 +583,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["model"], "claude-3-opus");
assert_eq!(result["max_tokens"], 1024);
assert_eq!(result["messages"][0]["role"], "user");
@@ -585,7 +599,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
@@ -607,36 +621,76 @@ mod tests {
}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
}
#[test]
- fn test_anthropic_to_openai_normalizes_fragmented_system_messages() {
+ fn test_anthropic_to_openai_preserves_matching_system_cache_control_when_merging() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": [
- {"type": "text", "text": "You are Claude Code."},
- {"type": "text", "text": "Be concise."}
+ {"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
+ {"type": "text", "text": "Be concise.", "cache_control": {"type": "ephemeral"}}
],
- "messages": [
- {"role": "system", "content": "Follow repo conventions."},
- {"role": "user", "content": "Hello"}
- ]
+ "messages": [{"role": "user", "content": "Hello"}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["messages"].as_array().unwrap().len(), 2);
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
- "You are Claude Code.\nBe concise.\nFollow repo conventions."
+ "You are Claude Code.\nBe concise."
);
+ assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
assert_eq!(result["messages"][1]["role"], "user");
}
+ #[test]
+ fn test_anthropic_to_openai_drops_mixed_present_absent_system_cache_control_when_merging() {
+ let input = json!({
+ "model": "claude-3-sonnet",
+ "max_tokens": 1024,
+ "system": [
+ {"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
+ {"type": "text", "text": "Be concise."}
+ ],
+ "messages": [{"role": "user", "content": "Hello"}]
+ });
+
+ let result = anthropic_to_openai(input).unwrap();
+ assert_eq!(result["messages"][0]["role"], "system");
+ assert_eq!(
+ result["messages"][0]["content"],
+ "You are Claude Code.\nBe concise."
+ );
+ assert!(result["messages"][0].get("cache_control").is_none());
+ }
+
+ #[test]
+ fn test_anthropic_to_openai_drops_conflicting_system_cache_control_when_merging() {
+ let input = json!({
+ "model": "claude-3-sonnet",
+ "max_tokens": 1024,
+ "system": [
+ {"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
+ {"type": "text", "text": "Be concise.", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
+ ],
+ "messages": [{"role": "user", "content": "Hello"}]
+ });
+
+ let result = anthropic_to_openai(input).unwrap();
+ assert_eq!(result["messages"][0]["role"], "system");
+ assert_eq!(
+ result["messages"][0]["content"],
+ "You are Claude Code.\nBe concise."
+ );
+ assert!(result["messages"][0].get("cache_control").is_none());
+ }
+
#[test]
fn test_anthropic_to_openai_tool_use() {
let input = json!({
@@ -651,7 +705,7 @@ mod tests {
}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "assistant");
assert!(msg.get("tool_calls").is_some());
@@ -671,7 +725,7 @@ mod tests {
}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "tool");
assert_eq!(msg["tool_call_id"], "call_123");
@@ -743,31 +797,19 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["model"], "gpt-4o");
}
#[test]
- fn test_anthropic_to_openai_with_cache_key() {
+ fn test_anthropic_to_openai_does_not_inject_prompt_cache_key() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
- let result = anthropic_to_openai(input, Some("provider-123")).unwrap();
- assert_eq!(result["prompt_cache_key"], "provider-123");
- }
-
- #[test]
- fn test_anthropic_to_openai_no_cache_key() {
- let input = json!({
- "model": "claude-3-opus",
- "max_tokens": 1024,
- "messages": [{"role": "user", "content": "Hello"}]
- });
-
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
assert!(result.get("prompt_cache_key").is_none());
}
@@ -793,7 +835,7 @@ mod tests {
}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ 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
@@ -1047,7 +1089,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
assert!(result.get("reasoning_effort").is_none());
}
@@ -1060,7 +1102,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["reasoning_effort"], "medium");
}
@@ -1073,7 +1115,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["reasoning_effort"], "xhigh");
}
@@ -1086,7 +1128,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["reasoning_effort"], "low");
}
@@ -1099,7 +1141,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["reasoning_effort"], "xhigh");
}
@@ -1111,7 +1153,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
assert!(result.get("reasoning_effort").is_none());
}
@@ -1124,7 +1166,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
assert!(
result.get("max_tokens").is_none(),
"{model} should not have max_tokens"
@@ -1144,7 +1186,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
- let result = anthropic_to_openai(input, None).unwrap();
+ let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["max_tokens"], 1024);
assert!(result.get("max_completion_tokens").is_none());
}
diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs
index c41270ac0..647ba02e6 100644
--- a/src-tauri/src/proxy/response_processor.rs
+++ b/src-tauri/src/proxy/response_processor.rs
@@ -528,7 +528,7 @@ async fn log_usage_internal(
model
};
- let request_id = uuid::Uuid::new_v4().to_string();
+ let request_id = usage.dedup_request_id();
log::debug!(
"[{app_type}] 记录请求日志: id={request_id}, provider={provider_id}, model={model}, streaming={is_streaming}, status={status_code}, latency_ms={latency_ms}, first_token_ms={first_token_ms:?}, session={}, input={}, output={}, cache_read={}, cache_creation={}",
@@ -783,6 +783,7 @@ mod tests {
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
+ message_id: None,
};
log_usage_internal(
@@ -842,6 +843,7 @@ mod tests {
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
+ message_id: None,
};
log_usage_internal(
diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs
index fb49107ff..1360aae03 100644
--- a/src-tauri/src/proxy/server.rs
+++ b/src-tauri/src/proxy/server.rs
@@ -24,7 +24,6 @@ use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::{oneshot, RwLock};
use tokio::task::JoinHandle;
-use tower_http::cors::{Any, CorsLayer};
/// 代理服务器状态(共享)
#[derive(Clone)]
@@ -279,11 +278,6 @@ impl ProxyServer {
}
fn build_router(&self) -> Router {
- let cors = CorsLayer::new()
- .allow_origin(Any)
- .allow_methods(Any)
- .allow_headers(Any);
-
Router::new()
// 健康检查
.route("/health", get(handlers::health_check))
@@ -332,7 +326,6 @@ impl ProxyServer {
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
// 提高默认请求体大小限制(避免 413 Payload Too Large)
.layer(DefaultBodyLimit::max(200 * 1024 * 1024))
- .layer(cors)
.with_state(self.state.clone())
}
diff --git a/src-tauri/src/proxy/session.rs b/src-tauri/src/proxy/session.rs
index cf3f964fe..cd94a034d 100644
--- a/src-tauri/src/proxy/session.rs
+++ b/src-tauri/src/proxy/session.rs
@@ -337,7 +337,7 @@ fn extract_from_metadata(body: &serde_json::Value) -> Option {
/// 从 user_id 解析 session_id
///
/// 格式: `user_identifier_session_actual_session_id`
-fn parse_session_from_user_id(user_id: &str) -> Option {
+pub(super) fn parse_session_from_user_id(user_id: &str) -> Option {
// 查找 "_session_" 分隔符
if let Some(pos) = user_id.find("_session_") {
let session_id = &user_id[pos + 9..]; // "_session_" 长度为 9
diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs
index 08df9e6eb..744e596f7 100644
--- a/src-tauri/src/proxy/types.rs
+++ b/src-tauri/src/proxy/types.rs
@@ -291,8 +291,12 @@ pub struct CopilotOptimizerConfig {
/// 确定性 Request ID(默认开启,P3 优先级)
#[serde(default = "default_true")]
pub deterministic_request_id: bool,
- /// Warmup 小模型降级(默认关闭,P4 优先级,opt-in)
- #[serde(default)]
+ /// Subagent 检测(默认开启)— 识别 Claude Code 子代理请求,
+ /// 设置 x-initiator=agent + x-interaction-type=conversation-subagent,避免子代理计费
+ #[serde(default = "default_true")]
+ pub subagent_detection: bool,
+ /// Warmup 小模型降级(默认开启 — 与参考实现对齐,避免探针请求消耗 premium quota)
+ #[serde(default = "default_true")]
pub warmup_downgrade: bool,
/// Warmup 降级使用的模型(默认 "gpt-4o-mini")
#[serde(default = "default_warmup_model")]
@@ -300,7 +304,7 @@ pub struct CopilotOptimizerConfig {
}
fn default_warmup_model() -> String {
- "gpt-4o-mini".to_string()
+ "gpt-5-mini".to_string()
}
impl Default for CopilotOptimizerConfig {
@@ -311,7 +315,8 @@ impl Default for CopilotOptimizerConfig {
tool_result_merging: true,
compact_detection: true,
deterministic_request_id: true,
- warmup_downgrade: false,
+ subagent_detection: true,
+ warmup_downgrade: true,
warmup_model: "gpt-4o-mini".to_string(),
}
}
diff --git a/src-tauri/src/proxy/usage/calculator.rs b/src-tauri/src/proxy/usage/calculator.rs
index 340150237..6499ac0c7 100644
--- a/src-tauri/src/proxy/usage/calculator.rs
+++ b/src-tauri/src/proxy/usage/calculator.rs
@@ -114,6 +114,7 @@ mod tests {
cache_read_tokens: 200,
cache_creation_tokens: 100,
model: None,
+ message_id: None,
};
let pricing = ModelPricing::from_strings("3.0", "15.0", "0.3", "3.75").unwrap();
@@ -144,6 +145,7 @@ mod tests {
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
+ message_id: None,
};
let pricing = ModelPricing::from_strings("3.0", "15.0", "0", "0").unwrap();
@@ -165,6 +167,7 @@ mod tests {
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
+ message_id: None,
};
let multiplier = Decimal::from_str("1.0").unwrap();
@@ -181,6 +184,7 @@ mod tests {
cache_read_tokens: 1,
cache_creation_tokens: 1,
model: None,
+ message_id: None,
};
let pricing = ModelPricing::from_strings("0.075", "0.3", "0.01875", "0.075").unwrap();
diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs
index c1f3c3774..94f028017 100644
--- a/src-tauri/src/proxy/usage/logger.rs
+++ b/src-tauri/src/proxy/usage/logger.rs
@@ -73,7 +73,7 @@ impl<'a> UsageLogger<'a> {
});
conn.execute(
- "INSERT INTO proxy_request_logs (
+ "INSERT OR REPLACE 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,
@@ -358,6 +358,7 @@ mod tests {
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
+ message_id: None,
};
logger.log_with_calculation(
diff --git a/src-tauri/src/proxy/usage/parser.rs b/src-tauri/src/proxy/usage/parser.rs
index 2541652c9..f2b797ee3 100644
--- a/src-tauri/src/proxy/usage/parser.rs
+++ b/src-tauri/src/proxy/usage/parser.rs
@@ -9,6 +9,9 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
+/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
+pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
+
/// Token 使用量统计
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TokenUsage {
@@ -18,6 +21,22 @@ pub struct TokenUsage {
pub cache_creation_tokens: u32,
/// 从响应中提取的实际模型名称(如果可用)
pub model: Option,
+ /// 从响应中提取的消息 ID(用于跨源去重)
+ ///
+ /// Claude API: `msg_xxx`,与 session JSONL 中的 `message.id` 一致
+ #[serde(skip)]
+ pub message_id: Option,
+}
+
+impl TokenUsage {
+ /// 生成与 session 日志共享的 request_id,用于跨源去重。
+ /// 有 message_id 时返回 `session:{id}`,否则回退到随机 UUID。
+ pub fn dedup_request_id(&self) -> String {
+ self.message_id
+ .as_ref()
+ .map(|mid| format!("{SESSION_REQUEST_ID_PREFIX}{mid}"))
+ .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
+ }
}
/// API 类型
@@ -39,6 +58,10 @@ impl TokenUsage {
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
+ let message_id = body
+ .get("id")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
Some(Self {
input_tokens: usage.get("input_tokens")?.as_u64()? as u32,
@@ -52,6 +75,7 @@ impl TokenUsage {
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
model,
+ message_id,
})
}
@@ -60,18 +84,23 @@ impl TokenUsage {
pub fn from_claude_stream_events(events: &[Value]) -> Option {
let mut usage = Self::default();
let mut model: Option = None;
+ let mut message_id: Option = None;
for event in events {
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
match event_type {
"message_start" => {
- // 从 message_start 提取模型名称
- if model.is_none() {
- if let Some(message) = event.get("message") {
+ if let Some(message) = event.get("message") {
+ if model.is_none() {
if let Some(m) = message.get("model").and_then(|v| v.as_str()) {
model = Some(m.to_string());
}
}
+ if message_id.is_none() {
+ if let Some(id) = message.get("id").and_then(|v| v.as_str()) {
+ message_id = Some(id.to_string());
+ }
+ }
}
if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) {
// 从 message_start 获取 input_tokens(原生 Claude API)
@@ -137,6 +166,7 @@ impl TokenUsage {
if usage.input_tokens > 0 || usage.output_tokens > 0 {
usage.model = model;
+ usage.message_id = message_id;
Some(usage)
} else {
None
@@ -153,6 +183,7 @@ impl TokenUsage {
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
+ message_id: None,
})
}
@@ -202,6 +233,7 @@ impl TokenUsage {
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
model,
+ message_id: None,
})
}
@@ -245,6 +277,7 @@ impl TokenUsage {
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
model,
+ message_id: None,
})
}
@@ -339,6 +372,7 @@ impl TokenUsage {
cache_read_tokens: cached_tokens,
cache_creation_tokens: 0,
model,
+ message_id: None,
})
}
@@ -383,6 +417,7 @@ impl TokenUsage {
.unwrap_or(0) as u32,
cache_creation_tokens: 0,
model,
+ message_id: None,
})
}
@@ -433,6 +468,7 @@ impl TokenUsage {
cache_read_tokens: total_cache_read,
cache_creation_tokens: 0,
model,
+ message_id: None,
})
} else {
None
diff --git a/src-tauri/src/services/model_fetch.rs b/src-tauri/src/services/model_fetch.rs
index 25c192867..2cc7f166b 100644
--- a/src-tauri/src/services/model_fetch.rs
+++ b/src-tauri/src/services/model_fetch.rs
@@ -41,7 +41,7 @@ pub async fn fetch_models(
}
let models_url = build_models_url(base_url, is_full_url)?;
- let client = crate::proxy::http_client::get_for_provider(None);
+ let client = crate::proxy::http_client::get();
let response = client
.get(&models_url)
diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs
index 3dec78d06..21147da65 100644
--- a/src-tauri/src/services/provider/live.rs
+++ b/src-tauri/src/services/provider/live.rs
@@ -1099,7 +1099,7 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
// One-time auth type detection to avoid repeated detection
let auth_type = detect_gemini_auth_type(provider);
- let mut env_map = json_to_env(&provider.settings_config)?;
+ let env_map = json_to_env(&provider.settings_config)?;
// Prepare config to write to ~/.gemini/settings.json
// Behavior:
@@ -1143,17 +1143,12 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
match auth_type {
GeminiAuthType::GoogleOfficial => {
- // Google official uses OAuth, clear env
- env_map.clear();
+ // Google Official uses OAuth, no API key validation needed.
+ // Write user's env vars as-is (e.g. GEMINI_MODEL, custom vars).
write_gemini_env_atomic(&env_map)?;
}
- GeminiAuthType::Packycode => {
- // PackyCode provider, uses API Key (strict validation on switch)
- validate_gemini_settings_strict(&provider.settings_config)?;
- write_gemini_env_atomic(&env_map)?;
- }
- GeminiAuthType::Generic => {
- // Generic provider, uses API Key (strict validation on switch)
+ GeminiAuthType::Packycode | GeminiAuthType::Generic => {
+ // API Key mode -- require GEMINI_API_KEY
validate_gemini_settings_strict(&provider.settings_config)?;
write_gemini_env_atomic(&env_map)?;
}
diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs
index fd0a4d03f..d16f18089 100644
--- a/src-tauri/src/services/provider/mod.rs
+++ b/src-tauri/src/services/provider/mod.rs
@@ -1407,6 +1407,16 @@ impl ProviderService {
// Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running;
+ // Block switching to official providers when proxy takeover is active.
+ // Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans.
+ if should_hot_switch && _provider.category.as_deref() == Some("official") {
+ return Err(AppError::localized(
+ "switch.official_blocked_by_proxy",
+ "代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。",
+ "Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.",
+ ));
+ }
+
if should_hot_switch {
// Proxy takeover mode: hot-switch only, don't write Live config
log::info!(
diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs
index b713670cc..267d71ca7 100644
--- a/src-tauri/src/services/proxy.rs
+++ b/src-tauri/src/services/proxy.rs
@@ -15,6 +15,7 @@ use crate::services::provider::{
use serde_json::{json, Value};
use std::str::FromStr;
use std::sync::Arc;
+use tauri::Emitter;
use tokio::sync::RwLock;
/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)
@@ -375,6 +376,26 @@ impl ProxyService {
// 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)
let _ = self.db.set_live_takeover_active(true).await;
+
+ // 8) Warn if the current provider is official (risk of account ban via proxy)
+ if let Ok(Some(current_id)) =
+ crate::settings::get_effective_current_provider(&self.db, &app)
+ {
+ if let Ok(Some(provider)) = self.db.get_provider_by_id(¤t_id, app_type_str) {
+ if provider.category.as_deref() == Some("official") {
+ if let Some(handle) = self.app_handle.read().await.as_ref() {
+ let _ = handle.emit(
+ "proxy-official-warning",
+ serde_json::json!({
+ "appType": app_type_str,
+ "providerName": provider.name,
+ }),
+ );
+ }
+ }
+ }
+ }
+
return Ok(());
}
@@ -1548,6 +1569,14 @@ impl ProxyService {
.map_err(|e| format!("读取供应商失败: {e}"))?
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
+ // Defense-in-depth: block official providers during proxy takeover
+ if provider.category.as_deref() == Some("official") {
+ return Err(
+ "代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
+ .to_string(),
+ );
+ }
+
let logical_target_changed =
crate::settings::get_effective_current_provider(&self.db, &app_type_enum)
.map_err(|e| format!("读取当前供应商失败: {e}"))?
diff --git a/src-tauri/src/services/session_usage.rs b/src-tauri/src/services/session_usage.rs
index d821f35d2..2b9c5af9f 100644
--- a/src-tauri/src/services/session_usage.rs
+++ b/src-tauri/src/services/session_usage.rs
@@ -278,7 +278,11 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr
continue;
}
- let request_id = format!("session:{}", msg.message_id);
+ let request_id = format!(
+ "{}{}",
+ crate::proxy::usage::parser::SESSION_REQUEST_ID_PREFIX,
+ msg.message_id
+ );
// 跳过 output_tokens 为 0 的无意义条目
if msg.output_tokens == 0 {
@@ -379,6 +383,7 @@ fn insert_session_log_entry(
cache_read_tokens: msg.cache_read_tokens,
cache_creation_tokens: msg.cache_creation_tokens,
model: Some(msg.model.clone()),
+ message_id: None,
};
let pricing = find_model_pricing_for_session(&conn, &msg.model);
diff --git a/src-tauri/src/services/session_usage_codex.rs b/src-tauri/src/services/session_usage_codex.rs
index 13dff44b0..bd92e2ea1 100644
--- a/src-tauri/src/services/session_usage_codex.rs
+++ b/src-tauri/src/services/session_usage_codex.rs
@@ -474,6 +474,7 @@ fn insert_codex_session_entry(
cache_read_tokens: delta.cached_input,
cache_creation_tokens: 0,
model: Some(model.to_string()),
+ message_id: None,
};
let pricing = find_codex_pricing(&conn, model);
diff --git a/src-tauri/src/services/session_usage_gemini.rs b/src-tauri/src/services/session_usage_gemini.rs
index 8bac1fa4f..24ff25c37 100644
--- a/src-tauri/src/services/session_usage_gemini.rs
+++ b/src-tauri/src/services/session_usage_gemini.rs
@@ -261,6 +261,7 @@ fn insert_gemini_session_entry(
cache_read_tokens: tokens.cached,
cache_creation_tokens: 0,
model: Some(model.to_string()),
+ message_id: None,
};
let pricing = find_gemini_pricing(&conn, model);
diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs
index f94dd8391..2acbdfae9 100644
--- a/src-tauri/src/services/skill.rs
+++ b/src-tauri/src/services/skill.rs
@@ -680,6 +680,13 @@ impl SkillService {
found.display()
);
source = found;
+ } else if temp_dir.join("SKILL.md").exists() {
+ // 根级 Skill:仓库本身就是 skill,SKILL.md 直接在解压根目录
+ log::info!(
+ "Skill directory '{}' not found, but SKILL.md exists at root, using temp_dir",
+ target_name,
+ );
+ source = temp_dir.clone();
} else {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs
index 24ac6263f..7b289e80b 100644
--- a/src-tauri/src/services/stream_check.rs
+++ b/src-tauri/src/services/stream_check.rs
@@ -220,9 +220,8 @@ impl StreamCheckService {
.or_else(|| adapter.extract_auth(provider))
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
- // 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
- let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
- let client = crate::proxy::http_client::get_for_provider(proxy_config);
+ // 获取 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);
@@ -274,34 +273,11 @@ impl StreamCheckService {
};
let response_time = start.elapsed().as_millis() as u64;
- let tested_at = chrono::Utc::now().timestamp();
-
- match result {
- Ok((status_code, model)) => {
- let health_status =
- Self::determine_status(response_time, config.degraded_threshold_ms);
- Ok(StreamCheckResult {
- status: health_status,
- 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,
- })
- }
- Err(e) => Ok(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,
- }),
- }
+ Ok(Self::build_stream_check_result(
+ result,
+ response_time,
+ config.degraded_threshold_ms,
+ ))
}
/// Claude 流式检查
@@ -385,7 +361,7 @@ impl StreamCheckService {
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, Some(&provider.id))
+ anthropic_to_openai(anthropic_body)
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
} else {
anthropic_body
@@ -506,7 +482,7 @@ impl StreamCheckService {
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
- return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
+ return Err(Self::http_status_error(status, error_text));
}
// 流式读取:只需首个 chunk
@@ -586,7 +562,7 @@ impl StreamCheckService {
if i == 0 && status == 404 && urls.len() > 1 {
continue;
}
- return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
+ return Err(Self::http_status_error(status, error_text));
}
let mut stream = response.bytes_stream();
@@ -661,7 +637,7 @@ impl StreamCheckService {
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
- return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
+ return Err(Self::http_status_error(status, error_text));
}
let mut stream = response.bytes_stream();
@@ -689,9 +665,8 @@ impl StreamCheckService {
config: &StreamCheckConfig,
start: Instant,
) -> Result {
- // 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
- let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
- let client = crate::proxy::http_client::get_for_provider(proxy_config);
+ // 获取 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);
@@ -749,16 +724,25 @@ impl StreamCheckService {
tested_at,
retry_count: 0,
},
- 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,
- },
+ Err(e) => {
+ let (http_status, message) = match &e {
+ AppError::HttpStatus { status, .. } => (
+ Some(*status),
+ Self::classify_http_status(*status).to_string(),
+ ),
+ _ => (None, e.to_string()),
+ };
+ StreamCheckResult {
+ status: HealthStatus::Failed,
+ success: false,
+ message,
+ response_time_ms: Some(response_time),
+ http_status,
+ model_used: String::new(),
+ tested_at,
+ retry_count: 0,
+ }
+ }
}
}
@@ -1156,6 +1140,39 @@ impl StreamCheckService {
}
}
+ /// 构造 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,
diff --git a/src-tauri/src/session_manager/providers/claude.rs b/src-tauri/src/session_manager/providers/claude.rs
index 226613d13..d02eaba22 100644
--- a/src-tauri/src/session_manager/providers/claude.rs
+++ b/src-tauri/src/session_manager/providers/claude.rs
@@ -9,10 +9,10 @@ use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
+ TITLE_MAX_CHARS,
};
const PROVIDER_ID: &str = "claude";
-const TITLE_MAX_CHARS: usize = 80;
pub fn scan_sessions() -> Vec {
let root = get_claude_config_dir().join("projects");
diff --git a/src-tauri/src/session_manager/providers/codex.rs b/src-tauri/src/session_manager/providers/codex.rs
index ce40e31f7..5d3c7d364 100644
--- a/src-tauri/src/session_manager/providers/codex.rs
+++ b/src-tauri/src/session_manager/providers/codex.rs
@@ -11,6 +11,7 @@ use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
+ TITLE_MAX_CHARS,
};
const PROVIDER_ID: &str = "codex";
@@ -129,8 +130,9 @@ fn parse_session(path: &Path) -> Option {
let mut session_id: Option = None;
let mut project_dir: Option = None;
let mut created_at: Option = None;
+ let mut first_user_message: Option = None;
- // Extract metadata from head lines
+ // Extract metadata and first user message from head lines
for line in &head {
let value: Value = match serde_json::from_str(line) {
Ok(parsed) => parsed,
@@ -158,6 +160,29 @@ fn parse_session(path: &Path) -> Option {
}
}
}
+ // Extract first user message as title candidate
+ if first_user_message.is_none()
+ && value.get("type").and_then(Value::as_str) == Some("response_item")
+ {
+ if let Some(payload) = value.get("payload") {
+ if payload.get("type").and_then(Value::as_str) == Some("message")
+ && payload.get("role").and_then(Value::as_str) == Some("user")
+ {
+ let text = payload.get("content").map(extract_text).unwrap_or_default();
+ let trimmed = text.trim();
+ if !trimmed.is_empty() && !trimmed.starts_with("# AGENTS.md") {
+ first_user_message = Some(trimmed.to_string());
+ }
+ }
+ }
+ }
+ if session_id.is_some()
+ && project_dir.is_some()
+ && created_at.is_some()
+ && first_user_message.is_some()
+ {
+ break;
+ }
}
// Extract last_active_at and summary from tail lines (reverse order)
@@ -190,10 +215,14 @@ fn parse_session(path: &Path) -> Option {
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
let session_id = session_id?;
- let title = project_dir
- .as_deref()
- .and_then(path_basename)
- .map(|value| value.to_string());
+ let title = first_user_message
+ .map(|t| truncate_summary(&t, TITLE_MAX_CHARS))
+ .or_else(|| {
+ project_dir
+ .as_deref()
+ .and_then(path_basename)
+ .map(|v| v.to_string())
+ });
let summary = summary.map(|text| truncate_summary(&text, 160));
@@ -261,6 +290,82 @@ mod tests {
assert!(!path.exists());
}
+ #[test]
+ fn parse_session_uses_first_user_message_as_title() {
+ let temp = tempdir().expect("tempdir");
+ let path = temp.path().join("session.jsonl");
+ std::fs::write(
+ &path,
+ concat!(
+ "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
+ "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"How do I deploy?\"}}\n",
+ "{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Here is how...\"}}\n"
+ ),
+ )
+ .expect("write");
+
+ let meta = parse_session(&path).unwrap();
+ assert_eq!(meta.title.as_deref(), Some("How do I deploy?"));
+ }
+
+ #[test]
+ fn parse_session_skips_agents_md_injection() {
+ let temp = tempdir().expect("tempdir");
+ let path = temp.path().join("session.jsonl");
+ std::fs::write(
+ &path,
+ concat!(
+ "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
+ "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"developer\",\"content\":\"\"}}\n",
+ "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# AGENTS.md instructions for /tmp/project\\nDo stuff\"}}\n",
+ "{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Fix the login bug\"}}\n"
+ ),
+ )
+ .expect("write");
+
+ let meta = parse_session(&path).unwrap();
+ // Should skip AGENTS.md injection and use the real user message
+ assert_eq!(meta.title.as_deref(), Some("Fix the login bug"));
+ }
+
+ #[test]
+ fn parse_session_falls_back_to_dir_basename() {
+ let temp = tempdir().expect("tempdir");
+ let path = temp.path().join("session.jsonl");
+ std::fs::write(
+ &path,
+ concat!(
+ "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/my-project\"}}\n",
+ "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Hello\"}}\n"
+ ),
+ )
+ .expect("write");
+
+ let meta = parse_session(&path).unwrap();
+ // No user message → falls back to dir basename
+ assert_eq!(meta.title.as_deref(), Some("my-project"));
+ }
+
+ #[test]
+ fn parse_session_truncates_long_title() {
+ let temp = tempdir().expect("tempdir");
+ let path = temp.path().join("session.jsonl");
+ let long_msg = "a".repeat(200);
+ std::fs::write(
+ &path,
+ format!(
+ "{{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"test-id\",\"cwd\":\"/tmp/p\"}}}}\n\
+ {{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":\"{long_msg}\"}}}}\n",
+ ),
+ )
+ .expect("write");
+
+ let meta = parse_session(&path).unwrap();
+ let title = meta.title.unwrap();
+ assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..."
+ assert!(title.ends_with("..."));
+ }
+
#[test]
fn load_messages_includes_function_call_and_output() {
let temp = tempdir().expect("tempdir");
diff --git a/src-tauri/src/session_manager/providers/openclaw.rs b/src-tauri/src/session_manager/providers/openclaw.rs
index a56b56389..d0a4d92d5 100644
--- a/src-tauri/src/session_manager/providers/openclaw.rs
+++ b/src-tauri/src/session_manager/providers/openclaw.rs
@@ -1,3 +1,4 @@
+use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
@@ -12,10 +13,20 @@ use crate::{
use super::utils::{
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
+ TITLE_MAX_CHARS,
};
const PROVIDER_ID: &str = "openclaw";
+/// Strip trailing `\n[message_id: ...]` metadata injected by OpenClaw gateway.
+fn strip_message_id_suffix(text: &str) -> &str {
+ if let Some(pos) = text.rfind("\n[message_id:") {
+ text[..pos].trim_end()
+ } else {
+ text
+ }
+}
+
pub fn scan_sessions() -> Vec {
let agents_dir = get_openclaw_dir().join("agents");
if !agents_dir.exists() {
@@ -46,22 +57,15 @@ pub fn scan_sessions() -> Vec {
Err(_) => continue,
};
+ let display_names = load_display_names(&sessions_dir);
+
for entry in session_entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
continue;
}
- // Skip sessions.json index file
- if path
- .file_name()
- .and_then(|n| n.to_str())
- .map(|n| n == "sessions.json")
- .unwrap_or(false)
- {
- continue;
- }
- if let Some(meta) = parse_session(&path) {
+ if let Some(meta) = parse_session(&path, Some(&display_names)) {
sessions.push(meta);
}
}
@@ -119,7 +123,7 @@ pub fn load_messages(path: &Path) -> Result, String> {
}
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result {
- let meta = parse_session(path).ok_or_else(|| {
+ let meta = parse_session(path, None).ok_or_else(|| {
format!(
"Failed to parse OpenClaw session metadata: {}",
path.display()
@@ -149,15 +153,46 @@ pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result Option {
+/// Read `sessions.json` index and build a sessionId → displayName lookup map.
+/// Returns an empty map if the file does not exist or cannot be parsed.
+fn load_display_names(sessions_dir: &Path) -> HashMap {
+ let index_path = sessions_dir.join("sessions.json");
+ let content = match std::fs::read_to_string(&index_path) {
+ Ok(c) => c,
+ Err(_) => return HashMap::new(),
+ };
+ let index: serde_json::Map = match serde_json::from_str(&content) {
+ Ok(m) => m,
+ Err(_) => return HashMap::new(),
+ };
+
+ let mut map = HashMap::new();
+ for (_key, entry) in &index {
+ if let (Some(id), Some(name)) = (
+ entry.get("sessionId").and_then(Value::as_str),
+ entry.get("displayName").and_then(Value::as_str),
+ ) {
+ if !name.is_empty() {
+ map.insert(id.to_string(), name.to_string());
+ }
+ }
+ }
+ map
+}
+
+fn parse_session(
+ path: &Path,
+ display_names: Option<&HashMap>,
+) -> Option {
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
let mut session_id: Option = None;
let mut cwd: Option = None;
let mut created_at: Option = None;
let mut summary: Option = None;
+ let mut first_user_message: Option = None;
- // Extract metadata and first message summary from head lines
+ // Extract metadata, summary, and first user message from head lines
for line in &head {
let value: Value = match serde_json::from_str(line) {
Ok(parsed) => parsed,
@@ -189,15 +224,31 @@ fn parse_session(path: &Path) -> Option {
continue;
}
- // OpenClaw summary is the first message content
- if event_type == "message" && summary.is_none() {
+ if event_type == "message" {
if let Some(message) = value.get("message") {
let text = message.get("content").map(extract_text).unwrap_or_default();
- if !text.trim().is_empty() {
- summary = Some(text);
+ let cleaned = strip_message_id_suffix(&text);
+ if !cleaned.trim().is_empty() {
+ if first_user_message.is_none()
+ && message.get("role").and_then(Value::as_str) == Some("user")
+ {
+ first_user_message = Some(cleaned.trim().to_string());
+ }
+ if summary.is_none() {
+ summary = Some(cleaned.trim().to_string());
+ }
}
}
}
+
+ if session_id.is_some()
+ && cwd.is_some()
+ && created_at.is_some()
+ && summary.is_some()
+ && first_user_message.is_some()
+ {
+ break;
+ }
}
// Extract last_active_at from tail lines (reverse order)
@@ -221,10 +272,17 @@ fn parse_session(path: &Path) -> Option {
});
let session_id = session_id?;
- let title = cwd
- .as_deref()
- .and_then(path_basename)
- .map(|s| s.to_string());
+ // Title priority: displayName (from sessions.json) > first user message > dir basename
+ let title = display_names
+ .and_then(|m| m.get(&session_id))
+ .filter(|s| !s.is_empty())
+ .map(|t| truncate_summary(t, TITLE_MAX_CHARS))
+ .or_else(|| first_user_message.map(|t| truncate_summary(&t, TITLE_MAX_CHARS)))
+ .or_else(|| {
+ cwd.as_deref()
+ .and_then(path_basename)
+ .map(|s| s.to_string())
+ });
let summary = summary.map(|text| truncate_summary(&text, 160));
@@ -284,6 +342,93 @@ mod tests {
use super::*;
use tempfile::tempdir;
+ #[test]
+ fn parse_session_uses_first_user_message_as_title() {
+ let temp = tempdir().expect("tempdir");
+ let path = temp.path().join("session-abc.jsonl");
+ std::fs::write(
+ &path,
+ concat!(
+ "{\"type\":\"session\",\"id\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
+ "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"How do I deploy?\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n",
+ "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Here is how...\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n"
+ ),
+ )
+ .expect("write");
+
+ let meta = parse_session(&path, None).unwrap();
+ assert_eq!(meta.title.as_deref(), Some("How do I deploy?"));
+ }
+
+ #[test]
+ fn parse_session_display_name_overrides_user_message() {
+ let temp = tempdir().expect("tempdir");
+ let sessions_dir = temp.path();
+
+ let path = sessions_dir.join("session-abc.jsonl");
+ std::fs::write(
+ &path,
+ concat!(
+ "{\"type\":\"session\",\"id\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
+ "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"fix something\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
+ ),
+ )
+ .expect("write session");
+
+ std::fs::write(
+ sessions_dir.join("sessions.json"),
+ r#"{
+ "agent:main:main": {
+ "sessionId": "session-abc",
+ "displayName": "重构登录模块"
+ }
+ }"#,
+ )
+ .expect("write index");
+
+ let display_names = load_display_names(sessions_dir);
+ let meta = parse_session(&path, Some(&display_names)).unwrap();
+ assert_eq!(meta.title.as_deref(), Some("重构登录模块"));
+ }
+
+ #[test]
+ fn parse_session_falls_back_to_dir_basename() {
+ let temp = tempdir().expect("tempdir");
+ let path = temp.path().join("session-def.jsonl");
+ std::fs::write(
+ &path,
+ concat!(
+ "{\"type\":\"session\",\"id\":\"session-def\",\"cwd\":\"/tmp/my-project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
+ "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
+ ),
+ )
+ .expect("write");
+
+ let meta = parse_session(&path, None).unwrap();
+ // No user message and no displayName → falls back to dir basename
+ assert_eq!(meta.title.as_deref(), Some("my-project"));
+ }
+
+ #[test]
+ fn parse_session_truncates_long_title() {
+ let temp = tempdir().expect("tempdir");
+ let path = temp.path().join("session-trunc.jsonl");
+ let long_msg = "a".repeat(200);
+ std::fs::write(
+ &path,
+ format!(
+ "{{\"type\":\"session\",\"id\":\"session-trunc\",\"cwd\":\"/tmp/p\",\"timestamp\":\"2026-03-06T10:00:00Z\"}}\n\
+ {{\"type\":\"message\",\"message\":{{\"role\":\"user\",\"content\":\"{long_msg}\"}},\"timestamp\":\"2026-03-06T10:01:00Z\"}}\n",
+ ),
+ )
+ .expect("write");
+
+ let meta = parse_session(&path, None).unwrap();
+ let title = meta.title.unwrap();
+ assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..."
+ assert!(title.ends_with("..."));
+ }
+
#[test]
fn delete_session_updates_index_and_removes_jsonl() {
let temp = tempdir().expect("tempdir");
diff --git a/src-tauri/src/session_manager/providers/utils.rs b/src-tauri/src/session_manager/providers/utils.rs
index dbbaa001d..4339ae3e7 100644
--- a/src-tauri/src/session_manager/providers/utils.rs
+++ b/src-tauri/src/session_manager/providers/utils.rs
@@ -5,6 +5,9 @@ use std::path::Path;
use chrono::{DateTime, FixedOffset};
use serde_json::Value;
+/// Maximum number of characters for session titles (shared across providers).
+pub const TITLE_MAX_CHARS: usize = 80;
+
/// Read the first `head_n` lines and last `tail_n` lines from a file.
/// For small files (< 16 KB), reads all lines once to avoid unnecessary seeking.
pub fn read_head_tail_lines(
diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs
index c9b4a4b41..8b41ba517 100644
--- a/src-tauri/src/settings.rs
+++ b/src-tauri/src/settings.rs
@@ -175,6 +175,8 @@ pub struct AppSettings {
pub show_in_tray: bool,
#[serde(default = "default_minimize_to_tray_on_close")]
pub minimize_to_tray_on_close: bool,
+ #[serde(default)]
+ pub use_app_window_controls: bool,
/// 是否启用 Claude 插件联动
#[serde(default)]
pub enable_claude_plugin_integration: bool,
@@ -293,6 +295,7 @@ impl Default for AppSettings {
Self {
show_in_tray: true,
minimize_to_tray_on_close: true,
+ use_app_window_controls: false,
enable_claude_plugin_integration: false,
skip_claude_onboarding: false,
launch_on_startup: false,
diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs
index 31af81b42..4bd63f1f6 100644
--- a/src-tauri/src/tray.rs
+++ b/src-tauri/src/tray.rs
@@ -297,6 +297,9 @@ pub fn create_tray_menu(
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
menu_builder = menu_builder.item(&show_main_item).separator();
+ // Pre-compute proxy running state (used to disable official providers in tray menu)
+ let is_proxy_running = futures::executor::block_on(app_state.proxy_service.is_running());
+
// 每个应用类型折叠为子菜单,避免供应商过多时菜单过长
for section in TRAY_SECTIONS.iter() {
if !visible_apps.is_visible(§ion.app_type) {
@@ -327,15 +330,32 @@ pub fn create_tray_menu(
};
let submenu_id = format!("submenu_{}", app_type_str);
+ // Check if this app is under proxy takeover (for disabling official providers)
+ let is_app_taken_over = is_proxy_running
+ && (futures::executor::block_on(app_state.db.get_live_backup(app_type_str))
+ .ok()
+ .flatten()
+ .is_some()
+ || app_state
+ .proxy_service
+ .detect_takeover_in_live_config_for_app(§ion.app_type));
+
let mut submenu_builder = SubmenuBuilder::with_id(app, &submenu_id, &submenu_label);
for (id, provider) in sort_providers(&providers) {
let is_current = current_id == *id;
+ let is_official_blocked =
+ is_app_taken_over && provider.category.as_deref() == Some("official");
+ let label = if is_official_blocked {
+ format!("{} \u{26D4}", &provider.name) // ⛔ emoji
+ } else {
+ provider.name.clone()
+ };
let item = CheckMenuItem::with_id(
app,
format!("{}{}", section.prefix, id),
- &provider.name,
- true,
+ &label,
+ !is_official_blocked, // disabled when blocked
is_current,
None::<&str>,
)
diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs
index f9afdb220..7d121e4fa 100644
--- a/src-tauri/src/usage_script.rs
+++ b/src-tauri/src/usage_script.rs
@@ -104,8 +104,7 @@ pub async fn execute_usage_script(
)
})?;
- // 5. 验证请求 URL 是否安全(防止 SSRF)
- // 如果提供了 base_url,则验证同源;否则只做基本安全检查
+ // 5. 验证请求 URL(HTTPS 强制 + 同源检查)
validate_request_url(&request.url, base_url, is_custom_template)?;
// 6. 发送 HTTP 请求
@@ -468,19 +467,10 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> {
));
}
- // 检查是否为明显的私有IP(但在 base_url 阶段不过于严格,主要在 request_url 阶段检查)
- if is_suspicious_hostname(hostname) {
- return Err(AppError::localized(
- "usage_script.base_url_suspicious",
- "base_url 包含可疑的主机名",
- "base_url contains a suspicious hostname",
- ));
- }
-
Ok(())
}
-/// 验证请求 URL 是否安全(防止 SSRF)
+/// 验证请求 URL 是否安全(HTTPS 强制 + 同源检查)
fn validate_request_url(
request_url: &str,
base_url: &str,
@@ -561,151 +551,11 @@ fn validate_request_url(
));
}
}
-
- // 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
- if let Some(host) = parsed_request.host_str() {
- let base_host = parsed_base.host_str().unwrap_or("");
-
- // 如果 base_url 不是私有地址,则禁止访问私有IP
- if !is_private_ip(base_host) && is_private_ip(host) {
- return Err(AppError::localized(
- "usage_script.private_ip_blocked",
- "禁止访问私有 IP 地址",
- "Access to private IP addresses is blocked",
- ));
- }
- }
- } else {
- // 自定义模板模式:没有 base_url,需要额外的安全检查
- // 禁止访问私有 IP 地址(SSRF 防护)
- if let Some(host) = parsed_request.host_str() {
- if is_private_ip(host) && !is_request_loopback {
- return Err(AppError::localized(
- "usage_script.private_ip_blocked",
- "禁止访问私有 IP 地址(localhost 除外)",
- "Access to private IP addresses is blocked (localhost allowed)",
- ));
- }
- }
}
Ok(())
}
-/// 检查是否为私有 IP 地址
-fn is_private_ip(host: &str) -> bool {
- // localhost 检查
- if host.eq_ignore_ascii_case("localhost") {
- return true;
- }
-
- // 尝试解析为IP地址
- if let Ok(ip_addr) = host.parse::() {
- return is_private_ip_addr(ip_addr);
- }
-
- // 如果不是IP地址,不是私有IP
- false
-}
-
-/// 使用标准库API检查IP地址是否为私有地址
-fn is_private_ip_addr(ip: std::net::IpAddr) -> bool {
- match ip {
- std::net::IpAddr::V4(ipv4) => {
- let octets = ipv4.octets();
-
- // 0.0.0.0/8 (包括未指定地址)
- if octets[0] == 0 {
- return true;
- }
-
- // RFC1918 私有地址范围
- // 10.0.0.0/8
- if octets[0] == 10 {
- return true;
- }
-
- // 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
- if octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31 {
- return true;
- }
-
- // 192.168.0.0/16
- if octets[0] == 192 && octets[1] == 168 {
- return true;
- }
-
- // 其他特殊地址
- // 169.254.0.0/16 (链路本地地址)
- if octets[0] == 169 && octets[1] == 254 {
- return true;
- }
-
- // 127.0.0.0/8 (环回地址)
- if octets[0] == 127 {
- return true;
- }
-
- false
- }
- std::net::IpAddr::V6(ipv6) => {
- // IPv6 私有地址检查 - 使用标准库方法
-
- // ::1 (环回地址)
- if ipv6.is_loopback() {
- return true;
- }
-
- // 唯一本地地址 (fc00::/7)
- // Rust 1.70+ 可以使用 ipv6.is_unique_local()
- // 但为了兼容性,我们手动检查
- let first_segment = ipv6.segments()[0];
- if (first_segment & 0xfe00) == 0xfc00 {
- return true;
- }
-
- // 链路本地地址 (fe80::/10)
- if (first_segment & 0xffc0) == 0xfe80 {
- return true;
- }
-
- // 未指定地址 ::
- if ipv6.is_unspecified() {
- return true;
- }
-
- false
- }
- }
-}
-
-/// 检查是否为可疑的主机名(只检查明显不安全的模式)
-fn is_suspicious_hostname(hostname: &str) -> bool {
- // 空主机名
- if hostname.is_empty() {
- return true;
- }
-
- // 检查明显的主机名格式问题
- if hostname.contains("..") || hostname.starts_with(".") || hostname.ends_with(".") {
- return true;
- }
-
- // 检查是否为纯IP地址但没有合理格式(过于宽松的检查在这里可能不够,但主要依赖后续的同源检查)
- if hostname.parse::().is_ok() {
- // IP地址格式的,在这里不直接拒绝,让同源检查来处理
- return false;
- }
-
- // 检查是否包含明显不当的字符
- let suspicious_chars = ['<', '>', '"', '\'', '\n', '\r', '\t', '\0'];
- if hostname.chars().any(|c| suspicious_chars.contains(&c)) {
- return true;
- }
-
- false
-}
-
/// 判断 URL 是否指向本机(localhost / loopback)
fn is_loopback_host(url: &Url) -> bool {
match url.host() {
@@ -720,77 +570,6 @@ fn is_loopback_host(url: &Url) -> bool {
mod tests {
use super::*;
- #[test]
- fn test_private_ip_validation() {
- // 测试IPv4私网地址
-
- // RFC1918私网地址 - 应该返回true
- assert!(is_private_ip("10.0.0.1"));
- assert!(is_private_ip("10.255.255.254"));
- assert!(is_private_ip("172.16.0.1"));
- assert!(is_private_ip("172.31.255.255"));
- assert!(is_private_ip("192.168.0.1"));
- assert!(is_private_ip("192.168.255.255"));
-
- // 链路本地地址 - 应该返回true
- assert!(is_private_ip("169.254.0.1"));
- assert!(is_private_ip("169.254.255.255"));
-
- // 环回地址 - 应该返回true
- assert!(is_private_ip("127.0.0.1"));
- assert!(is_private_ip("localhost"));
-
- // 公网172.x.x.x地址 - 应该返回false(这是修复的重点)
- assert!(!is_private_ip("172.0.0.1"));
- assert!(!is_private_ip("172.15.255.255"));
- assert!(!is_private_ip("172.32.0.1"));
- assert!(!is_private_ip("172.64.0.1"));
- assert!(!is_private_ip("172.67.0.1")); // Cloudflare CDN
- assert!(!is_private_ip("172.68.0.1"));
- assert!(!is_private_ip("172.100.50.25"));
- assert!(!is_private_ip("172.255.255.255"));
-
- // 其他公网地址 - 应该返回false
- assert!(!is_private_ip("8.8.8.8")); // Google DNS
- assert!(!is_private_ip("1.1.1.1")); // Cloudflare DNS
- assert!(!is_private_ip("208.67.222.222")); // OpenDNS
- assert!(!is_private_ip("180.76.76.76")); // Baidu DNS
-
- // 域名 - 应该返回false
- assert!(!is_private_ip("api.example.com"));
- assert!(!is_private_ip("www.google.com"));
- }
-
- #[test]
- fn test_ipv6_private_validation() {
- // IPv6私网地址
- assert!(is_private_ip("::1")); // 环回地址
- assert!(is_private_ip("fc00::1")); // 唯一本地地址
- assert!(is_private_ip("fd00::1")); // 唯一本地地址
- assert!(is_private_ip("fe80::1")); // 链路本地地址
- assert!(is_private_ip("::")); // 未指定地址
-
- // IPv6公网地址 - 应该返回false(修复的重点)
- assert!(!is_private_ip("2001:4860:4860::8888")); // Google DNS IPv6
- assert!(!is_private_ip("2606:4700:4700::1111")); // Cloudflare DNS IPv6
- assert!(!is_private_ip("2404:6800:4001:c01::67")); // Google DNS IPv6 (其他格式)
- assert!(!is_private_ip("2001:db8::1")); // 文档地址(非私网)
-
- // 测试包含 ::1 子串但不是环回地址的公网地址
- assert!(!is_private_ip("2001:db8::1abc")); // 包含 ::1abc 但不是环回
- assert!(!is_private_ip("2606:4700::1")); // 包含 ::1 但不是环回
- }
-
- #[test]
- fn test_hostname_bypass_prevention() {
- // 看起来像本地,但实际是域名
- assert!(!is_private_ip("127.0.0.1.evil.com"));
- assert!(!is_private_ip("localhost.evil.com"));
-
- // 0.0.0.0 应该被视为本地/阻断
- assert!(is_private_ip("0.0.0.0"));
- }
-
#[test]
fn test_https_bypass_prevention() {
// 非本地域名的 HTTP 应该被拒绝
@@ -801,37 +580,6 @@ mod tests {
);
}
- #[test]
- fn test_edge_cases() {
- // 边界情况测试
- assert!(is_private_ip("172.16.0.0")); // RFC1918起始
- assert!(is_private_ip("172.31.255.255")); // RFC1918结束
- assert!(is_private_ip("10.0.0.0")); // 10.0.0.0/8起始
- assert!(is_private_ip("10.255.255.255")); // 10.0.0.0/8结束
- assert!(is_private_ip("192.168.0.0")); // 192.168.0.0/16起始
- assert!(is_private_ip("192.168.255.255")); // 192.168.0.0/16结束
-
- // 紧邻RFC1918的公网地址 - 应该返回false
- assert!(!is_private_ip("172.15.255.255")); // 172.16.0.0的前一个
- assert!(!is_private_ip("172.32.0.0")); // 172.31.255.255的后一个
- }
-
- #[test]
- fn test_ip_addr_parsing() {
- // 测试IP地址解析功能
- let ipv4_private = "10.0.0.1".parse::().unwrap();
- assert!(is_private_ip_addr(ipv4_private));
-
- let ipv4_public = "172.67.0.1".parse::().unwrap();
- assert!(!is_private_ip_addr(ipv4_public));
-
- let ipv6_private = "fc00::1".parse::().unwrap();
- assert!(is_private_ip_addr(ipv6_private));
-
- let ipv6_public = "2001:4860:4860::8888".parse::().unwrap();
- assert!(!is_private_ip_addr(ipv6_public));
- }
-
#[test]
fn test_port_comparison() {
// 测试端口比较逻辑是否正确处理默认端口和显式端口
diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs
index c4400c230..2b9eddaa9 100644
--- a/src-tauri/tests/provider_service.rs
+++ b/src-tauri/tests/provider_service.rs
@@ -525,7 +525,7 @@ fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
}
#[test]
-fn switch_google_official_gemini_sets_oauth_security() {
+fn switch_google_official_gemini_preserves_env_vars() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
@@ -540,7 +540,9 @@ fn switch_google_official_gemini_sets_oauth_security() {
"google-official".to_string(),
"Google".to_string(),
json!({
- "env": {}
+ "env": {
+ "GEMINI_MODEL": "gemini-2.5-pro"
+ }
}),
Some("https://ai.google.dev".to_string()),
);
@@ -558,23 +560,30 @@ fn switch_google_official_gemini_sets_oauth_security() {
ProviderService::switch(&state, AppType::Gemini, "google-official")
.expect("switching to Google official Gemini should succeed");
- // Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
- let gemini_settings = home.join(".gemini").join("settings.json");
+ // Verify env vars are preserved in ~/.gemini/.env
+ let env_path = home.join(".gemini").join(".env");
assert!(
- gemini_settings.exists(),
- "Gemini settings.json should exist at {}",
- gemini_settings.display()
+ env_path.exists(),
+ "Gemini .env should exist at {}",
+ env_path.display()
);
+ let env_content = std::fs::read_to_string(&env_path).expect("read gemini .env");
+ assert!(
+ env_content.contains("GEMINI_MODEL=gemini-2.5-pro"),
+ "GEMINI_MODEL should be preserved in .env, got: {env_content}"
+ );
+
+ // Verify OAuth security flag is still set correctly
+ let gemini_settings = home.join(".gemini").join("settings.json");
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
let gemini_value: serde_json::Value =
serde_json::from_str(&gemini_raw).expect("parse gemini settings");
-
assert_eq!(
gemini_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
- "Gemini settings json should reflect oauth-personal for Google Official"
+ "OAuth security flag should still be set"
);
}
diff --git a/src/App.tsx b/src/App.tsx
index 348ff6d71..4406ac753 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -9,6 +9,10 @@ import {
Plus,
Settings,
ArrowLeft,
+ Minus,
+ Maximize2,
+ Minimize2,
+ X,
Book,
Wrench,
RefreshCw,
@@ -22,6 +26,7 @@ import {
Shield,
Cpu,
} from "lucide-react";
+import { getCurrentWindow } from "@tauri-apps/api/window";
import type { Provider, VisibleApps } from "@/types";
import type { EnvConflict } from "@/types/env";
import { useProvidersQuery, useSettingsQuery } from "@/lib/query";
@@ -99,9 +104,8 @@ interface WebDavSyncStatusUpdatedPayload {
error?: string;
}
-const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
+const DEFAULT_DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
const HEADER_HEIGHT = 64; // px
-const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
const STORAGE_KEY = "cc-switch-last-app";
const VALID_APPS: AppId[] = [
@@ -153,12 +157,17 @@ function App() {
const [currentView, setCurrentView] = useState(getInitialView);
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
const [isAddOpen, setIsAddOpen] = useState(false);
+ const [isWindowMaximized, setIsWindowMaximized] = useState(false);
useEffect(() => {
localStorage.setItem(VIEW_STORAGE_KEY, currentView);
}, [currentView]);
const { data: settingsData } = useSettingsQuery();
+ const useAppWindowControls =
+ isLinux() && (settingsData?.useAppWindowControls ?? false);
+ const dragBarHeight = useAppWindowControls ? 32 : DEFAULT_DRAG_BAR_HEIGHT;
+ const contentTopOffset = dragBarHeight + HEADER_HEIGHT;
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
claude: true,
codex: true,
@@ -261,7 +270,11 @@ function App() {
deleteProvider,
saveUsageScript,
setAsDefaultModel,
- } = useProviderActions(activeApp, isProxyRunning);
+ } = useProviderActions(
+ activeApp,
+ isProxyRunning,
+ isProxyRunning && isCurrentAppTakeoverActive,
+ );
const disableOmoMutation = useDisableCurrentOmo();
const handleDisableOmo = () => {
@@ -392,6 +405,77 @@ function App() {
};
}, [queryClient, t]);
+ // Listen for proxy-official-warning: warn when takeover is enabled with an official provider
+ useEffect(() => {
+ let unsubscribe: (() => void) | undefined;
+
+ const setup = async () => {
+ unsubscribe = await listen("proxy-official-warning", (event) => {
+ const { providerName } = event.payload as {
+ appType: string;
+ providerName: string;
+ };
+ toast.warning(
+ t("notifications.proxyOfficialWarning", {
+ name: providerName,
+ defaultValue: `当前供应商 ${providerName} 是官方供应商,建议切换到第三方供应商后再使用代理接管`,
+ }),
+ { duration: 8000 },
+ );
+ });
+ };
+
+ void setup();
+ return () => {
+ unsubscribe?.();
+ };
+ }, [t]);
+
+ useEffect(() => {
+ let active = true;
+ let unlistenResize: (() => void) | undefined;
+
+ const setupWindowStateSync = async () => {
+ try {
+ const currentWindow = getCurrentWindow();
+ const syncWindowMaximizedState = async () => {
+ const maximized = await currentWindow.isMaximized();
+ if (active) {
+ setIsWindowMaximized(maximized);
+ }
+ };
+
+ await syncWindowMaximizedState();
+ unlistenResize = await currentWindow.onResized(() => {
+ void syncWindowMaximizedState();
+ });
+ } catch (error) {
+ console.error("[App] Failed to sync window maximized state", error);
+ }
+ };
+
+ void setupWindowStateSync();
+ return () => {
+ active = false;
+ unlistenResize?.();
+ };
+ }, []);
+
+ useEffect(() => {
+ // settingsData 未加载时跳过,避免用 fallback false 覆盖 Rust 侧已设好的装饰状态
+ if (!settingsData) return;
+
+ const syncWindowDecorations = async () => {
+ try {
+ await getCurrentWindow().setDecorations(!useAppWindowControls);
+ } catch (error) {
+ console.error("[App] Failed to update window decorations", error);
+ }
+ };
+
+ void syncWindowDecorations();
+ }, [useAppWindowControls, settingsData]);
+
useEffect(() => {
const checkEnvOnStartup = async () => {
try {
@@ -734,6 +818,44 @@ function App() {
}
};
+ const notifyWindowControlError = (error: unknown) => {
+ toast.error(
+ t("notifications.windowControlFailed", {
+ defaultValue: "窗口控制失败:{{error}}",
+ error: extractErrorMessage(error),
+ }),
+ );
+ };
+
+ const handleWindowMinimize = async () => {
+ try {
+ await getCurrentWindow().minimize();
+ } catch (error) {
+ console.error("[App] Failed to minimize window", error);
+ notifyWindowControlError(error);
+ }
+ };
+
+ const handleWindowToggleMaximize = async () => {
+ try {
+ const currentWindow = getCurrentWindow();
+ await currentWindow.toggleMaximize();
+ setIsWindowMaximized(await currentWindow.isMaximized());
+ } catch (error) {
+ console.error("[App] Failed to toggle maximize", error);
+ notifyWindowControlError(error);
+ }
+ };
+
+ const handleWindowClose = async () => {
+ try {
+ await getCurrentWindow().close();
+ } catch (error) {
+ console.error("[App] Failed to close window", error);
+ notifyWindowControlError(error);
+ }
+ };
+
const renderContent = () => {
const content = (() => {
switch (currentView) {
@@ -880,14 +1002,57 @@ function App() {
return (