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 -SiliconFlow -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. +AIGoCode +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 -AIGoCode -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! +SiliconFlow +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! - -RightCode -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. - - AICoding 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! -Crazyrouter +Crazyrouter 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! + +RightCode +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. + + SSSAiCode 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 -XCodeAPI -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) +LemonData +Thanks to LemonData for sponsoring this project! LemonData is a high-performance AI API aggregation platform — one API key for 300+ models including GPT, Claude, Gemini, DeepSeek, and more. All models priced 30–70% below official rates with auto-failover, smart routing, and unlimited concurrency. New users get $1 free credit instantly upon registration — sign up via this linkto claim your bonus and start building right away! diff --git a/README_JA.md b/README_JA.md index 6d1e41603..3e10dd639 100644 --- a/README_JA.md +++ b/README_JA.md @@ -37,8 +37,8 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設 -SiliconFlow -SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。このリンクから登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥20 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。 +AIGoCode +本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、このリンクから登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます! @@ -47,8 +47,8 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設 -AIGoCode -本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、このリンクから登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます! +SiliconFlow +SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。このリンクから登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥20 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。 @@ -73,9 +73,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / -RightCode -本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデルに対応した中継(プロキシ)サービスを安定して提供しています。特に高いコストパフォーマンスを誇る Codex の月額プランを主力としており、未使用分の利用枠を翌日に繰り越して利用できる(繰越対応)点が特長です。チャージ(入金)後に請求書の発行が可能で、企業・チーム向けには専任担当による個別対応も行っています。さらに CC Switch ユーザー向けの特別優待として、こちらのリンクからご登録いただくと、チャージのたびに実支払額の 25% 相当の従量課金クレジットが付与されます。 - AICoding @@ -83,10 +80,15 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / -Crazyrouter +Crazyrouter Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典:こちらのリンクから登録すると $2 の無料クレジット を即時進呈。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると 30% のボーナスクレジット が追加されます! + +RightCode +本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデルに対応した中継(プロキシ)サービスを安定して出しています。特に高いコストパフォーマンスを誇る Codex の月刊プランを主力としており、未使用分の利用枠を翌日に繰り越して利用できる(繰越対応)점이特长です。チャージ(入金)後に請求書の発行が可能で、企業・チーム向けには専任担当による個別対応も行っています。さらに CC Switch ユーザー向けの特別優待として、こちらのリンクからご登録くと、チャージのたびに実支払額の 25% 相当の従量課金クレジットが付与されます。 + + SSSAiCode SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。高コストパフォーマンスの公式 Claude サービスを 0.5¥/$ 換算で提供、月額制・Paygo など多様な課金方式に対応し、当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:こちらのリンクから登録すると、毎回のチャージで $10 の追加ボーナスを受けられます! @@ -96,12 +98,12 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / Micu Micu API のご支援に感謝します!Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応!「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能!CC Switch ユーザー向けの限定特典:こちらのリンクから登録し、チャージ時にプロモコード「ccswitch」を入力すると 10% 割引 が適用されます! - -XCodeAPI -XCodeAPI のご支援に感謝します!CC Switch ユーザー向けの特別特典:こちらのリンクから登録すると、初回注文で 10% の追加クレジットボーナスがもらえます!(サイト管理者に連絡して受け取りください) +LemonData +LemonData のご支援に感謝します!LemonData は高性能 AI API アグリゲーションプラットフォームで、GPT、Claude、Gemini、DeepSeek など 300 以上のモデルに 1 つの API キーでアクセス可能。全モデルが公式価格の 30〜70% オフで自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。新規ユーザーは登録だけで即座に $1 の無料クレジットを獲得 — こちらのリンクから登録してボーナスを獲得し、すぐに開発を始めましょう! + CTok CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。こちらから登録してください! diff --git a/README_ZH.md b/README_ZH.md index b60d1b080..386d32d36 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -37,8 +37,8 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构 -SiliconFlow -感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过此链接注册并完成实名认证,即可获得 ¥20 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。 +AIGoCode +感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过此链接注册的用户首次充值可以获得额外10%奖励额度! @@ -47,8 +47,8 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构 -AIGoCode -感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过此链接注册的用户首次充值可以获得额外10%奖励额度! +SiliconFlow +感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过此链接注册并完成实名认证,即可获得 ¥20 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。 @@ -73,21 +73,21 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按量的高性价比 Coding Plan 套餐,基于官方2~5折优惠。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过此链接注册的用户,可得免费5元平台体验金! - -RightCode -感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务。主打极高性价比的Codex包月套餐,提供额度转结,套餐当天用不完的额度,第二天还能接着用!充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过此链接注册,每次充值均可获得实付金额25%的按量额度! - - AICoding 感谢 AICoding.sh 赞助了本项目!AICoding.sh —— 全球大模型 API 超值中转服务!Claude Code 1.9 折,GPT 0.1 折,已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型,企业级高并发、极速开票、7×24 专属技术支持,通过此链接 注册的 CC Switch 用户,首充可享受九折优惠! -Crazyrouter +Crazyrouter 感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI 等。全部模型低至官方定价的 55%,支持自动故障转移、智能路由和无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠:通过此链接注册即可获得 $2 免费额度,首次充值时输入优惠码 `CCSWITCH` 还可获得额外 30% 奖励额度! + +RightCode +感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务。主打极高性价比的Codex包月套餐,提供额度转结,套餐当天用不完的额度,第二天还能接着用!充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过此链接注册,每次充值均可获得实付金额25%的按量额度! + + SSSAiCode 感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,提供高性价比折合0.5¥/$的官方Claude服务,支持包月、Paygo多种计费方式、支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用此链接注册每次充值均可享受10$的额外奖励! @@ -97,12 +97,12 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 Micu 感谢 米醋API 赞助了本项目!米醋API 是一家致力于提供极致性价比与高稳定性的全球大模型中转服务商。米醋API 背后有实体企业做核心保障,杜绝跑路风险,支持极速正规开票!我们主打“试错零成本”:1 元起充低门槛,0 手续费随时退款!米醋API 为本软件的用户提供了特别优惠,使用此链接注册并在充值时填写"ccswitch"优惠码可享九折优惠! - -XCodeAPI -感谢 XCodeAPI 赞助了本项目!XCodeAPI 为本软件的用户提供特别福利,使用此链接注册后首单加赠10%的额度!(联系站长领取) +LemonData +感谢 LemonData 赞助了本项目!LemonData 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 GPT、Claude、Gemini、DeepSeek 等 300+ 模型。所有模型定价为官方价格的 30%-70%,支持自动故障转移、智能路由和无限并发。新用户注册即获 $1 免费额度——通过此链接注册即可领取奖励,立即开始开发! + CTok 感谢 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 (
- {DRAG_BAR_HEIGHT > 0 && ( + {(dragBarHeight > 0 || useAppWindowControls) && (
+ style={{ WebkitAppRegion: "drag", height: dragBarHeight } as any} + > + {useAppWindowControls && ( +
+ + + +
+ )} +
)} {showEnvBanner && envConflicts.length > 0 && ( void; + isOfficialBlockedByProxy?: boolean; // OpenClaw: default model isDefaultModel?: boolean; onSetAsDefault?: () => void; @@ -60,6 +62,7 @@ export function ProviderActions({ isAutoFailoverEnabled = false, isInFailoverQueue = false, onToggleFailover, + isOfficialBlockedByProxy = false, // OpenClaw: default model isDefaultModel = false, onSetAsDefault, @@ -166,6 +169,16 @@ export function ProviderActions({ }; } + if (isOfficialBlockedByProxy) { + return { + disabled: true, + variant: "secondary" as const, + className: "opacity-40 cursor-not-allowed", + icon: , + text: t("provider.blockedByProxy", { defaultValue: "已拦截" }), + }; + } + if (isCurrent) { return { disabled: true, diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index e85973e5b..c0a36bfd4 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -175,6 +175,8 @@ export function ProviderCard({ const usageEnabled = provider.meta?.usage_script?.enabled ?? false; const isOfficial = isOfficialProvider(provider, appId); + const isOfficialBlockedByProxy = + isProxyTakeover && (provider.category === "official" || isOfficial); const isCopilot = provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT || provider.meta?.usage_script?.templateType === "github_copilot"; @@ -424,6 +426,7 @@ export function ProviderCard({ isInConfig={isInConfig} isTesting={isTesting} isProxyTakeover={isProxyTakeover} + isOfficialBlockedByProxy={isOfficialBlockedByProxy} isOmo={isAnyOmo} onSwitch={() => onSwitch(provider)} onEdit={() => onEdit(provider)} diff --git a/src/components/providers/forms/ProviderAdvancedConfig.tsx b/src/components/providers/forms/ProviderAdvancedConfig.tsx index 6023bd5f8..0a7c54d7b 100644 --- a/src/components/providers/forms/ProviderAdvancedConfig.tsx +++ b/src/components/providers/forms/ProviderAdvancedConfig.tsx @@ -1,19 +1,9 @@ import { useTranslation } from "react-i18next"; import { useState, useEffect } from "react"; -import { - ChevronDown, - ChevronRight, - FlaskConical, - Globe, - Coins, - Eye, - EyeOff, - X, -} from "lucide-react"; +import { ChevronDown, ChevronRight, FlaskConical, Coins } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; -import { Button } from "@/components/ui/button"; import { Select, SelectContent, @@ -22,7 +12,7 @@ import { SelectValue, } from "@/components/ui/select"; import { cn } from "@/lib/utils"; -import type { ProviderTestConfig, ProviderProxyConfig } from "@/types"; +import type { ProviderTestConfig } from "@/types"; export type PricingModelSourceOption = "inherit" | "request" | "response"; @@ -34,136 +24,31 @@ interface ProviderPricingConfig { interface ProviderAdvancedConfigProps { testConfig: ProviderTestConfig; - proxyConfig: ProviderProxyConfig; pricingConfig: ProviderPricingConfig; onTestConfigChange: (config: ProviderTestConfig) => void; - onProxyConfigChange: (config: ProviderProxyConfig) => void; onPricingConfigChange: (config: ProviderPricingConfig) => void; } -/** 从 ProviderProxyConfig 构建完整 URL */ -function buildProxyUrl(config: ProviderProxyConfig): string { - if (!config.proxyHost) return ""; - - const protocol = config.proxyType || "http"; - const host = config.proxyHost; - const port = config.proxyPort || (protocol === "socks5" ? 1080 : 7890); - - return `${protocol}://${host}:${port}`; -} - -/** 从完整 URL 解析为 ProviderProxyConfig */ -function parseProxyUrl(url: string): Partial { - if (!url.trim()) { - return { proxyHost: undefined, proxyPort: undefined, proxyType: undefined }; - } - - try { - const parsed = new URL(url); - const protocol = parsed.protocol.replace(":", "") as - | "http" - | "https" - | "socks5"; - const host = parsed.hostname; - const port = parsed.port ? parseInt(parsed.port, 10) : undefined; - - return { - proxyType: protocol, - proxyHost: host || undefined, - proxyPort: port, - }; - } catch { - // 尝试简单解析(不是标准 URL 格式) - const match = url.match(/^(?:(\w+):\/\/)?([^:]+)(?::(\d+))?$/); - if (match) { - return { - proxyType: (match[1] as "http" | "https" | "socks5") || "http", - proxyHost: match[2] || undefined, - proxyPort: match[3] ? parseInt(match[3], 10) : undefined, - }; - } - return {}; - } -} - export function ProviderAdvancedConfig({ testConfig, - proxyConfig, pricingConfig, onTestConfigChange, - onProxyConfigChange, onPricingConfigChange, }: ProviderAdvancedConfigProps) { const { t } = useTranslation(); const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled); - const [isProxyConfigOpen, setIsProxyConfigOpen] = useState( - proxyConfig.enabled, - ); const [isPricingConfigOpen, setIsPricingConfigOpen] = useState( pricingConfig.enabled, ); - const [showPassword, setShowPassword] = useState(false); - - // 代理 URL 输入状态(仅在初始化时从 proxyConfig 构建) - const [proxyUrl, setProxyUrl] = useState(() => buildProxyUrl(proxyConfig)); - - // 标记是否为用户主动输入(用于区分外部更新和用户输入) - const [isUserTyping, setIsUserTyping] = useState(false); useEffect(() => { setIsTestConfigOpen(testConfig.enabled); }, [testConfig.enabled]); - // 同步外部 proxyConfig.enabled 变化到展开状态 - useEffect(() => { - setIsProxyConfigOpen(proxyConfig.enabled); - }, [proxyConfig.enabled]); - - // 同步外部 pricingConfig.enabled 变化到展开状态 useEffect(() => { setIsPricingConfigOpen(pricingConfig.enabled); }, [pricingConfig.enabled]); - // 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据) - useEffect(() => { - if (!isUserTyping) { - const newUrl = buildProxyUrl(proxyConfig); - if (newUrl !== proxyUrl) { - setProxyUrl(newUrl); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [proxyConfig.proxyType, proxyConfig.proxyHost, proxyConfig.proxyPort]); - - // 处理代理 URL 变化(用户输入时不触发 URL 重建) - const handleProxyUrlChange = (value: string) => { - setIsUserTyping(true); - setProxyUrl(value); - const parsed = parseProxyUrl(value); - onProxyConfigChange({ - ...proxyConfig, - ...parsed, - }); - }; - - // 输入框失焦时结束用户输入状态 - const handleProxyUrlBlur = () => { - setIsUserTyping(false); - }; - - // 清除代理配置 - const handleClearProxy = () => { - setProxyUrl(""); - onProxyConfigChange({ - ...proxyConfig, - proxyType: undefined, - proxyHost: undefined, - proxyPort: undefined, - proxyUsername: undefined, - proxyPassword: undefined, - }); - }; - return (
@@ -342,141 +227,6 @@ export function ProviderAdvancedConfig({
- {/* 代理配置 */} -
- -
-
-

- {t("providerAdvanced.proxyConfigDesc", { - defaultValue: - "为此供应商配置单独的网络代理,不启用时使用系统代理或全局设置。", - })} -

- - {/* 代理地址输入框(仿照全局代理样式) */} -
- handleProxyUrlChange(e.target.value)} - onBlur={handleProxyUrlBlur} - className="font-mono text-sm flex-1" - disabled={!proxyConfig.enabled} - /> - -
- - {/* 认证信息:用户名 + 密码(可选) */} -
- - onProxyConfigChange({ - ...proxyConfig, - proxyUsername: e.target.value || undefined, - }) - } - className="font-mono text-sm flex-1" - disabled={!proxyConfig.enabled} - /> -
- - onProxyConfigChange({ - ...proxyConfig, - proxyPassword: e.target.value || undefined, - }) - } - className="font-mono text-sm pr-10" - disabled={!proxyConfig.enabled} - /> - -
-
-
-
-
- {/* 计费配置 */}
+ )}
); -} +}); diff --git a/src/components/settings/WindowSettings.tsx b/src/components/settings/WindowSettings.tsx index a2597aed9..43c1a0ec5 100644 --- a/src/components/settings/WindowSettings.tsx +++ b/src/components/settings/WindowSettings.tsx @@ -3,6 +3,7 @@ import type { SettingsFormState } from "@/hooks/useSettings"; import { AppWindow, MonitorUp, Power, EyeOff } from "lucide-react"; import { ToggleRow } from "@/components/ui/toggle-row"; import { AnimatePresence, motion } from "framer-motion"; +import { isLinux } from "@/lib/platform"; interface WindowSettingsProps { settings: SettingsFormState; @@ -75,6 +76,18 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) { onChange({ minimizeToTrayOnClose: value }) } /> + + {isLinux() && ( + } + title={t("settings.useAppWindowControls")} + description={t("settings.useAppWindowControlsDescription")} + checked={!!settings.useAppWindowControls} + onCheckedChange={(value) => + onChange({ useAppWindowControls: value }) + } + /> + )}
); diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index fe485ec20..0b4cd6c51 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Table, @@ -31,24 +31,33 @@ import { interface RequestLogTableProps { appType?: string; refreshIntervalMs: number; + timeRange?: "1d" | "7d" | "30d"; } const ONE_DAY_SECONDS = 24 * 60 * 60; const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS; +const TIME_RANGE_SECONDS: Record = { + "1d": ONE_DAY_SECONDS, + "7d": 7 * ONE_DAY_SECONDS, + "30d": 30 * ONE_DAY_SECONDS, +}; + type TimeMode = "rolling" | "fixed"; export function RequestLogTable({ appType: dashboardAppType, refreshIntervalMs, + timeRange = "1d", }: RequestLogTableProps) { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); + const rollingWindowSeconds = TIME_RANGE_SECONDS[timeRange] ?? ONE_DAY_SECONDS; + const getRollingRange = () => { const now = Math.floor(Date.now() / 1000); - const oneDayAgo = now - ONE_DAY_SECONDS; - return { startDate: oneDayAgo, endDate: now }; + return { startDate: now - rollingWindowSeconds, endDate: now }; }; const [appliedTimeMode, setAppliedTimeMode] = useState("rolling"); @@ -57,9 +66,15 @@ export function RequestLogTable({ const [appliedFilters, setAppliedFilters] = useState({}); const [draftFilters, setDraftFilters] = useState({}); const [page, setPage] = useState(0); + const [pageInput, setPageInput] = useState(""); const pageSize = 20; const [validationError, setValidationError] = useState(null); + // Reset page when the dashboard time range changes + useEffect(() => { + setPage(0); + }, [timeRange]); + // When dashboard-level app filter is active (not "all"), override the local appType filter const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all"; const effectiveFilters: LogFilters = dashboardAppTypeActive @@ -69,7 +84,7 @@ export function RequestLogTable({ const { data: result, isLoading } = useRequestLogs({ filters: effectiveFilters, timeMode: appliedTimeMode, - rollingWindowSeconds: ONE_DAY_SECONDS, + rollingWindowSeconds, page, pageSize, options: { @@ -131,6 +146,15 @@ export function RequestLogTable({ setPage(0); }; + const handleGoToPage = () => { + const trimmed = pageInput.trim(); + if (!/^\d+$/.test(trimmed)) return; + const parsed = Number(trimmed); + if (parsed < 1 || parsed > totalPages) return; + setPage(parsed - 1); + setPageInput(""); + }; + const handleRefresh = () => { const key = { timeMode: appliedTimeMode, @@ -561,30 +585,33 @@ export function RequestLogTable({ > - {/* 页码按钮 */} {(() => { const pages: (number | string)[] = []; - if (totalPages <= 7) { + // 3 head + 3 tail + 3 neighborhood = 9 max distinct pages + if (totalPages <= 9) { for (let i = 0; i < totalPages; i++) pages.push(i); } else { - pages.push(0); - if (page > 2) pages.push("..."); + const pageSet = new Set(); + for (let i = 0; i < 3; i++) pageSet.add(i); + for (let i = totalPages - 3; i < totalPages; i++) + pageSet.add(i); for ( - let i = Math.max(1, page - 1); - i <= Math.min(totalPages - 2, page + 1); + let i = Math.max(0, page - 1); + i <= Math.min(totalPages - 1, page + 1); i++ - ) { - pages.push(i); + ) + pageSet.add(i); + const sorted = Array.from(pageSet).sort((a, b) => a - b); + for (let i = 0; i < sorted.length; i++) { + if (i > 0 && sorted[i] - sorted[i - 1] > 1) { + pages.push(`ellipsis-${i}`); + } + pages.push(sorted[i]); } - if (page < totalPages - 3) pages.push("..."); - pages.push(totalPages - 1); } - return pages.map((p, idx) => + return pages.map((p) => typeof p === "string" ? ( - + ... ) : ( @@ -608,6 +635,21 @@ export function RequestLogTable({ > +
+ setPageInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleGoToPage(); + }} + placeholder={t("usage.pageInputPlaceholder")} + className="h-8 w-16 text-center text-xs" + /> + +
)} diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index d0ad19a7e..7690279e3 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -7,7 +7,6 @@ import { RequestLogTable } from "./RequestLogTable"; import { ProviderStatsTable } from "./ProviderStatsTable"; import { ModelStatsTable } from "./ModelStatsTable"; import type { AppTypeFilter, TimeRange } from "@/types/usage"; -import { useUsageSummary } from "@/lib/query/usage"; import { motion } from "framer-motion"; import { BarChart3, @@ -27,7 +26,6 @@ import { } from "@/components/ui/accordion"; import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel"; import { cn } from "@/lib/utils"; -import { fmtUsd, parseFiniteNumber } from "./format"; const APP_FILTER_OPTIONS: AppTypeFilter[] = [ "all", @@ -57,11 +55,6 @@ export function UsageDashboard() { const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30; - // Summary data for the app filter bar - const { data: summaryData } = useUsageSummary(days, appType, { - refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, - }); - return ( ))} -
- - {(summaryData?.totalRequests ?? 0).toLocaleString()}{" "} - {t("usage.requestsLabel")} - - | - - {fmtUsd(parseFiniteNumber(summaryData?.totalCost) ?? 0, 4)}{" "} - {t("usage.costLabel")} - -
diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 7fb74bd68..26c19a00e 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -662,23 +662,6 @@ export const providerPresets: ProviderPreset[] = [ icon: "micu", iconColor: "#000000", }, - { - name: "X-Code API", - websiteUrl: "https://x-code.cc", - apiKeyUrl: "https://x-code.cc", - settingsConfig: { - env: { - ANTHROPIC_BASE_URL: "https://x-code.cc", - ANTHROPIC_AUTH_TOKEN: "", - }, - }, - endpointCandidates: ["https://x-code.cc"], - category: "third_party", - isPartner: true, // 合作伙伴 - partnerPromotionKey: "x-code", // 促销信息 i18n key - icon: "xcode", - iconColor: "#000000", - }, { name: "CTok.ai", websiteUrl: "https://ctok.ai", @@ -863,7 +846,7 @@ export const providerPresets: ProviderPreset[] = [ }, { name: "PIPELLM", - websiteUrl: "https://www.pipellm.ai", + websiteUrl: "https://code.pipellm.ai", apiKeyUrl: "https://code.pipellm.ai/login?ref=uvw650za", settingsConfig: { env: { diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 02379839f..0e8caa046 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -333,23 +333,6 @@ requires_openai_auth = true`, icon: "micu", iconColor: "#000000", }, - { - name: "X-Code API", - websiteUrl: "https://x-code.cc", - apiKeyUrl: "https://x-code.cc", - auth: generateThirdPartyAuth(""), - config: generateThirdPartyConfig( - "x-code", - "https://x-code.cc/v1", - "gpt-5.4", - ), - endpointCandidates: ["https://x-code.cc/v1"], - category: "third_party", - isPartner: true, // 合作伙伴 - partnerPromotionKey: "x-code", // 促销信息 i18n key - icon: "xcode", - iconColor: "#000000", - }, { name: "CTok.ai", websiteUrl: "https://ctok.ai", @@ -408,7 +391,7 @@ model_auto_compact_token_limit = 9000000`, }, { name: "PIPELLM", - websiteUrl: "https://www.pipellm.ai", + websiteUrl: "https://code.pipellm.ai", apiKeyUrl: "https://code.pipellm.ai/login?ref=uvw650za", auth: { OPENAI_API_KEY: "", diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index b62c08118..d75822197 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -1009,7 +1009,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, { name: "PIPELLM", - websiteUrl: "https://www.pipellm.ai", + websiteUrl: "https://code.pipellm.ai", apiKeyUrl: "https://code.pipellm.ai/login?ref=uvw650za", settingsConfig: { baseUrl: "https://cc-api.pipellm.ai", diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 5c44ef295..f52080f6c 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -996,7 +996,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, { name: "PIPELLM", - websiteUrl: "https://www.pipellm.ai", + websiteUrl: "https://code.pipellm.ai", apiKeyUrl: "https://code.pipellm.ai/login?ref=uvw650za", settingsConfig: { npm: "@ai-sdk/anthropic", @@ -1291,36 +1291,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, }, - { - name: "X-Code API", - websiteUrl: "https://x-code.cc", - apiKeyUrl: "https://x-code.cc", - settingsConfig: { - npm: "@ai-sdk/anthropic", - name: "X-Code API", - options: { - baseURL: "https://x-code.cc/v1", - apiKey: "", - setCacheKey: true, - }, - models: { - "claude-opus-4-6": { name: "Claude Opus 4.6" }, - "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, - }, - }, - category: "third_party", - isPartner: true, - partnerPromotionKey: "x-code", - icon: "xcode", - iconColor: "#000000", - templateValues: { - apiKey: { - label: "API Key", - placeholder: "", - editorValue: "", - }, - }, - }, { name: "CTok.ai", websiteUrl: "https://ctok.ai", diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 85d125c34..64b2edf1c 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -23,7 +23,11 @@ import { openclawKeys } from "@/hooks/useOpenClaw"; * Hook for managing provider actions (add, update, delete, switch) * Extracts business logic from App.tsx */ -export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { +export function useProviderActions( + activeApp: AppId, + isProxyRunning?: boolean, + isProxyTakeover?: boolean, +) { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -185,6 +189,18 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { ); } + // Block official providers when proxy takeover is active + if (isProxyTakeover && provider.category === "official") { + toast.error( + t("notifications.officialBlockedByProxy", { + defaultValue: + "代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁", + }), + { duration: 6000 }, + ); + return; + } + try { const result = await switchProviderMutation.mutateAsync(provider.id); await syncClaudePlugin(provider); @@ -220,7 +236,14 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { // 错误提示由 mutation 处理 } }, - [switchProviderMutation, syncClaudePlugin, activeApp, isProxyRunning, t], + [ + switchProviderMutation, + syncClaudePlugin, + activeApp, + isProxyRunning, + isProxyTakeover, + t, + ], ); // 删除供应商 diff --git a/src/hooks/useSettingsForm.ts b/src/hooks/useSettingsForm.ts index 72530920b..0944b91d1 100644 --- a/src/hooks/useSettingsForm.ts +++ b/src/hooks/useSettingsForm.ts @@ -81,6 +81,7 @@ export function useSettingsForm(): UseSettingsFormResult { ...data, showInTray: data.showInTray ?? true, minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true, + useAppWindowControls: data.useAppWindowControls ?? false, enableClaudePluginIntegration: data.enableClaudePluginIntegration ?? false, silentStartup: data.silentStartup ?? false, @@ -105,6 +106,7 @@ export function useSettingsForm(): UseSettingsFormResult { ({ showInTray: true, minimizeToTrayOnClose: true, + useAppWindowControls: false, enableClaudePluginIntegration: false, skipClaudeOnboarding: false, language: readPersistedLanguage(), @@ -139,6 +141,7 @@ export function useSettingsForm(): UseSettingsFormResult { ...serverData, showInTray: serverData.showInTray ?? true, minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true, + useAppWindowControls: serverData.useAppWindowControls ?? false, enableClaudePluginIntegration: serverData.enableClaudePluginIntegration ?? false, silentStartup: serverData.silentStartup ?? false, diff --git a/src/hooks/useStreamCheck.ts b/src/hooks/useStreamCheck.ts index 958ac7b41..c30667d76 100644 --- a/src/hooks/useStreamCheck.ts +++ b/src/hooks/useStreamCheck.ts @@ -47,13 +47,37 @@ export function useStreamCheck(appId: AppId) { // 降级状态也重置熔断器,因为至少能通信 resetCircuitBreaker.mutate({ providerId, appType: appId }); } else { - toast.error( - t("streamCheck.failed", { - providerName: providerName, - message: result.message, - defaultValue: `${providerName} 检查失败: ${result.message}`, - }), - ); + const httpStatus = result.httpStatus; + const hintKey = httpStatus + ? `streamCheck.httpHint.${httpStatus >= 500 ? "5xx" : httpStatus}` + : null; + const description = + (hintKey ? t(hintKey, { defaultValue: "" }) : "") || undefined; + + // 401/403/400 = 检查被拒(供应商可能正常);429/5xx = 临时问题 + const isProbeRejection = + httpStatus != null && + ([401, 403, 400, 429].includes(httpStatus) || httpStatus >= 500); + + if (isProbeRejection) { + toast.warning( + t("streamCheck.rejected", { + providerName: providerName, + message: result.message, + defaultValue: `${providerName} 检查被拒: ${result.message}`, + }), + { description, duration: 8000, closeButton: true }, + ); + } else { + toast.error( + t("streamCheck.failed", { + providerName: providerName, + message: result.message, + defaultValue: `${providerName} 检查失败: ${result.message}`, + }), + { description, duration: 8000, closeButton: true }, + ); + } } return result; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index fba579456..dada3987c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -91,7 +91,11 @@ "switchToChinese": "Switch to Chinese", "switchToEnglish": "Switch to English", "enterEditMode": "Enter Edit Mode", - "exitEditMode": "Exit Edit Mode" + "exitEditMode": "Exit Edit Mode", + "windowMinimize": "Minimize window", + "windowMaximize": "Maximize window", + "windowRestore": "Restore window", + "windowClose": "Close window" }, "provider": { "tabProvider": "Provider", @@ -104,6 +108,7 @@ "currentlyUsing": "Currently Using", "enable": "Enable", "inUse": "In Use", + "blockedByProxy": "Blocked", "editProvider": "Edit Provider", "editProviderHint": "Configuration will be applied to the current provider immediately after update.", "deleteProvider": "Delete Provider", @@ -189,19 +194,22 @@ "deleteFailed": "Failed to delete provider: {{error}}", "settingsSaved": "Settings saved", "settingsSaveFailed": "Failed to save settings: {{error}}", - "proxyRequiredForSwitch": "This provider {{reason}}, requires the proxy service to work properly. Start the proxy first.", + "proxyRequiredForSwitch": "This provider {{reason}}, requires the routing service to work properly. Start routing first.", "proxyReasonCopilot": "uses GitHub Copilot as a Claude provider", "proxyReasonOpenAIChat": "uses OpenAI Chat API format", "proxyReasonOpenAIResponses": "uses OpenAI Responses API format", "proxyReasonFullUrl": "has full URL connection mode enabled", - "openAIFormatHint": "This provider uses OpenAI-compatible format and requires the proxy service to be enabled", - "copilotProxyHint": "GitHub Copilot as a Claude provider always requires the local proxy; the proxy automatically selects Chat Completions or Responses based on the current model.", + "openAIFormatHint": "This provider uses OpenAI-compatible format and requires the routing service to be enabled", + "copilotProxyHint": "GitHub Copilot as a Claude provider always requires local routing; routing automatically selects Chat Completions or Responses based on the current model.", "openLinkFailed": "Failed to open link", "openclawModelsRegistered": "Models have been registered to /model list", "openclawDefaultModelSet": "Set as default model", "openclawDefaultModelSetFailed": "Failed to set default model", "openclawNoModels": "No models configured", - "backfillWarning": "Switched successfully, but failed to save changes back to the previous provider" + "backfillWarning": "Switched successfully, but failed to save changes back to the previous provider", + "windowControlFailed": "Window control failed: {{error}}", + "officialBlockedByProxy": "Cannot switch to official provider while local routing is active. Using routing with official APIs may cause account bans.", + "proxyOfficialWarning": "Current provider {{name}} is official. Consider switching to a third-party provider before using local routing." }, "confirm": { "deleteProvider": "Delete Provider", @@ -209,8 +217,8 @@ "removeProvider": "Remove Provider", "removeProviderMessage": "Are you sure you want to remove provider \"{{name}}\" from the configuration?\n\nAfter removal, this provider will no longer be active, but the configuration data will be retained in CC Switch. You can re-add it at any time.", "proxy": { - "title": "Enable Local Proxy", - "message": "Local proxy is an advanced feature. Please make sure you understand how it works before enabling.\n\nWe recommend consulting the relevant documentation or your provider for proper configuration.", + "title": "Enable Local Routing", + "message": "Local routing is an advanced feature. Please make sure you understand how it works before enabling.\n\nWe recommend consulting the relevant documentation or your provider for proper configuration.", "confirm": "I understand, enable" }, "failover": { @@ -245,7 +253,7 @@ "tabGeneral": "General", "tabAuth": "Auth", "tabAdvanced": "Advanced", - "tabProxy": "Proxy", + "tabProxy": "Routing", "authCenter": { "title": "OAuth Authentication Center", "description": "Use your other subscriptions in Claude Code — please be mindful of compliance risks.", @@ -259,10 +267,10 @@ "description": "Manage storage paths for Claude, Codex and Gemini configurations" }, "proxy": { - "title": "Local Proxy", - "description": "Control proxy service toggle, view status and port info", - "enableFeature": "Show Proxy Toggle on Main Page", - "enableFeatureDescription": "When enabled, the proxy and failover toggles will appear at the top of the main page", + "title": "Local Routing", + "description": "Control routing service toggle, view status and port info", + "enableFeature": "Show Routing Toggle on Main Page", + "enableFeatureDescription": "When enabled, the routing and failover toggles will appear at the top of the main page", "enableFailoverToggle": "Show Failover Toggle on Main Page", "enableFailoverToggleDescription": "When enabled, the failover toggle will appear independently at the top of the main page", "running": "Running", @@ -494,6 +502,8 @@ "autoLaunchFailed": "Failed to set auto-launch", "minimizeToTray": "Minimize to tray on close", "minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.", + "useAppWindowControls": "Enable app-level window controls", + "useAppWindowControlsDescription": "Use built-in minimize, maximize/restore, and close buttons in the app header.", "enableClaudePluginIntegration": "Apply to Claude Code extension", "enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app", "skipClaudeOnboarding": "Skip Claude Code first-run confirmation", @@ -612,7 +622,7 @@ "saving": "Saving...", "globalProxy": { "label": "Global Proxy", - "hint": "Proxy all requests (API, Skills download, etc.). Leave empty for direct connection.", + "hint": "Proxy all requests (API, Skills download, etc.). When local routing is enabled, app traffic is also routed through this proxy. Leave empty for direct connection.", "username": "Username (optional)", "password": "Password (optional)", "test": "Test Connection", @@ -715,7 +725,9 @@ "copyCommand": "Copy Command", "copyMessage": "Copy Message", "messageCopied": "Message copied", - "conversationHistory": "Conversation History" + "conversationHistory": "Conversation History", + "expandContent": "Expand full content", + "collapseContent": "Collapse" }, "console": { "providerSwitchReceived": "Received provider switch event:", @@ -773,7 +785,6 @@ "siliconflow": "SiliconFlow is an official partner of CC Switch", "ucloud": "Compshare offers an exclusive bonus for CC Switch users — register via this link to get ¥5 platform trial credit!", "micu": "Micu is an official partner of CC Switch", - "x-code": "XCodeAPI offers a special bonus for CC Switch users — register via this link and get 10% extra credit on your first order (contact admin to claim)", "ctok": "Join the CTok community on the official website and subscribe to a plan.", "ddshub": "DDSHub offers a special bonus for CC Switch users — register via this link and get 10% extra credit on your first top-up (contact group admin to claim)!", "lionccapi": "LionCCAPI offers exclusive benefits for CC Switch users. After registration, add WeChat HSQBJ088888888 and mention 'cc-switch' to receive $10 free credits!", @@ -812,12 +823,12 @@ "fullUrlLabel": "Full URL", "fullUrlEnabled": "Full URL Mode", "fullUrlDisabled": "Mark as Full URL", - "fullUrlHint": "💡 Enter the full request URL. This mode requires the proxy to be enabled, and the proxy will use the URL as-is without appending a path", + "fullUrlHint": "💡 Enter the full request URL. This mode requires routing to be enabled, and routing will use the URL as-is without appending a path", "fullUrlHintGeminiNative": "💡 In Gemini Native full URL mode, two inputs are supported: 1. official/structured Gemini URLs, which will still be normalized to the requested model and streaming method; 2. opaque custom relay URLs, which will be used mostly as-is with only query parameters appended", "apiFormatAnthropic": "Anthropic Messages (Native)", - "apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)", - "apiFormatOpenAIResponses": "OpenAI Responses API (Requires proxy)", - "apiFormatGeminiNative": "Gemini Native generateContent (Requires proxy)", + "apiFormatOpenAIChat": "OpenAI Chat Completions (Requires routing)", + "apiFormatOpenAIResponses": "OpenAI Responses API (Requires routing)", + "apiFormatGeminiNative": "Gemini Native generateContent (Requires routing)", "authField": "Auth Field", "authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN (Default)", "authFieldApiKey": "ANTHROPIC_API_KEY", @@ -937,11 +948,6 @@ "testPrompt": "Test Prompt", "degradedThreshold": "Degraded Threshold (ms)", "maxRetries": "Max Retries", - "proxyConfig": "Proxy Config", - "useCustomProxy": "Use separate proxy", - "proxyConfigDesc": "Configure separate network proxy for this provider. Uses system proxy or global settings when disabled.", - "proxyUsername": "Username (optional)", - "proxyPassword": "Password (optional)", "pricingConfig": "Pricing Config", "useCustomPricing": "Use separate config", "pricingConfigDesc": "Configure separate pricing parameters for this provider. Uses global defaults when disabled.", @@ -1085,7 +1091,7 @@ }, "dataSources": "Data Sources", "dataSource": { - "proxy": "Proxy", + "proxy": "Routing", "session_log": "Session Log", "codex_db": "Codex DB", "codex_session": "Codex Session", @@ -1100,6 +1106,8 @@ "failed": "Session sync failed" }, "totalRecords": "{{total}} records total", + "goToPage": "Go", + "pageInputPlaceholder": "Page", "modelPricing": "Model Pricing", "loadPricingError": "Failed to load pricing data", "modelPricingDesc": "Configure token costs for each model", @@ -1935,9 +1943,9 @@ "addressCopied": "Address copied", "currentProvider": "Current Provider:", "waitingFirstRequest": "Current Provider: Waiting for first request...", - "stoppedTitle": "Proxy Service Stopped", + "stoppedTitle": "Routing Service Stopped", "stoppedDescription": "Use the toggle above to start the service", - "openSettings": "Configure Proxy Service", + "openSettings": "Configure Routing Service", "stats": { "activeConnections": "Active Connections", "totalRequests": "Total Requests", @@ -1946,14 +1954,14 @@ } }, "settings": { - "title": "Proxy Service Settings", - "description": "Configure local proxy server listening address, port and runtime parameters. Changes take effect immediately after saving.", + "title": "Routing Service Settings", + "description": "Configure local routing server listening address, port and runtime parameters. Changes take effect immediately after saving.", "alert": { - "autoApply": "Changes will be automatically synced to the running proxy service without manual restart." + "autoApply": "Changes will be automatically synced to the running routing service without manual restart." }, "basic": { "title": "Basic Settings", - "description": "Configure proxy service listening address and port." + "description": "Configure routing service listening address and port." }, "advanced": { "title": "Advanced Parameters", @@ -1967,12 +1975,12 @@ "listenAddress": { "label": "Listen Address", "placeholder": "127.0.0.1", - "description": "IP address the proxy server listens on (recommended: 127.0.0.1)" + "description": "IP address the routing server listens on (recommended: 127.0.0.1)" }, "listenPort": { "label": "Listen Port", "placeholder": "15721", - "description": "Port number the proxy server listens on (1024 ~ 65535)" + "description": "Port number the routing server listens on (1024 ~ 65535)" }, "maxRetries": { "label": "Max Retries", @@ -1986,7 +1994,7 @@ }, "enableLogging": { "label": "Enable Logging", - "description": "Log all proxy requests for troubleshooting" + "description": "Log all routing requests for troubleshooting" }, "streamingFirstByteTimeout": { "label": "Streaming First Byte Timeout (sec)", @@ -2017,29 +2025,29 @@ "save": "Save Configuration" }, "toast": { - "saved": "Proxy configuration saved", + "saved": "Routing configuration saved", "saveFailed": "Save failed: {{error}}" }, "invalidPort": "Invalid port, please enter a number between 1024-65535", "invalidAddress": "Invalid address, please enter a valid IP address (e.g. 127.0.0.1) or localhost", - "configSaved": "Proxy configuration saved", + "configSaved": "Routing configuration saved", "configSaveFailed": "Failed to save configuration", - "restartRequired": "Restart proxy service for address or port changes to take effect" + "restartRequired": "Restart routing service for address or port changes to take effect" }, "switchFailed": "Switch failed: {{error}}", "takeover": { - "hint": "Select apps to take over — once enabled, requests from that app will be routed through the local proxy", - "enabled": "{{app}} takeover enabled", - "disabled": "{{app}} takeover disabled", - "failed": "Failed to toggle takeover", + "hint": "Select apps to route — once enabled, requests from that app will go through local routing", + "enabled": "{{app}} routing enabled", + "disabled": "{{app}} routing disabled", + "failed": "Failed to toggle routing", "tooltip": { - "active": "{{appLabel}} is intercepting - {{address}}:{{port}}\nSwitch provider for hot switching", - "broken": "{{appLabel}} is intercepting, but proxy service is not running", - "inactive": "Intercept {{appLabel}}'s live config to route requests through local proxy" + "active": "{{appLabel}} is routing - {{address}}:{{port}}\nSwitch provider for hot switching", + "broken": "{{appLabel}} is routing, but routing service is not running", + "inactive": "Route {{appLabel}}'s requests through local routing" } }, "failover": { - "proxyRequired": "Proxy service must be started to configure failover", + "proxyRequired": "Routing service must be started to configure failover", "autoSwitch": "Auto Failover", "autoSwitchDescription": "When enabled, switches to queue P1 immediately and automatically tries the next provider in the queue on failures" }, @@ -2103,10 +2111,10 @@ "failed": "Failed to toggle logging" }, "server": { - "started": "Proxy service started - {{address}}:{{port}}", - "startFailed": "Failed to start proxy service: {{detail}}" + "started": "Routing service started - {{address}}:{{port}}", + "startFailed": "Failed to start routing service: {{detail}}" }, - "stoppedWithRestore": "Proxy service stopped, all takeover configs restored", + "stoppedWithRestore": "Routing service stopped, all routing configs restored", "stopWithRestoreFailed": "Stop failed: {{detail}}" }, "streamCheck": { @@ -2124,11 +2132,21 @@ "operational": "{{providerName}} is operational ({{responseTimeMs}}ms)", "degraded": "{{providerName}} is slow ({{responseTimeMs}}ms)", "failed": "{{providerName}} check failed: {{message}}", - "error": "{{providerName}} check error: {{error}}" + "rejected": "{{providerName}} check rejected: {{message}}", + "error": "{{providerName}} check error: {{error}}", + "httpHint": { + "400": "Provider rejected request format. Health check probe may differ from actual usage.", + "401": "API key may be invalid, or provider uses OAuth auth. Check failure doesn't mean it's unusable.", + "402": "Account quota or billing issue.", + "403": "Provider blocked this request. Some verify client identity; may work fine in actual use.", + "404": "Endpoint not found. Check base URL and API path.", + "429": "Too many requests. Try again later.", + "5xx": "Provider internal error. Likely temporary." + } }, "proxyConfig": { - "proxyEnabled": "Proxy Enabled", - "appTakeover": "Proxy Enabled", + "proxyEnabled": "Routing Master Switch", + "appTakeover": "Routing Enabled", "perAppConfig": "Per-App Config", "circuitBreaker": "Circuit Breaker", "circuitBreakerSettings": "Circuit Breaker Settings", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index edc9d9c86..e0793b98c 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -91,7 +91,11 @@ "switchToChinese": "中国語に切り替え", "switchToEnglish": "英語に切り替え", "enterEditMode": "編集モードに入る", - "exitEditMode": "編集モードを終了" + "exitEditMode": "編集モードを終了", + "windowMinimize": "ウィンドウを最小化", + "windowMaximize": "ウィンドウを最大化", + "windowRestore": "ウィンドウを元に戻す", + "windowClose": "ウィンドウを閉じる" }, "provider": { "tabProvider": "プロバイダー", @@ -104,6 +108,7 @@ "currentlyUsing": "現在使用中", "enable": "有効化", "inUse": "使用中", + "blockedByProxy": "ブロック", "editProvider": "プロバイダーを編集", "editProviderHint": "保存すると現在のプロバイダーにすぐ反映されます。", "deleteProvider": "プロバイダーを削除", @@ -189,19 +194,22 @@ "deleteFailed": "プロバイダーの削除に失敗しました: {{error}}", "settingsSaved": "設定を保存しました", "settingsSaveFailed": "設定の保存に失敗しました: {{error}}", - "proxyRequiredForSwitch": "このプロバイダーは{{reason}}、プロキシサービスが必要です。先にプロキシを起動してください", + "proxyRequiredForSwitch": "このプロバイダーは{{reason}}、ルーティングサービスが必要です。先にルーティングを起動してください", "proxyReasonCopilot": "GitHub Copilot を Claude プロバイダーとして使用しており", "proxyReasonOpenAIChat": "OpenAI Chat API フォーマットを使用しており", "proxyReasonOpenAIResponses": "OpenAI Responses API フォーマットを使用しており", "proxyReasonFullUrl": "完全 URL 接続モードが有効になっており", - "openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です", - "copilotProxyHint": "GitHub Copilot を Claude プロバイダーとして使用する場合、ローカルプロキシが常に必要です。プロキシは現在のモデルに応じて Chat Completions または Responses を自動的に選択します。", + "openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、ルーティングサービスの有効化が必要です", + "copilotProxyHint": "GitHub Copilot を Claude プロバイダーとして使用する場合、ローカルルーティングが常に必要です。ルーティングは現在のモデルに応じて Chat Completions または Responses を自動的に選択します。", "openLinkFailed": "リンクを開けませんでした", "openclawModelsRegistered": "モデルが /model リストに登録されました", "openclawDefaultModelSet": "デフォルトモデルに設定しました", "openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました", "openclawNoModels": "モデルが設定されていません", - "backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました" + "backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました", + "windowControlFailed": "ウィンドウ操作に失敗しました: {{error}}", + "officialBlockedByProxy": "ローカルルーティングモード中は公式プロバイダーに切り替えできません。ルーティング経由で公式 API にアクセスするとアカウントが停止される可能性があります。", + "proxyOfficialWarning": "現在のプロバイダー {{name}} は公式です。ローカルルーティングを使用する前にサードパーティプロバイダーに切り替えてください。" }, "confirm": { "deleteProvider": "プロバイダーを削除", @@ -209,8 +217,8 @@ "removeProvider": "プロバイダーを解除", "removeProviderMessage": "プロバイダー「{{name}}」を設定から解除してもよろしいですか?\n\n解除後、このプロバイダーは無効になりますが、設定データは CC Switch に保持されます。いつでも再追加できます。", "proxy": { - "title": "ローカルプロキシの有効化", - "message": "ローカルプロキシは上級機能です。有効にする前に、その仕組みを理解していることをご確認ください。\n\n適切な設定方法については、関連ドキュメントまたはプロバイダーにご相談ください。", + "title": "ローカルルーティングの有効化", + "message": "ローカルルーティングは上級機能です。有効にする前に、その仕組みを理解していることをご確認ください。\n\n適切な設定方法については、関連ドキュメントまたはプロバイダーにご相談ください。", "confirm": "理解しました、有効にする" }, "failover": { @@ -245,7 +253,7 @@ "tabGeneral": "一般", "tabAuth": "認証", "tabAdvanced": "詳細", - "tabProxy": "プロキシ", + "tabProxy": "ルーティング", "authCenter": { "title": "OAuth 認証センター", "description": "Claude Code で他のサブスクリプションをご利用いただけます。コンプライアンスリスクにご注意ください。", @@ -259,10 +267,10 @@ "description": "Claude、Codex、Gemini の設定保存パスを管理" }, "proxy": { - "title": "ローカルプロキシ", - "description": "プロキシサービスの切り替え、ステータスとポート情報を表示", - "enableFeature": "メインページにプロキシ切り替えを表示", - "enableFeatureDescription": "有効にすると、メインページ上部にプロキシとフェイルオーバーの切り替えが表示されます", + "title": "ローカルルーティング", + "description": "ルーティングサービスの切り替え、ステータスとポート情報を表示", + "enableFeature": "メインページにルーティング切り替えを表示", + "enableFeatureDescription": "有効にすると、メインページ上部にルーティングとフェイルオーバーの切り替えが表示されます", "enableFailoverToggle": "メインページにフェイルオーバー切り替えを表示", "enableFailoverToggleDescription": "有効にすると、メインページ上部にフェイルオーバー切り替えが独立して表示されます", "running": "実行中", @@ -494,6 +502,8 @@ "autoLaunchFailed": "自動起動の設定に失敗しました", "minimizeToTray": "閉じるときトレイへ最小化", "minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。", + "useAppWindowControls": "アプリ内ウィンドウボタンを有効化", + "useAppWindowControlsDescription": "有効にすると、アプリヘッダーに最小化・最大化/復元・閉じるボタンを表示します。", "enableClaudePluginIntegration": "Claude Code 拡張に適用", "enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します", "skipClaudeOnboarding": "Claude Code の初回確認をスキップ", @@ -612,14 +622,14 @@ "saving": "保存中...", "globalProxy": { "label": "グローバルプロキシ", - "hint": "すべてのリクエスト(API、Skills ダウンロードなど)をプロキシ経由で送信します。空欄で直接接続。", + "hint": "すべてのリクエスト(API、Skills ダウンロードなど)をプロキシ経由で送信します。ローカルルーティング有効時、アプリのリクエストもこのプロキシを経由します。空欄で直接接続。", "username": "ユーザー名(任意)", "password": "パスワード(任意)", "test": "接続テスト", "scan": "ローカルプロキシをスキャン", "clear": "クリア", "scanFailed": "スキャンに失敗しました: {{error}}", - "saved": "プロキシ設定を保存しました", + "saved": "ルーティング設定を保存しました", "saveFailed": "保存に失敗しました: {{error}}", "testSuccess": "接続成功!遅延 {{latency}}ms", "testFailed": "接続に失敗しました: {{error}}", @@ -715,7 +725,9 @@ "copyCommand": "コマンドをコピー", "copyMessage": "メッセージをコピー", "messageCopied": "メッセージがコピーされました", - "conversationHistory": "会話履歴" + "conversationHistory": "会話履歴", + "expandContent": "全文を表示", + "collapseContent": "折りたたむ" }, "console": { "providerSwitchReceived": "プロバイダー切り替えイベントを受信:", @@ -773,7 +785,6 @@ "siliconflow": "SiliconFlow は CC Switch の公式パートナーです", "ucloud": "Compshare は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、5元のプラットフォーム体験クレジットがもらえます!", "micu": "Micu は CC Switch の公式パートナーです", - "x-code": "XCodeAPI は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、初回注文で 10% の追加クレジットがもらえます(管理者に連絡して受け取り)", "ctok": "公式サイトで CTok コミュニティに参加し、プランを購読してください。", "ddshub": "DDSHub は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、初回チャージで 10% の追加クレジットがもらえます(グループ管理者に連絡して受け取り)!", "lionccapi": "LionCCAPIはCC Switchユーザーに特別特典を提供しています。登録後、WeChat HSQBJ088888888を追加し、「cc-switch」と伝えると$10分のクレジットがもらえます!", @@ -812,12 +823,12 @@ "fullUrlLabel": "フル URL", "fullUrlEnabled": "フル URL モード", "fullUrlDisabled": "フル URL として設定", - "fullUrlHint": "💡 完全なリクエスト URL を入力してください。このモードはプロキシを有効にして使用する必要があり、プロキシはこの URL をそのまま使用し、パスを追加しません", + "fullUrlHint": "💡 完全なリクエスト URL を入力してください。このモードはルーティングを有効にして使用する必要があり、ルーティングはこの URL をそのまま使用し、パスを追加しません", "fullUrlHintGeminiNative": "💡 Gemini Native のフル URL モードでは 2 種類の入力を扱えます。1. 公式/構造化された Gemini URL は、要求されたモデルやストリーミング方式に合わせて正規化されます。2. カスタム relay の opaque な完全 URL は、主にそのまま使用され、必要なクエリだけ追加されます", "apiFormatAnthropic": "Anthropic Messages(ネイティブ)", - "apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)", - "apiFormatOpenAIResponses": "OpenAI Responses API(プロキシが必要)", - "apiFormatGeminiNative": "Gemini Native generateContent(プロキシが必要)", + "apiFormatOpenAIChat": "OpenAI Chat Completions(ルーティングが必要)", + "apiFormatOpenAIResponses": "OpenAI Responses API(ルーティングが必要)", + "apiFormatGeminiNative": "Gemini Native generateContent(ルーティングが必要)", "authField": "認証フィールド", "authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(デフォルト)", "authFieldApiKey": "ANTHROPIC_API_KEY", @@ -937,11 +948,6 @@ "testPrompt": "テストプロンプト", "degradedThreshold": "低下閾値(ミリ秒)", "maxRetries": "最大リトライ回数", - "proxyConfig": "プロキシ設定", - "useCustomProxy": "個別プロキシを使用", - "proxyConfigDesc": "このプロバイダーに個別のネットワークプロキシを設定します。無効の場合はシステムプロキシまたはグローバル設定を使用します。", - "proxyUsername": "ユーザー名(任意)", - "proxyPassword": "パスワード(任意)", "pricingConfig": "課金設定", "useCustomPricing": "個別設定を使用", "pricingConfigDesc": "このプロバイダーに個別の課金パラメータを設定します。無効の場合はグローバル設定を使用します。", @@ -1085,7 +1091,7 @@ }, "dataSources": "データソース", "dataSource": { - "proxy": "プロキシ", + "proxy": "ルーティング", "session_log": "セッションログ", "codex_db": "Codex DB", "codex_session": "Codex セッション", @@ -1100,6 +1106,8 @@ "failed": "セッション同期に失敗しました" }, "totalRecords": "全 {{total}} 件", + "goToPage": "移動", + "pageInputPlaceholder": "ページ", "modelPricing": "モデル料金", "loadPricingError": "料金データの読み込みに失敗しました", "modelPricingDesc": "各モデルのトークンコストを設定", @@ -1935,9 +1943,9 @@ "addressCopied": "アドレスをコピーしました", "currentProvider": "現在のプロバイダー:", "waitingFirstRequest": "現在のプロバイダー: 最初のリクエスト待ち...", - "stoppedTitle": "プロキシサービス停止中", + "stoppedTitle": "ルーティングサービス停止中", "stoppedDescription": "上のトグルでサービスを開始できます", - "openSettings": "プロキシサービスを設定", + "openSettings": "ルーティングサービスを設定", "stats": { "activeConnections": "アクティブ接続", "totalRequests": "総リクエスト数", @@ -1946,14 +1954,14 @@ } }, "settings": { - "title": "プロキシサービス設定", - "description": "ローカルプロキシサーバーのリッスンアドレス、ポート、実行パラメータを設定します。保存後すぐに反映されます。", + "title": "ルーティングサービス設定", + "description": "ローカルルーティングサーバーのリッスンアドレス、ポート、実行パラメータを設定します。保存後すぐに反映されます。", "alert": { - "autoApply": "変更は実行中のプロキシサービスに自動的に同期され、手動での再起動は不要です。" + "autoApply": "変更は実行中のルーティングサービスに自動的に同期され、手動での再起動は不要です。" }, "basic": { "title": "基本設定", - "description": "プロキシサービスのリッスンアドレスとポートを設定します。" + "description": "ルーティングサービスのリッスンアドレスとポートを設定します。" }, "advanced": { "title": "詳細パラメータ", @@ -1967,12 +1975,12 @@ "listenAddress": { "label": "リッスンアドレス", "placeholder": "127.0.0.1", - "description": "プロキシサーバーがリッスンするIPアドレス(推奨: 127.0.0.1)" + "description": "ルーティングサーバーがリッスンするIPアドレス(推奨: 127.0.0.1)" }, "listenPort": { "label": "リッスンポート", "placeholder": "15721", - "description": "プロキシサーバーがリッスンするポート番号(1024 ~ 65535)" + "description": "ルーティングサーバーがリッスンするポート番号(1024 ~ 65535)" }, "maxRetries": { "label": "最大リトライ回数", @@ -1986,7 +1994,7 @@ }, "enableLogging": { "label": "ログ記録を有効化", - "description": "トラブルシューティングのためにすべてのプロキシリクエストを記録" + "description": "トラブルシューティングのためにすべてのルーティングリクエストを記録" }, "streamingFirstByteTimeout": { "label": "ストリーミング初回バイトタイムアウト(秒)", @@ -2017,29 +2025,29 @@ "save": "設定を保存" }, "toast": { - "saved": "プロキシ設定を保存しました", + "saved": "ルーティング設定を保存しました", "saveFailed": "保存に失敗しました: {{error}}" }, "invalidPort": "無効なポートです。1024〜65535 の数値を入力してください", "invalidAddress": "無効なアドレスです。有効な IP アドレス(例: 127.0.0.1)または localhost を入力してください", - "configSaved": "プロキシ設定を保存しました", + "configSaved": "ルーティング設定を保存しました", "configSaveFailed": "設定の保存に失敗しました", - "restartRequired": "アドレスまたはポートの変更を反映するにはプロキシサービスの再起動が必要です" + "restartRequired": "アドレスまたはポートの変更を反映するにはルーティングサービスの再起動が必要です" }, "switchFailed": "切り替えに失敗しました: {{error}}", "takeover": { - "hint": "テイクオーバーするアプリを選択します。有効にすると、そのアプリのリクエストはローカルプロキシ経由で転送されます", - "enabled": "{{app}} テイクオーバー有効", - "disabled": "{{app}} テイクオーバー無効", - "failed": "テイクオーバーの切り替えに失敗しました", + "hint": "ルーティングするアプリを選択します。有効にすると、そのアプリのリクエストはローカルルーティング経由で転送されます", + "enabled": "{{app}} ルーティング有効", + "disabled": "{{app}} ルーティング無効", + "failed": "ルーティングの切り替えに失敗しました", "tooltip": { - "active": "{{appLabel}} がインターセプト中 - {{address}}:{{port}}\nホットスイッチングのためプロバイダを切り替え", - "broken": "{{appLabel}} がインターセプト中ですが、プロキシサービスが実行されていません", - "inactive": "{{appLabel}} のライブ設定をインターセプトしてリクエストをローカルプロキシ経由でルーティング" + "active": "{{appLabel}} がルーティング中 - {{address}}:{{port}}\nホットスイッチングのためプロバイダを切り替え", + "broken": "{{appLabel}} がルーティング中ですが、ルーティングサービスが実行されていません", + "inactive": "{{appLabel}} のリクエストをローカルルーティング経由でルーティング" } }, "failover": { - "proxyRequired": "フェイルオーバーを設定するには、プロキシサービスを先に起動する必要があります", + "proxyRequired": "フェイルオーバーを設定するには、ルーティングサービスを先に起動する必要があります", "autoSwitch": "自動フェイルオーバー", "autoSwitchDescription": "有効にするとキューの P1 に即時切り替え、リクエスト失敗時はキュー内の次のプロバイダーを自動で試行します" }, @@ -2103,10 +2111,10 @@ "failed": "ログ状態の切り替えに失敗しました" }, "server": { - "started": "プロキシサービスが開始されました - {{address}}:{{port}}", - "startFailed": "プロキシサービスの開始に失敗しました: {{detail}}" + "started": "ルーティングサービスが開始されました - {{address}}:{{port}}", + "startFailed": "ルーティングサービスの開始に失敗しました: {{detail}}" }, - "stoppedWithRestore": "プロキシサービスが停止し、すべてのテイクオーバー設定が復元されました", + "stoppedWithRestore": "ルーティングサービスが停止し、すべてのルーティング設定が復元されました", "stopWithRestoreFailed": "停止に失敗しました: {{detail}}" }, "streamCheck": { @@ -2124,11 +2132,21 @@ "operational": "{{providerName}} は正常に動作しています ({{responseTimeMs}}ms)", "degraded": "{{providerName}} の応答が遅いです ({{responseTimeMs}}ms)", "failed": "{{providerName}} のチェックに失敗しました: {{message}}", - "error": "{{providerName}} のチェックでエラーが発生しました: {{error}}" + "rejected": "{{providerName}} のチェックが拒否されました: {{message}}", + "error": "{{providerName}} のチェックでエラーが発生しました: {{error}}", + "httpHint": { + "400": "リクエスト形式が拒否されました。ヘルスチェックの形式は実際の使用と異なる場合があります。", + "401": "APIキーが無効か、OAuthなどの認証方式を使用しています。チェック失敗は実際に使えないことを意味しません。", + "402": "アカウントの利用枠または請求の問題です。", + "403": "プロバイダーがリクエストを拒否しました。実際の使用では正常に動作する場合があります。", + "404": "エンドポイントが見つかりません。Base URLとAPIパスを確認してください。", + "429": "リクエストが多すぎます。しばらくしてからお試しください。", + "5xx": "プロバイダーの内部エラーです。一時的な問題の可能性が高いです。" + } }, "proxyConfig": { - "proxyEnabled": "プロキシ有効", - "appTakeover": "プロキシ有効", + "proxyEnabled": "ルーティング総スイッチ", + "appTakeover": "ルーティング有効", "perAppConfig": "アプリ別設定", "circuitBreaker": "サーキットブレーカー", "circuitBreakerSettings": "サーキットブレーカー設定", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 09b873086..7554f1449 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -91,7 +91,11 @@ "switchToChinese": "切换到中文", "switchToEnglish": "切换到英文", "enterEditMode": "进入编辑模式", - "exitEditMode": "退出编辑模式" + "exitEditMode": "退出编辑模式", + "windowMinimize": "最小化窗口", + "windowMaximize": "最大化窗口", + "windowRestore": "还原窗口", + "windowClose": "关闭窗口" }, "provider": { "tabProvider": "供应商", @@ -104,6 +108,7 @@ "currentlyUsing": "当前使用", "enable": "启用", "inUse": "使用中", + "blockedByProxy": "已拦截", "editProvider": "编辑供应商", "editProviderHint": "更新配置后将立即应用到当前供应商。", "deleteProvider": "删除供应商", @@ -189,19 +194,22 @@ "deleteFailed": "删除供应商失败:{{error}}", "settingsSaved": "设置已保存", "settingsSaveFailed": "保存设置失败:{{error}}", - "proxyRequiredForSwitch": "此供应商{{reason}},需要代理服务才能正常使用,请先启动代理", + "proxyRequiredForSwitch": "此供应商{{reason}},需要路由服务才能正常使用,请先启动路由", "proxyReasonCopilot": "使用 GitHub Copilot 作为 Claude 供应商", "proxyReasonOpenAIChat": "使用 OpenAI Chat 接口格式", "proxyReasonOpenAIResponses": "使用 OpenAI Responses 接口格式", "proxyReasonFullUrl": "开启了完整 URL 连接模式", - "openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用", - "copilotProxyHint": "GitHub Copilot 作为 Claude 供应商时始终需要本地代理;代理会根据当前模型自动选择 Chat Completions 或 Responses。", + "openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启路由服务才能正常使用", + "copilotProxyHint": "GitHub Copilot 作为 Claude 供应商时始终需要本地路由;路由会根据当前模型自动选择 Chat Completions 或 Responses。", "openLinkFailed": "链接打开失败", "openclawModelsRegistered": "模型已注册到 /model 列表", "openclawDefaultModelSet": "已设为默认模型", "openclawDefaultModelSetFailed": "设置默认模型失败", "openclawNoModels": "该供应商没有配置模型", - "backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存" + "backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存", + "windowControlFailed": "窗口控制失败:{{error}}", + "officialBlockedByProxy": "本地路由模式下不能切换到官方供应商,使用路由访问官方 API 可能导致账号被封禁", + "proxyOfficialWarning": "当前供应商 {{name}} 是官方供应商,建议切换到第三方供应商后再使用本地路由" }, "confirm": { "deleteProvider": "删除供应商", @@ -209,8 +217,8 @@ "removeProvider": "移除供应商", "removeProviderMessage": "确定要从配置中移除供应商 \"{{name}}\" 吗?\n\n移除后该供应商将不再生效,但配置数据会保留在 CC Switch 中,您可以随时重新添加。", "proxy": { - "title": "启用本地代理服务", - "message": "本地代理是一项高级功能,启用前请确保您已了解其工作原理。\n\n建议先查阅相关文档或咨询您的供应商,以获取正确的配置方式。", + "title": "启用本地路由服务", + "message": "本地路由是一项高级功能,启用前请确保您已了解其工作原理。\n\n建议先查阅相关文档或咨询您的供应商,以获取正确的配置方式。", "confirm": "我已了解,继续启用" }, "failover": { @@ -245,7 +253,7 @@ "tabGeneral": "通用", "tabAuth": "认证", "tabAdvanced": "高级", - "tabProxy": "代理", + "tabProxy": "路由", "authCenter": { "title": "OAuth 认证中心", "description": "在 Claude Code 中使用您的其他订阅,请注意合规风险。", @@ -259,10 +267,10 @@ "description": "管理 Claude、Codex 和 Gemini 的配置存储路径" }, "proxy": { - "title": "本地代理", - "description": "控制代理服务开关、查看状态与端口信息", - "enableFeature": "在主页面显示本地代理开关", - "enableFeatureDescription": "开启后,主页面顶部将显示代理和故障转移开关", + "title": "本地路由", + "description": "控制路由服务开关、查看状态与端口信息", + "enableFeature": "在主页面显示本地路由开关", + "enableFeatureDescription": "开启后,主页面顶部将显示路由和故障转移开关", "enableFailoverToggle": "在主页面显示故障转移开关", "enableFailoverToggleDescription": "开启后,主页面顶部将独立显示故障转移开关", "running": "运行中", @@ -494,6 +502,8 @@ "autoLaunchFailed": "设置开机自启失败", "minimizeToTray": "关闭时最小化到托盘", "minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。", + "useAppWindowControls": "启用应用级窗口按钮", + "useAppWindowControlsDescription": "开启后使用应用自建的最小化、最大化/还原、关闭按钮;关闭后沿用系统窗口模式。", "enableClaudePluginIntegration": "应用到 Claude Code 插件", "enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换", "skipClaudeOnboarding": "跳过 Claude Code 初次安装确认", @@ -612,7 +622,7 @@ "saving": "正在保存...", "globalProxy": { "label": "全局代理", - "hint": "代理所有请求(API、Skills 下载等)。留空表示直连。", + "hint": "代理所有请求(API、Skills 下载等)。开启本地路由时,应用的请求也会经过此代理。留空表示直连。", "username": "用户名(可选)", "password": "密码(可选)", "test": "测试连接", @@ -715,7 +725,9 @@ "copyCommand": "复制命令", "copyMessage": "复制消息", "messageCopied": "已复制消息内容", - "conversationHistory": "对话记录" + "conversationHistory": "对话记录", + "expandContent": "展开完整内容", + "collapseContent": "收起" }, "console": { "providerSwitchReceived": "收到供应商切换事件:", @@ -773,7 +785,6 @@ "siliconflow": "硅基流动是 CC Switch 的官方合作伙伴", "ucloud": "优云智算为CC Switch 的用户提供了特殊优惠,通过此链接注册,可以获得五元平台体验金!", "micu": "Micu 是 CC Switch 的官方合作伙伴", - "x-code": "XCodeAPI 为CC Switch 的用户提供特别福利,使用此链接注册后首单加赠10%的额度(联系站长领取)", "ctok": "官网加入CTok社群,订阅套餐。", "ddshub": "呆呆兽为CC Switch 的用户提供了特别福利,通过此链接注册后,首单充值可额外赠送 10% 额度(联系群主领取)!", "lionccapi": "LionCCAPI 为 CC Switch 的用户提供了特别福利,注册后添加客服微信HSQBJ088888888,发暗号cc-switch备注即可送10美金额度!", @@ -813,12 +824,12 @@ "fullUrlLabel": "完整 URL", "fullUrlEnabled": "完整 URL 模式", "fullUrlDisabled": "标记为完整 URL", - "fullUrlHint": "💡 请填写完整请求 URL,并且必须开启代理后使用;代理将直接使用此 URL,不拼接路径", + "fullUrlHint": "💡 请填写完整请求 URL,并且必须开启路由后使用;路由将直接使用此 URL,不拼接路径", "fullUrlHintGeminiNative": "💡 Gemini Native 下,完整 URL 模式同时兼容两类地址:1. 官方/标准 Gemini URL,代理会按模型和流式参数自动归一化;2. 自定义 relay 的完整 URL,代理会尽量原样使用,只补查询参数,不再强行追加 models 路径", "apiFormatAnthropic": "Anthropic Messages (原生)", - "apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)", - "apiFormatOpenAIResponses": "OpenAI Responses API (需开启代理)", - "apiFormatGeminiNative": "Gemini Native generateContent (需开启代理)", + "apiFormatOpenAIChat": "OpenAI Chat Completions (需开启路由)", + "apiFormatOpenAIResponses": "OpenAI Responses API (需开启路由)", + "apiFormatGeminiNative": "Gemini Native generateContent (需开启路由)", "authField": "认证字段", "authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(默认)", "authFieldApiKey": "ANTHROPIC_API_KEY", @@ -938,11 +949,6 @@ "testPrompt": "测试提示词", "degradedThreshold": "降级阈值(毫秒)", "maxRetries": "最大重试次数", - "proxyConfig": "代理配置", - "useCustomProxy": "使用单独代理", - "proxyConfigDesc": "为此供应商配置单独的网络代理,不启用时使用系统代理或全局设置。", - "proxyUsername": "用户名(可选)", - "proxyPassword": "密码(可选)", "pricingConfig": "计费配置", "useCustomPricing": "使用单独配置", "pricingConfigDesc": "为此供应商配置单独的计费参数,不启用时使用全局默认配置。", @@ -1086,7 +1092,7 @@ }, "dataSources": "数据来源", "dataSource": { - "proxy": "代理", + "proxy": "路由", "session_log": "会话日志", "codex_db": "Codex 数据库", "codex_session": "Codex 会话日志", @@ -1101,6 +1107,8 @@ "failed": "会话同步失败" }, "totalRecords": "共 {{total}} 条记录", + "goToPage": "跳转", + "pageInputPlaceholder": "页码", "modelPricing": "模型定价", "loadPricingError": "加载定价数据失败", "modelPricingDesc": "配置各模型的 Token 成本", @@ -1936,9 +1944,9 @@ "addressCopied": "地址已复制", "currentProvider": "当前 Provider:", "waitingFirstRequest": "当前 Provider:等待首次请求…", - "stoppedTitle": "代理服务已停止", + "stoppedTitle": "路由服务已停止", "stoppedDescription": "使用上方开关即可启动服务", - "openSettings": "配置代理服务", + "openSettings": "配置路由服务", "stats": { "activeConnections": "活跃连接", "totalRequests": "总请求数", @@ -1947,14 +1955,14 @@ } }, "settings": { - "title": "代理服务设置", - "description": "配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。", + "title": "路由服务设置", + "description": "配置本地路由服务器的监听地址、端口和运行参数,保存后立即生效。", "alert": { - "autoApply": "保存后将自动同步到正在运行的代理服务,无需手动重启。" + "autoApply": "保存后将自动同步到正在运行的路由服务,无需手动重启。" }, "basic": { "title": "基础设置", - "description": "配置代理服务监听的地址与端口。" + "description": "配置路由服务监听的地址与端口。" }, "advanced": { "title": "高级参数", @@ -1968,12 +1976,12 @@ "listenAddress": { "label": "监听地址", "placeholder": "127.0.0.1", - "description": "代理服务器监听的 IP 地址(推荐 127.0.0.1)" + "description": "路由服务器监听的 IP 地址(推荐 127.0.0.1)" }, "listenPort": { "label": "监听端口", "placeholder": "15721", - "description": "代理服务器监听的端口号(1024 ~ 65535)" + "description": "路由服务器监听的端口号(1024 ~ 65535)" }, "maxRetries": { "label": "最大重试次数", @@ -1987,7 +1995,7 @@ }, "enableLogging": { "label": "启用日志记录", - "description": "记录所有代理请求,便于排查问题" + "description": "记录所有路由请求,便于排查问题" }, "streamingFirstByteTimeout": { "label": "流式首字超时(秒)", @@ -2018,29 +2026,29 @@ "save": "保存配置" }, "toast": { - "saved": "代理配置已保存", + "saved": "路由配置已保存", "saveFailed": "保存失败: {{error}}" }, "invalidPort": "端口无效,请输入 1024-65535 之间的数字", "invalidAddress": "地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost", - "configSaved": "代理配置已保存", + "configSaved": "路由配置已保存", "configSaveFailed": "保存配置失败", - "restartRequired": "修改地址或端口后需要重启代理服务才能生效" + "restartRequired": "修改地址或端口后需要重启路由服务才能生效" }, "switchFailed": "切换失败: {{error}}", "takeover": { - "hint": "选择要接管的应用,启用后该应用的请求将通过本地代理转发", - "enabled": "{{app}} 接管已启用", - "disabled": "{{app}} 接管已关闭", - "failed": "切换接管状态失败", + "hint": "选择要路由的应用,启用后该应用的请求将通过本地路由转发", + "enabled": "{{app}} 路由已启用", + "disabled": "{{app}} 路由已关闭", + "failed": "切换路由状态失败", "tooltip": { - "active": "{{appLabel}} 已接管 - {{address}}:{{port}}\n切换该应用供应商为热切换", - "broken": "{{appLabel}} 已接管,但代理服务未运行", - "inactive": "接管 {{appLabel}} 的 Live 配置,让该应用请求走本地代理" + "active": "{{appLabel}} 路由中 - {{address}}:{{port}}\n切换该应用供应商为热切换", + "broken": "{{appLabel}} 路由中,但路由服务未运行", + "inactive": "路由 {{appLabel}} 的请求,让该应用请求走本地路由" } }, "failover": { - "proxyRequired": "需要先启动代理服务才能配置故障转移", + "proxyRequired": "需要先启动路由服务才能配置故障转移", "autoSwitch": "自动故障转移", "autoSwitchDescription": "开启后将立即切换到队列 P1,并在请求失败时自动切换到队列中的下一个供应商" }, @@ -2104,10 +2112,10 @@ "failed": "切换日志状态失败" }, "server": { - "started": "代理服务已启动 - {{address}}:{{port}}", - "startFailed": "启动代理服务失败: {{detail}}" + "started": "路由服务已启动 - {{address}}:{{port}}", + "startFailed": "启动路由服务失败: {{detail}}" }, - "stoppedWithRestore": "代理服务已关闭,已恢复所有接管配置", + "stoppedWithRestore": "路由服务已关闭,已恢复所有路由配置", "stopWithRestoreFailed": "停止失败: {{detail}}" }, "streamCheck": { @@ -2125,11 +2133,21 @@ "operational": "{{providerName}} 运行正常 ({{responseTimeMs}}ms)", "degraded": "{{providerName}} 响应较慢 ({{responseTimeMs}}ms)", "failed": "{{providerName}} 检查失败: {{message}}", - "error": "{{providerName}} 检查出错: {{error}}" + "rejected": "{{providerName}} 检查被拒: {{message}}", + "error": "{{providerName}} 检查出错: {{error}}", + "httpHint": { + "400": "供应商拒绝了请求格式。健康检查的探测格式可能与实际使用不同。", + "401": "API Key 可能无效,或供应商使用 OAuth 等认证方式。检查失败不代表实际不可用。", + "402": "账户配额或计费问题。", + "403": "供应商拒绝了此请求。部分供应商会校验客户端身份,检查失败但实际使用可能正常。", + "404": "接口地址不存在,请检查 Base URL 和 API 路径。", + "429": "请求频率过高,请稍后重试。", + "5xx": "供应商内部错误,通常是临时问题。" + } }, "proxyConfig": { - "proxyEnabled": "代理总开关", - "appTakeover": "代理启用", + "proxyEnabled": "路由总开关", + "appTakeover": "路由启用", "perAppConfig": "应用配置", "circuitBreaker": "熔断器配置", "circuitBreakerSettings": "熔断器设置", diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index 5402dc151..821de195f 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -5,7 +5,6 @@ import _dds from "./dds.svg?url"; import _eflowcode from "./eflowcode.png"; import _pipellm from "./pipellm.png"; import _shengsuanyun from "./shengsuanyun.svg?url"; -import _xcode from "./xcode.svg?url"; export const icons: Record = { aicodemirror: `AICodeMirror`, @@ -84,7 +83,6 @@ export const iconUrls: Record = { eflowcode: _eflowcode, pipellm: _pipellm, shengsuanyun: _shengsuanyun, - xcode: _xcode, }; export const iconList = [ diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index 161b30c3b..04e986459 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -345,13 +345,6 @@ export const iconMetadata: Record = { keywords: [], defaultColor: "currentColor", }, - xcode: { - name: "xcode", - displayName: "Xcode", - category: "tool", - keywords: ["apple", "xcode", "ide"], - defaultColor: "currentColor", - }, zhipu: { name: "zhipu", displayName: "Zhipu AI", diff --git a/src/icons/extracted/xcode.svg b/src/icons/extracted/xcode.svg deleted file mode 100644 index a5dd83f39..000000000 --- a/src/icons/extracted/xcode.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 1cc0a4fc8..8379c217b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,22 +109,6 @@ export interface ProviderTestConfig { maxRetries?: number; } -// 供应商单独的代理配置 -export interface ProviderProxyConfig { - // 是否启用单独配置(false 时使用全局/系统代理) - enabled: boolean; - // 代理类型:http, https, socks5 - proxyType?: "http" | "https" | "socks5"; - // 代理主机 - proxyHost?: string; - // 代理端口 - proxyPort?: number; - // 代理用户名(可选) - proxyUsername?: string; - // 代理密码(可选) - proxyPassword?: string; -} - export type AuthBindingSource = "provider_config" | "managed_account"; export interface AuthBinding { @@ -149,8 +133,6 @@ export interface ProviderMeta { partnerPromotionKey?: string; // 供应商单独的模型测试配置 testConfig?: ProviderTestConfig; - // 供应商单独的代理配置 - proxyConfig?: ProviderProxyConfig; // 供应商成本倍率 costMultiplier?: string; // 供应商计费模式来源 @@ -171,7 +153,7 @@ export interface ProviderMeta { apiKeyField?: ClaudeApiKeyField; // 是否将 base_url 视为完整 API 端点(代理直接使用此 URL,不拼接路径) isFullUrl?: boolean; - // Prompt cache key for OpenAI-compatible endpoints (improves cache hit rate) + // Prompt cache key for OpenAI Responses-compatible endpoints (improves cache hit rate) promptCacheKey?: string; // 供应商类型(用于识别 Copilot 等特殊供应商) providerType?: string; @@ -254,6 +236,8 @@ export interface Settings { showInTray: boolean; // 点击关闭按钮时是否最小化到托盘而不是关闭应用 minimizeToTrayOnClose: boolean; + // 是否启用应用级窗口控制按钮(最小化/最大化/关闭) + useAppWindowControls?: boolean; // 启用 Claude 插件联动(写入 ~/.claude/config.json 的 primaryApiKey) enableClaudePluginIntegration?: boolean; // 跳过 Claude Code 初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding)