Compare commits

..

2 Commits

Author SHA1 Message Date
YoVinchen fdb36ead45 fix(usage): reorder cache columns and prevent header text wrapping
- Swap cache read and cache write columns order
- Add whitespace-nowrap to all table headers to prevent text wrapping
- Improves table readability and layout consistency
2025-12-07 14:36:57 +08:00
YoVinchen 663acf49e8 feat(skill): add multi-app skill support for Claude/Codex/Gemini
- Add app-specific skill management with AppType prefix in skill keys
- Implement per-app skill tracking in database schema
- Add get_skills_for_app command to retrieve skills by application
- Update SkillsPage to support app-specific skill loading with initialApp prop
- Parse app parameter and validate against supported app types
- Maintain backward compatibility with default claude app
2025-12-07 14:08:03 +08:00
220 changed files with 5236 additions and 21536 deletions
+1 -1
View File
@@ -1 +1 @@
22.12.0 v22.4.1
-152
View File
@@ -5,158 +5,6 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.9.0-3] - 2025-12-29
### Beta Release
Third beta release with important bug fixes for Windows compatibility, UI improvements, and new features.
### Added
- **Universal Provider** - Support for universal provider configurations (#348)
- **Provider Search Filter** - Quick filter to find providers by name (#435)
- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436)
- **Xiaomi MiMo Icon** - Added MiMo icon and Claude provider configuration (#470)
- **Usage Model Extraction** - Extract model info from usage statistics (#455)
- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog
- **Exit Animations** - Added exit animation to FullScreenPanel dialogs
- **Fade Transitions** - Smooth fade transitions for app/view/panel switching
### Fixed
#### Windows
- Wrap npx/npm commands with `cmd /c` for MCP export
- Prevent terminal windows from appearing during version check
#### macOS
- Use .app bundle path for autostart to prevent terminal window popup
#### UI
- Resolve Dialog/Modal not opening on first click (#492)
- Improve dark mode text contrast for form labels
- Reduce header spacing and fix layout shift on view switch
- Prevent header layout shift when switching views
#### Database & Schema
- Add missing base columns migration for proxy_config
- Add backward compatibility check for proxy_config seed insert
#### Other
- Use local timezone and robust DST handling in usage stats (#500)
- Remove deprecated `sync_enabled_to_codex` call
- Gracefully handle invalid Codex config.toml during MCP sync
- Add missing translations for reasoning model and OpenRouter compat mode
### Improved
- **macOS Tray** - Use macOS tray template icon
- **Header Alignment** - Remove macOS titlebar tint, align custom header
- **Shadow Removal** - Cleaner UI by removing shadow styles
- **Code Inspector** - Added code-inspector-plugin for development
- **i18n** - Complete internationalization for usage panel and settings
- **Sponsor Logos** - Made sponsor logos clickable
### Stats
- 35 commits since v3.9.0-2
- 5 files changed in test/lint fixes
---
## [3.9.0-1] - 2025-12-18
### Beta Release
This beta release introduces the **Local API Proxy** feature, along with Skills multi-app support, UI improvements, and numerous bug fixes.
### Major Features
#### Local Proxy Server
- **Local HTTP Proxy** - High-performance proxy server built on Axum framework
- **Multi-app Support** - Unified proxy for Claude Code, Codex, and Gemini CLI API requests
- **Per-app Takeover** - Independent control over which apps route through the proxy
- **Live Config Takeover** - Automatically backs up and redirects CLI configurations to local proxy
#### Auto Failover
- **Circuit Breaker** - Automatically detects provider failures and triggers protection
- **Smart Failover** - Automatically switches to backup provider when current one is unavailable
- **Health Tracking** - Real-time monitoring of provider availability
- **Independent Failover Queues** - Each app maintains its own failover queue
#### Monitoring
- **Request Logging** - Detailed logging of all proxy requests
- **Usage Statistics** - Token consumption, latency, success rate metrics
- **Real-time Status** - Frontend displays proxy status and statistics
#### Skills Multi-App Support
- **Multi-app Support** - Skills now support both Claude and Codex (#365)
- **Multi-app Migration** - Existing Skills auto-migrate to multi-app structure (#378)
- **Installation Path Fix** - Use directory basename for skill installation path (#358)
### Added
- **Provider Icon Colors** - Customize provider icon colors (#385)
- **Deeplink Usage Config** - Import usage query config via deeplink (#400)
- **Error Request Logging** - Detailed logging for proxy requests (#401)
- **Closable Toast** - Added close button to switch notification toast (#350)
- **Icon Color Component** - ProviderIcon component supports color prop (#384)
### Fixed
#### Proxy Related
- Takeover Codex base_url via model_provider
- Harden crash recovery with fallback detection
- Sync UI when active provider differs from current setting
- Resolve circuit breaker race condition and error classification
- Stabilize live takeover and provider editing
- Reset health badges when proxy stops
- Retry failover for all HTTP errors including 4xx
- Fix HalfOpen counter underflow and config field inconsistencies
- Resolve circuit breaker state persistence and HalfOpen deadlock
- Auto-recover live config after abnormal exit
- Update live backup when hot-switching provider in proxy mode
- Wait for server shutdown before exiting app
- Disable auto-start on app launch by resetting enabled flag on stop
- Sync live config tokens to database before takeover
- Resolve 404 error and auto-setup proxy targets
#### MCP Related
- Skip sync when target CLI app is not installed
- Improve upsert and import robustness
- Use browser-compatible platform detection for MCP presets
#### UI Related
- Restore fade transition for Skills button
- Add close button to all success toasts
- Prevent card jitter when health badge appears
- Update SettingsPage tab styles (#342)
#### Other
- Fix Azure website link (#407)
- Add fallback to provider config for usage credentials (#360)
- Fix Windows black screen on startup (use system titlebar)
- Add fallback for crypto.randomUUID() on older WebViews
- Use correct npm package for Codex CLI version check
- Security fixes for JavaScript executor and usage script (#151)
### Improved
- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active
- **Card Animation** - Improved provider card hover animation
- **Remove Restart Prompt** - No longer prompts restart when switching providers
### Technical
- Implement per-app takeover mode
- Proxy module contains 20+ Rust files with complete layered architecture
- Add 5 new database tables for proxy functionality
- Modularize handlers.rs to reduce code duplication
- Remove is_proxy_target in favor of failover_queue
### Stats
- 55 commits since v3.8.2
- 164 files changed
- +22,164 / -570 lines
---
## [3.8.0] - 2025-11-28 ## [3.8.0] - 2025-11-28
### Major Updates ### Major Updates
+16 -12
View File
@@ -2,7 +2,8 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI # All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.8.3-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest) [![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -11,30 +12,33 @@
English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md) English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md)
**From Provider Switcher to All-in-One AI CLI Management Platform**
Unified management for Claude Code, Codex & Gemini CLI provider configurations, MCP servers, Skills extensions, and system prompts.
</div> </div>
## ❤️Sponsor ## ❤️Sponsor
[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB) ![Zhipu GLM](assets/partners/banners/glm-en.jpg)
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)! This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
--- ---
<table> <table>
<tr> <tr>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td> <td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td> <td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
</tr> </tr>
<tr> <tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td> <td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
<td>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 <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td> <td>Thanks to ShanDianShuo for sponsoring this project! ShanDianShuo is a local-first AI voice input: Millisecond latency, data stays on device, 4x faster than typing, AI-powered correction, Privacy-first, completely free. Doubles your coding efficiency with Claude Code! <a href="https://www.shandianshuo.cn">Free download</a> for Mac/Win</td>
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href="https://www.dmxapi.cn/register?aff=bUHu">Register here</a></td>
</tr> </tr>
</table> </table>
@@ -47,7 +51,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
## Features ## Features
### Current Version: v3.8.3 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md) ### Current Version: v3.8.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
**v3.8.0 Major Update (2025-11-28)** **v3.8.0 Major Update (2025-11-28)**
+16 -12
View File
@@ -2,7 +2,8 @@
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント # Claude Code / Codex / Gemini CLI オールインワン・アシスタント
[![Version](https://img.shields.io/badge/version-3.8.3-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest) [![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -11,30 +12,33 @@
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.8.0 リリースノート](docs/release-note-v3.8.0-en.md) [English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.8.0 リリースノート](docs/release-note-v3.8.0-en.md)
**プロバイダスイッチャーから AI CLI 一体型管理プラットフォームへ**
Claude Code・Codex・Gemini CLI のプロバイダ設定、MCP サーバー、Skills 拡張、システムプロンプトを統合管理。
</div> </div>
## ❤️スポンサー ## ❤️スポンサー
[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB) ![Zhipu GLM](assets/partners/banners/glm-en.jpg)
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります! 本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。
GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。
[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
--- ---
<table> <table>
<tr> <tr>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td> <td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td> <td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
</tr> </tr>
<tr> <tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td> <td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td> <td>ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。<a href="https://www.shandianshuo.cn">Mac/Win 版を無料ダウンロード</a></td>
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td>
</tr> </tr>
</table> </table>
@@ -47,7 +51,7 @@
## 特長 ## 特長
### 現在のバージョン:v3.8.3 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md) ### 現在のバージョン:v3.8.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
**v3.8.0 メジャーアップデート (2025-11-28)** **v3.8.0 メジャーアップデート (2025-11-28)**
+16 -12
View File
@@ -2,7 +2,8 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具 # Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.8.3-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest) [![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -11,32 +12,35 @@
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md) [English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md)
**从供应商切换器到 AI CLI 一体化管理平台**
统一管理 Claude Code、Codex 与 Gemini CLI 的供应商配置、MCP 服务器、Skills 扩展和系统提示词。
</div> </div>
## ❤️赞助商 ## ❤️赞助商
[![智谱 GLM](assets/partners/banners/glm-zh.jpg)](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII) ![智谱 GLM](assets/partners/banners/glm-zh.jpg)
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。 感谢智谱AI的 GLM CODING PLAN 赞助了本项目!
GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。
CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
--- ---
<table> <table>
<tr> <tr>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td> <td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠。</td> <td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠。</td>
</tr> </tr>
<tr> <tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td> <td width="180"><img src="assets/partners/logos/sds-zh.png" alt="ShanDianShuo" width="150"></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td> <td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍,AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td>
</tr> </tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-zh.jpeg" alt="DMXAPI" width="150"></a></td>
<td>感谢 DMXAPI(大模型API)赞助了本项目! DMXAPI,一个Key用全球大模型。
为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!<a href="https://www.dmxapi.cn/register?aff=bUHu">点击这里注册</a></td>
</tr>
</table> </table>
## 界面预览 ## 界面预览
@@ -47,7 +51,7 @@
## 功能特性 ## 功能特性
### 当前版本:v3.8.3 | [完整更新日志](CHANGELOG.md) ### 当前版本:v3.8.2 | [完整更新日志](CHANGELOG.md)
**v3.8.0 重大更新(2025-11-28** **v3.8.0 重大更新(2025-11-28**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

+1 -1
View File
@@ -4,7 +4,7 @@
"rsc": false, "rsc": false,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "tailwind.config.cjs", "config": "tailwind.config.js",
"css": "src/index.css", "css": "src/index.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
-165
View File
@@ -1,165 +0,0 @@
# CC Switch 代理功能使用指南
## 功能介绍
CC Switch 的代理功能是一个本地 HTTP 代理服务器,可以统一管理 Claude Code、Codex 和 Gemini CLI 的 API 请求。主要特性包括:
- **统一代理入口** - 所有 CLI 应用的请求通过本地代理转发
- **自动故障转移** - 当前供应商故障时自动切换到备用供应商
- **按应用控制** - 可独立控制每个应用是否启用代理
- **配置保护** - 自动备份原始配置,停止代理时安全恢复
## 快速开始
### 1. 启动代理
在 CC Switch 主界面,点击右上角的 **Proxy** 按钮,可以看到代理控制面板。
点击 **启动代理** 按钮启动本地代理服务器。代理默认监听 `127.0.0.1:15721`
### 2. 启用应用接管
代理启动后,你可以选择让哪些应用的请求通过代理:
- **Claude** - 接管 Claude Code 的 API 请求
- **Codex** - 接管 Codex CLI 的 API 请求
- **Gemini** - 接管 Gemini CLI 的 API 请求
点击对应应用的开关即可启用/禁用接管。
> **注意**:启用接管后,CC Switch 会自动修改对应应用的配置文件,将 API 端点指向本地代理。原始配置会被安全备份。
### 3. 正常使用 CLI
启用接管后,你可以正常使用各个 CLI 工具。所有请求都会经过 CC Switch 代理转发到配置的供应商。
### 4. 停止代理
当你不再需要代理时,点击 **停止代理** 按钮。CC Switch 会:
1. 安全关闭代理服务器
2. 自动恢复所有应用的原始配置
3. 清除代理状态
## 自动故障转移
### 工作原理
代理功能内置了智能故障转移机制:
1. **健康监控** - 实时监控每个供应商的响应状态
2. **熔断器** - 连续失败 5 次后触发熔断,暂停使用该供应商
3. **自动切换** - 熔断后自动切换到列表中的下一个供应商
4. **自动恢复** - 30 秒后尝试恢复熔断的供应商
### 配置故障转移
要使用故障转移功能,你需要:
1. 在对应应用下添加多个供应商(至少 2 个)
2. 启动代理并启用接管
3. 当主供应商故障时,代理会自动切换到备用供应商
### 健康状态指示
在供应商卡片上可以看到健康状态指示:
- **绿色** - 供应商正常
- **红色** - 供应商故障/熔断中
- **灰色** - 未使用代理或未检测
## 按应用接管
v3.9.0 新增了按应用分粒度控制功能:
- 你可以只接管 Claude,而让 Codex 使用原始配置
- 每个应用的接管状态独立管理
- 启用/禁用不会影响其他应用
### 接管状态检测
CC Switch 通过检测配置备份来判断接管状态:
- 存在备份 = 已接管
- 无备份 = 未接管
这确保了即使 CC Switch 异常退出,重新启动后也能正确识别状态。
## 代理配置
在代理面板中,你可以配置以下参数:
| 参数 | 默认值 | 说明 |
|------|--------|------|
| 监听地址 | 127.0.0.1 | 代理服务器绑定地址 |
| 监听端口 | 15721 | 代理服务器端口 |
| 最大重试 | 3 | 请求失败时的最大重试次数 |
| 请求超时 | 120 秒 | 单个请求的超时时间 |
| 启用日志 | 是 | 是否记录请求日志 |
## 常见问题
### Q: 代理启动失败,提示端口被占用?
A: 默认端口 15721 可能被其他程序占用。你可以:
- 关闭占用该端口的程序
- 在代理配置中修改端口号
### Q: 启用接管后 CLI 无法使用?
A: 请检查:
1. 代理服务器是否正常运行(查看代理面板状态)
2. 供应商配置是否正确(API Key 等)
3. 网络连接是否正常
### Q: 如何恢复原始配置?
A: 点击 **停止代理** 按钮,CC Switch 会自动恢复所有应用的原始配置。
如果 CC Switch 异常退出,重新启动后会检测到之前的备份,你可以:
- 点击停止代理来恢复配置
- 或继续使用代理功能
### Q: 故障转移没有生效?
A: 请确保:
1. 配置了至少 2 个供应商
2. 代理已启动且接管已启用
3. 故障转移只在代理模式下工作
### Q: 代理会影响性能吗?
A: 本地代理的延迟开销非常小(通常 < 1ms)。但如果启用了请求日志,在高频请求场景下可能会有少量性能影响。
## 技术细节
### 配置文件位置
启用接管后,CC Switch 会修改以下配置文件:
| 应用 | 配置文件 | 修改内容 |
|------|----------|----------|
| Claude | `~/.claude/settings.json` | `apiBaseUrl` 指向代理 |
| Codex | `~/.codex/config.toml` | `[api] baseUrl` 指向代理 |
| Gemini | `~/.gemini/.env` | `GEMINI_BASE_URL` 指向代理 |
原始配置备份在 CC Switch 数据库中,停止代理时自动恢复。
### 代理模式
代理服务器运行在接管模式下,会:
1. 接收来自 CLI 的 HTTPS 请求
2. 根据当前供应商配置转发到真实 API 端点
3. 返回响应给 CLI
4. 记录请求日志和健康状态
### 数据库表
代理功能使用以下数据库表:
- `proxy_config` - 代理配置
- `provider_health` - 供应商健康状态
- `proxy_request_logs` - 请求日志
- `circuit_breaker_config` - 熔断器配置
- `proxy_live_backup` - Live 配置备份
+3 -8
View File
@@ -1,8 +1,7 @@
{ {
"name": "cc-switch", "name": "cc-switch",
"version": "3.9.0-3", "version": "3.8.2",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": { "scripts": {
"dev": "pnpm tauri dev", "dev": "pnpm tauri dev",
"build": "pnpm tauri build", "build": "pnpm tauri build",
@@ -28,7 +27,6 @@
"@types/react-dom": "^18.2.0", "@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0", "@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"code-inspector-plugin": "^1.3.3",
"cross-fetch": "^4.1.0", "cross-fetch": "^4.1.0",
"jsdom": "^25.0.0", "jsdom": "^25.0.0",
"msw": "^2.11.6", "msw": "^2.11.6",
@@ -36,7 +34,7 @@
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^7.3.0", "vite": "^5.0.0",
"vitest": "^2.0.5" "vitest": "^2.0.5"
}, },
"dependencies": { "dependencies": {
@@ -52,7 +50,6 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@lobehub/icons-static-svg": "^1.73.0", "@lobehub/icons-static-svg": "^1.73.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -70,9 +67,7 @@
"@tauri-apps/plugin-updater": "^2.0.0", "@tauri-apps/plugin-updater": "^2.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"framer-motion": "^12.23.25",
"i18next": "^25.5.2", "i18next": "^25.5.2",
"jsonc-parser": "^3.2.1", "jsonc-parser": "^3.2.1",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
@@ -87,4 +82,4 @@
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }
+25 -638
View File
File diff suppressed because it is too large Load Diff
+5 -57
View File
@@ -586,12 +586,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.10.1" version = "1.10.1"
@@ -701,7 +695,7 @@ dependencies = [
[[package]] [[package]]
name = "cc-switch" name = "cc-switch"
version = "3.9.0-3" version = "3.8.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@@ -2220,7 +2214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"png 0.17.16", "png",
] ]
[[package]] [[package]]
@@ -2336,19 +2330,6 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.25.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.0",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -2778,16 +2759,6 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "moxcms"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "muda" name = "muda"
version = "0.17.1" version = "0.17.1"
@@ -2803,7 +2774,7 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
"once_cell", "once_cell",
"png 0.17.16", "png",
"serde", "serde",
"thiserror 2.0.17", "thiserror 2.0.17",
"windows-sys 0.60.2", "windows-sys 0.60.2",
@@ -3592,19 +3563,6 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "png"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
dependencies = [
"bitflags 2.9.4",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@@ -3736,15 +3694,6 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "pxfm"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.37.5" version = "0.37.5"
@@ -5038,7 +4987,6 @@ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"http-range", "http-range",
"image",
"jni", "jni",
"libc", "libc",
"log", "log",
@@ -5107,7 +5055,7 @@ dependencies = [
"ico", "ico",
"json-patch", "json-patch",
"plist", "plist",
"png 0.17.16", "png",
"proc-macro2", "proc-macro2",
"quote", "quote",
"semver", "semver",
@@ -5862,7 +5810,7 @@ dependencies = [
"objc2-core-graphics", "objc2-core-graphics",
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
"once_cell", "once_cell",
"png 0.17.16", "png",
"serde", "serde",
"thiserror 2.0.17", "thiserror 2.0.17",
"windows-sys 0.59.0", "windows-sys 0.59.0",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cc-switch" name = "cc-switch"
version = "3.9.0-3" version = "3.8.2"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"] authors = ["Jason Young"]
license = "MIT" license = "MIT"
@@ -26,7 +26,7 @@ serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
log = "0.4" log = "0.4"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset", "image-png"] } tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] }
tauri-plugin-log = "2" tauri-plugin-log = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

+4 -71
View File
@@ -1,36 +1,16 @@
use crate::error::AppError; use crate::error::AppError;
use auto_launch::{AutoLaunch, AutoLaunchBuilder}; use auto_launch::{AutoLaunch, AutoLaunchBuilder};
/// 获取 macOS 上的 .app bundle 路径
/// 将 `/path/to/CC Switch.app/Contents/MacOS/CC Switch` 转换为 `/path/to/CC Switch.app`
#[cfg(target_os = "macos")]
fn get_macos_app_bundle_path(exe_path: &std::path::Path) -> Option<std::path::PathBuf> {
let path_str = exe_path.to_string_lossy();
// 查找 .app/Contents/MacOS/ 模式
if let Some(app_pos) = path_str.find(".app/Contents/MacOS/") {
let app_bundle_end = app_pos + 4; // ".app" 的结束位置
Some(std::path::PathBuf::from(&path_str[..app_bundle_end]))
} else {
None
}
}
/// 初始化 AutoLaunch 实例 /// 初始化 AutoLaunch 实例
fn get_auto_launch() -> Result<AutoLaunch, AppError> { fn get_auto_launch() -> Result<AutoLaunch, AppError> {
let app_name = "CC Switch"; let app_name = "CC Switch";
let exe_path = let app_path =
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?; std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
// macOS 需要使用 .app bundle 路径,否则 AppleScript login item 会打开终端
#[cfg(target_os = "macos")]
let app_path = get_macos_app_bundle_path(&exe_path).unwrap_or(exe_path);
#[cfg(not(target_os = "macos"))]
let app_path = exe_path;
// 使用 AutoLaunchBuilder 消除平台差异 // 使用 AutoLaunchBuilder 消除平台差异
// macOS: 使用 AppleScript 方式(默认),需要 .app bundle 路径 // Windows/Linux: new() 接受 3 参数
// Windows/Linux: 使用注册表/XDG autostart // macOS: new() 接受 4 参数(含 hidden 参数)
// Builder 模式自动处理这些差异
let auto_launch = AutoLaunchBuilder::new() let auto_launch = AutoLaunchBuilder::new()
.set_app_name(app_name) .set_app_name(app_name)
.set_app_path(&app_path.to_string_lossy()) .set_app_path(&app_path.to_string_lossy())
@@ -67,50 +47,3 @@ pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
.is_enabled() .is_enabled()
.map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}"))) .map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}")))
} }
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_valid() {
let exe_path = std::path::Path::new("/Applications/CC Switch.app/Contents/MacOS/CC Switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(
result,
Some(std::path::PathBuf::from("/Applications/CC Switch.app"))
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_with_spaces() {
let exe_path =
std::path::Path::new("/Users/test/My Apps/CC Switch.app/Contents/MacOS/CC Switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(
result,
Some(std::path::PathBuf::from(
"/Users/test/My Apps/CC Switch.app"
))
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_not_in_bundle() {
let exe_path = std::path::Path::new("/usr/local/bin/cc-switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(result, None);
}
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_dev_build() {
// 开发环境下的路径通常不在 .app bundle 内
let exe_path = std::path::Path::new("/Users/dev/project/target/debug/cc-switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(result, None);
}
}
-243
View File
@@ -7,64 +7,6 @@ use std::path::{Path, PathBuf};
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path}; use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
use crate::error::AppError; use crate::error::AppError;
/// 需要在 Windows 上用 cmd /c 包装的命令
/// 这些命令在 Windows 上实际是 .cmd 批处理文件,需要通过 cmd /c 来执行
#[cfg(windows)]
const WINDOWS_WRAP_COMMANDS: &[&str] = &["npx", "npm", "yarn", "pnpm", "node", "bun", "deno"];
/// Windows 平台:将 `npx args...` 转换为 `cmd /c npx args...`
/// 解决 Claude Code /doctor 报告的 "Windows requires 'cmd /c' wrapper to execute npx" 警告
#[cfg(windows)]
fn wrap_command_for_windows(obj: &mut Map<String, Value>) {
// 只处理 stdio 类型(默认或显式)
let server_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
if server_type != "stdio" {
return;
}
let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) else {
return;
};
// 已经是 cmd 的不重复包装
if cmd.eq_ignore_ascii_case("cmd") || cmd.eq_ignore_ascii_case("cmd.exe") {
return;
}
// 提取命令名(去掉 .cmd 后缀和路径)
let cmd_name = Path::new(cmd)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(cmd);
let needs_wrap = WINDOWS_WRAP_COMMANDS
.iter()
.any(|&c| cmd_name.eq_ignore_ascii_case(c));
if !needs_wrap {
return;
}
// 构建新的 args: ["/c", "原命令", ...原args]
let original_args = obj
.get("args")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut new_args = vec![Value::String("/c".into()), Value::String(cmd.into())];
new_args.extend(original_args);
obj.insert("command".into(), Value::String("cmd".into()));
obj.insert("args".into(), Value::Array(new_args));
}
/// 非 Windows 平台无需处理
#[cfg(not(windows))]
fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
// 非 Windows 平台不做任何处理
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct McpStatus { pub struct McpStatus {
@@ -163,55 +105,6 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
Ok(Some(content)) Ok(Some(content))
} }
/// 在 ~/.claude.json 根对象写入 hasCompletedOnboarding=true(用于跳过 Claude Code 初次安装确认)
/// 仅增量写入该字段,其他字段保持不变
pub fn set_has_completed_onboarding() -> Result<bool, AppError> {
let path = user_config_path();
let mut root = if path.exists() {
read_json_value(&path)?
} else {
serde_json::json!({})
};
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
let already = obj
.get("hasCompletedOnboarding")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if already {
return Ok(false);
}
obj.insert("hasCompletedOnboarding".into(), Value::Bool(true));
write_json_value(&path, &root)?;
Ok(true)
}
/// 删除 ~/.claude.json 根对象的 hasCompletedOnboarding 字段(恢复 Claude Code 初次安装确认)
/// 仅增量删除该字段,其他字段保持不变
pub fn clear_has_completed_onboarding() -> Result<bool, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(false);
}
let mut root = read_json_value(&path)?;
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
let existed = obj.remove("hasCompletedOnboarding").is_some();
if !existed {
return Ok(false);
}
write_json_value(&path, &root)?;
Ok(true)
}
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> { pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
if id.trim().is_empty() { if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into())); return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
@@ -397,9 +290,6 @@ pub fn set_mcp_servers_map(
obj.remove("homepage"); obj.remove("homepage");
obj.remove("docs"); obj.remove("docs");
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
wrap_command_for_windows(&mut obj);
out.insert(id.clone(), Value::Object(obj)); out.insert(id.clone(), Value::Object(obj));
} }
@@ -413,136 +303,3 @@ pub fn set_mcp_servers_map(
write_json_value(&path, &root)?; write_json_value(&path, &root)?;
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// 测试 Windows 命令包装功能
/// 由于使用条件编译,在非 Windows 平台上测试的是空函数
#[test]
fn test_wrap_command_for_windows_npx() {
let mut obj = json!({"command": "npx", "args": ["-y", "@upstash/context7-mcp"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(
obj["args"],
json!(["/c", "npx", "-y", "@upstash/context7-mcp"])
);
}
#[cfg(not(windows))]
{
// 非 Windows 平台不做任何处理
assert_eq!(obj["command"], "npx");
}
}
#[test]
fn test_wrap_command_for_windows_npm() {
let mut obj = json!({"command": "npm", "args": ["run", "start"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "npm", "run", "start"]));
}
}
#[test]
fn test_wrap_command_for_windows_already_cmd() {
// 已经是 cmd 的不应该重复包装
let mut obj = json!({"command": "cmd", "args": ["/c", "npx", "-y", "foo"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
assert_eq!(obj["command"], "cmd");
// args 应该保持不变,不会变成 ["/c", "cmd", "/c", "npx", ...]
assert_eq!(obj["args"], json!(["/c", "npx", "-y", "foo"]));
}
#[test]
fn test_wrap_command_for_windows_http_type_skipped() {
// http 类型不应该被处理
let mut obj = json!({"type": "http", "url": "https://example.com/mcp"})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
assert!(!obj.contains_key("command"));
assert_eq!(obj["url"], "https://example.com/mcp");
}
#[test]
fn test_wrap_command_for_windows_other_command_skipped() {
// 非目标命令(如 python)不应该被包装
let mut obj = json!({"command": "python", "args": ["server.py"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
// python 不在 WINDOWS_WRAP_COMMANDS 列表中,不应该被包装
assert_eq!(obj["command"], "python");
assert_eq!(obj["args"], json!(["server.py"]));
}
#[test]
fn test_wrap_command_for_windows_no_args() {
// 没有 args 的情况
let mut obj = json!({"command": "npx"}).as_object().unwrap().clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "npx"]));
}
}
#[test]
fn test_wrap_command_for_windows_with_cmd_suffix() {
// 处理 npx.cmd 格式
let mut obj = json!({"command": "npx.cmd", "args": ["-y", "foo"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "npx.cmd", "-y", "foo"]));
}
}
#[test]
fn test_wrap_command_for_windows_case_insensitive() {
// 大小写不敏感
let mut obj = json!({"command": "NPX", "args": ["-y", "foo"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
}
}
}
-102
View File
@@ -1,102 +0,0 @@
//! 故障转移队列命令
//!
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
use crate::database::FailoverQueueItem;
use crate::provider::Provider;
use crate::store::AppState;
/// 获取故障转移队列
#[tauri::command]
pub async fn get_failover_queue(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<Vec<FailoverQueueItem>, String> {
state
.db
.get_failover_queue(&app_type)
.map_err(|e| e.to_string())
}
/// 获取可添加到故障转移队列的供应商(不在队列中的)
#[tauri::command]
pub async fn get_available_providers_for_failover(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<Vec<Provider>, String> {
state
.db
.get_available_providers_for_failover(&app_type)
.map_err(|e| e.to_string())
}
/// 添加供应商到故障转移队列
#[tauri::command]
pub async fn add_to_failover_queue(
state: tauri::State<'_, AppState>,
app_type: String,
provider_id: String,
) -> Result<(), String> {
state
.db
.add_to_failover_queue(&app_type, &provider_id)
.map_err(|e| e.to_string())
}
/// 从故障转移队列移除供应商
#[tauri::command]
pub async fn remove_from_failover_queue(
state: tauri::State<'_, AppState>,
app_type: String,
provider_id: String,
) -> Result<(), String> {
state
.db
.remove_from_failover_queue(&app_type, &provider_id)
.map_err(|e| e.to_string())
}
/// 获取指定应用的自动故障转移开关状态(从 proxy_config 表读取)
#[tauri::command]
pub async fn get_auto_failover_enabled(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<bool, String> {
state
.db
.get_proxy_config_for_app(&app_type)
.await
.map(|config| config.auto_failover_enabled)
.map_err(|e| e.to_string())
}
/// 设置指定应用的自动故障转移开关状态(写入 proxy_config 表)
///
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
#[tauri::command]
pub async fn set_auto_failover_enabled(
state: tauri::State<'_, AppState>,
app_type: String,
enabled: bool,
) -> Result<(), String> {
log::info!(
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
);
// 读取当前配置
let mut config = state
.db
.get_proxy_config_for_app(&app_type)
.await
.map_err(|e| e.to_string())?;
// 更新 auto_failover_enabled 字段
config.auto_failover_enabled = enabled;
// 写回数据库
state
.db
.update_proxy_config_for_app(config)
.await
.map_err(|e| e.to_string())
}
-230
View File
@@ -4,12 +4,6 @@ use crate::init_status::InitErrorPayload;
use tauri::AppHandle; use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
/// 打开外部链接 /// 打开外部链接
#[tauri::command] #[tauri::command]
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> { pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
@@ -64,227 +58,3 @@ pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
pub async fn get_migration_result() -> Result<bool, String> { pub async fn get_migration_result() -> Result<bool, String> {
Ok(crate::init_status::take_migration_success()) Ok(crate::init_status::take_migration_success())
} }
#[derive(serde::Serialize)]
pub struct ToolVersion {
name: String,
version: Option<String>,
latest_version: Option<String>, // 新增字段:最新版本
error: Option<String>,
}
#[tauri::command]
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
let tools = vec!["claude", "codex", "gemini"];
let mut results = Vec::new();
// 用于获取远程版本的 client
let client = reqwest::Client::builder()
.user_agent("cc-switch/1.0")
.build()
.map_err(|e| e.to_string())?;
for tool in tools {
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
let (local_version, local_error) = {
// 先尝试直接执行
let direct_result = try_get_version(tool);
if direct_result.0.is_some() {
direct_result
} else {
// 扫描常见的 npm 全局安装路径
scan_cli_version(tool)
}
};
// 2. 获取远程最新版本
let latest_version = match tool {
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
_ => None,
};
results.push(ToolVersion {
name: tool.to_string(),
version: local_version,
latest_version,
error: local_error,
});
}
Ok(results)
}
/// Helper function to fetch latest version from npm registry
async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Option<String> {
let url = format!("https://registry.npmjs.org/{package}");
match client.get(&url).send().await {
Ok(resp) => {
if let Ok(json) = resp.json::<serde_json::Value>().await {
json.get("dist-tags")
.and_then(|tags| tags.get("latest"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else {
None
}
}
Err(_) => None,
}
}
/// 从版本输出中提取纯版本号
fn extract_version(raw: &str) -> String {
// 匹配 semver 格式: x.y.z 或 x.y.z-xxx
let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").unwrap();
re.find(raw)
.map(|m| m.as_str().to_string())
.unwrap_or_else(|| raw.to_string())
}
/// 尝试直接执行命令获取版本
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
use std::process::Command;
#[cfg(target_os = "windows")]
let output = {
Command::new("cmd")
.args(["/C", &format!("{tool} --version")])
.creation_flags(CREATE_NO_WINDOW)
.output()
};
#[cfg(not(target_os = "windows"))]
let output = {
Command::new("sh")
.arg("-c")
.arg(format!("{tool} --version"))
.output()
};
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() {
(None, Some("未安装或无法执行".to_string()))
} else {
(Some(extract_version(raw)), None)
}
} else {
let err = if stderr.is_empty() { stdout } else { stderr };
(
None,
Some(if err.is_empty() {
"未安装或无法执行".to_string()
} else {
err
}),
)
}
}
Err(e) => (None, Some(e.to_string())),
}
}
/// 扫描常见路径查找 CLI
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
use std::process::Command;
let home = dirs::home_dir().unwrap_or_default();
// 常见的 npm 全局安装路径
let mut search_paths: Vec<std::path::PathBuf> = vec![
home.join(".npm-global/bin"),
home.join(".local/bin"),
home.join("n/bin"), // n version manager
];
#[cfg(target_os = "macos")]
{
search_paths.push(std::path::PathBuf::from("/opt/homebrew/bin"));
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
}
#[cfg(target_os = "linux")]
{
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
search_paths.push(std::path::PathBuf::from("/usr/bin"));
}
#[cfg(target_os = "windows")]
{
if let Some(appdata) = dirs::data_dir() {
search_paths.push(appdata.join("npm"));
}
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
}
// 扫描 nvm 目录下的所有 node 版本
let nvm_base = home.join(".nvm/versions/node");
if nvm_base.exists() {
if let Ok(entries) = std::fs::read_dir(&nvm_base) {
for entry in entries.flatten() {
let bin_path = entry.path().join("bin");
if bin_path.exists() {
search_paths.push(bin_path);
}
}
}
}
// 在每个路径中查找工具
for path in &search_paths {
let tool_path = if cfg!(target_os = "windows") {
path.join(format!("{tool}.cmd"))
} else {
path.join(tool)
};
if tool_path.exists() {
// 构建 PATH 环境变量,确保 node 可被找到
let current_path = std::env::var("PATH").unwrap_or_default();
#[cfg(target_os = "windows")]
let new_path = format!("{};{}", path.display(), current_path);
#[cfg(not(target_os = "windows"))]
let new_path = format!("{}:{}", path.display(), current_path);
#[cfg(target_os = "windows")]
let output = {
// 使用 cmd /C 包装执行,确保子进程也在隐藏的控制台中运行
Command::new("cmd")
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
.env("PATH", &new_path)
.creation_flags(CREATE_NO_WINDOW)
.output()
};
#[cfg(not(target_os = "windows"))]
let output = {
Command::new(&tool_path)
.arg("--version")
.env("PATH", &new_path)
.output()
};
if let Ok(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if !raw.is_empty() {
return (Some(extract_version(raw)), None);
}
}
}
}
}
(None, Some("未安装或无法执行".to_string()))
}
+2 -4
View File
@@ -3,31 +3,29 @@
mod config; mod config;
mod deeplink; mod deeplink;
mod env; mod env;
mod failover;
mod import_export; mod import_export;
mod mcp; mod mcp;
mod misc; mod misc;
mod model_test;
mod plugin; mod plugin;
mod prompt; mod prompt;
mod provider; mod provider;
mod proxy; mod proxy;
mod settings; mod settings;
pub mod skill; pub mod skill;
mod stream_check;
mod usage; mod usage;
pub use config::*; pub use config::*;
pub use deeplink::*; pub use deeplink::*;
pub use env::*; pub use env::*;
pub use failover::*;
pub use import_export::*; pub use import_export::*;
pub use mcp::*; pub use mcp::*;
pub use misc::*; pub use misc::*;
pub use model_test::*;
pub use plugin::*; pub use plugin::*;
pub use prompt::*; pub use prompt::*;
pub use provider::*; pub use provider::*;
pub use proxy::*; pub use proxy::*;
pub use settings::*; pub use settings::*;
pub use skill::*; pub use skill::*;
pub use stream_check::*;
pub use usage::*; pub use usage::*;
+128
View File
@@ -0,0 +1,128 @@
//! 模型测试相关命令
use crate::app_config::AppType;
use crate::error::AppError;
use crate::services::model_test::{
ModelTestConfig, ModelTestLog, ModelTestResult, ModelTestService,
};
use crate::store::AppState;
use tauri::State;
/// 测试单个供应商的模型可用性
#[tauri::command]
pub async fn test_provider_model(
state: State<'_, AppState>,
app_type: AppType,
provider_id: String,
) -> Result<ModelTestResult, AppError> {
// 获取测试配置
let config = state.db.get_model_test_config()?;
// 获取供应商
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers
.get(&provider_id)
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
// 执行测试
let result = ModelTestService::test_provider(&app_type, provider, &config).await?;
// 记录日志
let _ = state.db.save_model_test_log(
&provider_id,
&provider.name,
app_type.as_str(),
&result.model_used,
&config.test_prompt,
&result,
);
Ok(result)
}
/// 批量测试所有供应商
#[tauri::command]
pub async fn test_all_providers_model(
state: State<'_, AppState>,
app_type: AppType,
proxy_targets_only: bool,
) -> Result<Vec<(String, ModelTestResult)>, AppError> {
let config = state.db.get_model_test_config()?;
let providers = state.db.get_all_providers(app_type.as_str())?;
let mut results = Vec::new();
for (id, provider) in providers {
// 如果只测试代理目标,跳过非代理目标
if proxy_targets_only && !provider.is_proxy_target.unwrap_or(false) {
continue;
}
match ModelTestService::test_provider(&app_type, &provider, &config).await {
Ok(result) => {
// 记录日志
let _ = state.db.save_model_test_log(
&id,
&provider.name,
app_type.as_str(),
&result.model_used,
&config.test_prompt,
&result,
);
results.push((id, result));
}
Err(e) => {
let error_result = ModelTestResult {
success: false,
message: e.to_string(),
response_time_ms: None,
http_status: None,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
};
results.push((id, error_result));
}
}
}
Ok(results)
}
/// 获取模型测试配置
#[tauri::command]
pub fn get_model_test_config(state: State<'_, AppState>) -> Result<ModelTestConfig, AppError> {
state.db.get_model_test_config()
}
/// 保存模型测试配置
#[tauri::command]
pub fn save_model_test_config(
state: State<'_, AppState>,
config: ModelTestConfig,
) -> Result<(), AppError> {
state.db.save_model_test_config(&config)
}
/// 获取模型测试日志
#[tauri::command]
pub fn get_model_test_logs(
state: State<'_, AppState>,
app_type: Option<String>,
provider_id: Option<String>,
limit: Option<u32>,
) -> Result<Vec<ModelTestLog>, AppError> {
state.db.get_model_test_logs(
app_type.as_deref(),
provider_id.as_deref(),
limit.unwrap_or(50),
)
}
/// 清理旧的测试日志
#[tauri::command]
pub fn cleanup_model_test_logs(
state: State<'_, AppState>,
keep_count: Option<u32>,
) -> Result<u64, AppError> {
state.db.cleanup_model_test_logs(keep_count.unwrap_or(100))
}
-12
View File
@@ -34,15 +34,3 @@ pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String>
pub async fn is_claude_plugin_applied() -> Result<bool, String> { pub async fn is_claude_plugin_applied() -> Result<bool, String> {
crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string()) crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())
} }
/// Claude Code:跳过初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding=true
#[tauri::command]
pub async fn apply_claude_onboarding_skip() -> Result<bool, String> {
crate::claude_mcp::set_has_completed_onboarding().map_err(|e| e.to_string())
}
/// Claude Code:恢复初次安装确认(删除 ~/.claude.json 的 hasCompletedOnboarding 字段)
#[tauri::command]
pub async fn clear_claude_onboarding_skip() -> Result<bool, String> {
crate::claude_mcp::clear_has_completed_onboarding().map_err(|e| e.to_string())
}
+13 -94
View File
@@ -86,6 +86,19 @@ pub fn switch_provider(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// 设置代理目标供应商
#[tauri::command]
pub fn set_proxy_target_provider(
state: State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::set_proxy_target(state.inner(), app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> { fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
ProviderService::import_default_config(state, app_type) ProviderService::import_default_config(state, app_type)
} }
@@ -229,97 +242,3 @@ pub fn update_providers_sort_order(
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string()) ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
} }
// ============================================================================
// 统一供应商(Universal Provider)命令
// ============================================================================
use crate::provider::UniversalProvider;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
/// 统一供应商同步完成事件的 payload
#[derive(Clone, serde::Serialize)]
pub struct UniversalProviderSyncedEvent {
/// 操作类型: "upsert" | "delete" | "sync"
pub action: String,
/// 统一供应商 ID
pub id: String,
}
/// 发送统一供应商同步事件,通知前端刷新供应商列表
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
let _ = app.emit(
"universal-provider-synced",
UniversalProviderSyncedEvent {
action: action.to_string(),
id: id.to_string(),
},
);
}
/// 获取所有统一供应商
#[tauri::command]
pub fn get_universal_providers(
state: State<'_, AppState>,
) -> Result<HashMap<String, UniversalProvider>, String> {
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
}
/// 获取单个统一供应商
#[tauri::command]
pub fn get_universal_provider(
state: State<'_, AppState>,
id: String,
) -> Result<Option<UniversalProvider>, String> {
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
}
/// 添加或更新统一供应商
#[tauri::command]
pub fn upsert_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
provider: UniversalProvider,
) -> Result<bool, String> {
let id = provider.id.clone();
let result =
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "upsert", &id);
Ok(result)
}
/// 删除统一供应商
#[tauri::command]
pub fn delete_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let result =
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "delete", &id);
Ok(result)
}
/// 同步统一供应商到各应用(手动触发)
#[tauri::command]
pub fn sync_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let result =
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "sync", &id);
Ok(result)
}
+4 -251
View File
@@ -3,10 +3,9 @@
//! 提供前端调用的 API 接口 //! 提供前端调用的 API 接口
use crate::proxy::types::*; use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState; use crate::store::AppState;
/// 启动代理服务器(仅启动服务,不接管 Live 配置) /// 启动代理服务器
#[tauri::command] #[tauri::command]
pub async fn start_proxy_server( pub async fn start_proxy_server(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
@@ -14,31 +13,10 @@ pub async fn start_proxy_server(
state.proxy_service.start().await state.proxy_service.start().await
} }
/// 停止代理服务器(恢复 Live 配置) /// 停止代理服务器
#[tauri::command] #[tauri::command]
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> { pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), String> {
state.proxy_service.stop_with_restore().await state.proxy_service.stop().await
}
/// 获取各应用接管状态
#[tauri::command]
pub async fn get_proxy_takeover_status(
state: tauri::State<'_, AppState>,
) -> Result<ProxyTakeoverStatus, String> {
state.proxy_service.get_takeover_status().await
}
/// 为指定应用开启/关闭接管
#[tauri::command]
pub async fn set_proxy_takeover_for_app(
state: tauri::State<'_, AppState>,
app_type: String,
enabled: bool,
) -> Result<(), String> {
state
.proxy_service
.set_takeover_for_app(&app_type, enabled)
.await
} }
/// 获取代理服务器状态 /// 获取代理服务器状态
@@ -62,233 +40,8 @@ pub async fn update_proxy_config(
state.proxy_service.update_config(&config).await state.proxy_service.update_config(&config).await
} }
// ==================== Global & Per-App Config ====================
/// 获取全局代理配置
///
/// 返回统一的全局配置字段(代理开关、监听地址、端口、日志开关)
#[tauri::command]
pub async fn get_global_proxy_config(
state: tauri::State<'_, AppState>,
) -> Result<GlobalProxyConfig, String> {
let db = &state.db;
db.get_global_proxy_config()
.await
.map_err(|e| e.to_string())
}
/// 更新全局代理配置
///
/// 更新统一的全局配置字段,会同时更新三行(claude/codex/gemini
#[tauri::command]
pub async fn update_global_proxy_config(
state: tauri::State<'_, AppState>,
config: GlobalProxyConfig,
) -> Result<(), String> {
let db = &state.db;
db.update_global_proxy_config(config)
.await
.map_err(|e| e.to_string())
}
/// 获取指定应用的代理配置
///
/// 返回应用级配置(enabled、auto_failover、超时、熔断器等)
#[tauri::command]
pub async fn get_proxy_config_for_app(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<AppProxyConfig, String> {
let db = &state.db;
db.get_proxy_config_for_app(&app_type)
.await
.map_err(|e| e.to_string())
}
/// 更新指定应用的代理配置
///
/// 更新应用级配置(enabled、auto_failover、超时、熔断器等)
#[tauri::command]
pub async fn update_proxy_config_for_app(
state: tauri::State<'_, AppState>,
config: AppProxyConfig,
) -> Result<(), String> {
let db = &state.db;
db.update_proxy_config_for_app(config)
.await
.map_err(|e| e.to_string())
}
/// 检查代理服务器是否正在运行 /// 检查代理服务器是否正在运行
#[tauri::command] #[tauri::command]
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> { pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
Ok(state.proxy_service.is_running().await) Ok(state.proxy_service.is_running().await)
} }
/// 检查是否处于 Live 接管模式
#[tauri::command]
pub async fn is_live_takeover_active(state: tauri::State<'_, AppState>) -> Result<bool, String> {
state.proxy_service.is_takeover_active().await
}
/// 代理模式下切换供应商(热切换)
#[tauri::command]
pub async fn switch_proxy_provider(
state: tauri::State<'_, AppState>,
app_type: String,
provider_id: String,
) -> Result<(), String> {
state
.proxy_service
.switch_proxy_target(&app_type, &provider_id)
.await
}
// ==================== 故障转移相关命令 ====================
/// 获取供应商健康状态
#[tauri::command]
pub async fn get_provider_health(
state: tauri::State<'_, AppState>,
provider_id: String,
app_type: String,
) -> Result<ProviderHealth, String> {
let db = &state.db;
db.get_provider_health(&provider_id, &app_type)
.await
.map_err(|e| e.to_string())
}
/// 重置熔断器
///
/// 重置后会检查是否应该切回队列中优先级更高的供应商:
/// 1. 检查自动故障转移是否开启
/// 2. 如果恢复的供应商在队列中优先级更高(queue_order 更小),则自动切换
#[tauri::command]
pub async fn reset_circuit_breaker(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
provider_id: String,
app_type: String,
) -> Result<(), String> {
// 1. 重置数据库健康状态
let db = &state.db;
db.update_provider_health(&provider_id, &app_type, true, None)
.await
.map_err(|e| e.to_string())?;
// 2. 如果代理正在运行,重置内存中的熔断器状态
state
.proxy_service
.reset_provider_circuit_breaker(&provider_id, &app_type)
.await?;
// 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取)
// 只有当该应用已被代理接管(enabled=true)且开启了自动故障转移时才执行
let (app_enabled, auto_failover_enabled) = match db.get_proxy_config_for_app(&app_type).await {
Ok(config) => (config.enabled, config.auto_failover_enabled),
Err(e) => {
log::error!("[{app_type}] Failed to read proxy_config: {e}, defaulting to disabled");
(false, false)
}
};
if app_enabled && auto_failover_enabled && state.proxy_service.is_running().await {
// 获取当前供应商 ID
let current_id = db
.get_current_provider(&app_type)
.map_err(|e| e.to_string())?;
if let Some(current_id) = current_id {
// 获取故障转移队列
let queue = db
.get_failover_queue(&app_type)
.map_err(|e| e.to_string())?;
// 找到恢复的供应商和当前供应商在队列中的位置(使用 sort_index
let restored_order = queue
.iter()
.find(|item| item.provider_id == provider_id)
.and_then(|item| item.sort_index);
let current_order = queue
.iter()
.find(|item| item.provider_id == current_id)
.and_then(|item| item.sort_index);
// 如果恢复的供应商优先级更高(sort_index 更小),则切换
if let (Some(restored), Some(current)) = (restored_order, current_order) {
if restored < current {
log::info!(
"[Recovery] 供应商 {provider_id} 已恢复且优先级更高 (P{restored} vs P{current}),自动切换"
);
// 获取供应商名称用于日志和事件
let provider_name = db
.get_all_providers(&app_type)
.ok()
.and_then(|providers| providers.get(&provider_id).map(|p| p.name.clone()))
.unwrap_or_else(|| provider_id.clone());
// 创建故障转移切换管理器并执行切换
let switch_manager =
crate::proxy::failover_switch::FailoverSwitchManager::new(db.clone());
if let Err(e) = switch_manager
.try_switch(Some(&app_handle), &app_type, &provider_id, &provider_name)
.await
{
log::error!("[Recovery] 自动切换失败: {e}");
}
}
}
}
}
Ok(())
}
/// 获取熔断器配置
#[tauri::command]
pub async fn get_circuit_breaker_config(
state: tauri::State<'_, AppState>,
) -> Result<CircuitBreakerConfig, String> {
let db = &state.db;
db.get_circuit_breaker_config()
.await
.map_err(|e| e.to_string())
}
/// 更新熔断器配置
#[tauri::command]
pub async fn update_circuit_breaker_config(
state: tauri::State<'_, AppState>,
config: CircuitBreakerConfig,
) -> Result<(), String> {
let db = &state.db;
// 1. 更新数据库配置
db.update_circuit_breaker_config(&config)
.await
.map_err(|e| e.to_string())?;
// 2. 如果代理正在运行,热更新内存中的熔断器配置
state
.proxy_service
.update_circuit_breaker_configs(config)
.await?;
Ok(())
}
/// 获取熔断器统计信息(仅当代理服务器运行时)
#[tauri::command]
pub async fn get_circuit_breaker_stats(
state: tauri::State<'_, AppState>,
provider_id: String,
app_type: String,
) -> Result<Option<CircuitBreakerStats>, String> {
// 这个功能需要访问运行中的代理服务器的内存状态
// 目前先返回 None,后续可以通过 ProxyService 暴露接口来实现
let _ = (state, provider_id, app_type);
Ok(None)
}
-106
View File
@@ -1,106 +0,0 @@
//! 流式健康检查命令
use crate::app_config::AppType;
use crate::error::AppError;
use crate::services::stream_check::{
HealthStatus, StreamCheckConfig, StreamCheckResult, StreamCheckService,
};
use crate::store::AppState;
use std::collections::HashSet;
use tauri::State;
/// 流式健康检查(单个供应商)
#[tauri::command]
pub async fn stream_check_provider(
state: State<'_, AppState>,
app_type: AppType,
provider_id: String,
) -> Result<StreamCheckResult, AppError> {
let config = state.db.get_stream_check_config()?;
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers
.get(&provider_id)
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
let result = StreamCheckService::check_with_retry(&app_type, provider, &config).await?;
// 记录日志
let _ =
state
.db
.save_stream_check_log(&provider_id, &provider.name, app_type.as_str(), &result);
Ok(result)
}
/// 批量流式健康检查
#[tauri::command]
pub async fn stream_check_all_providers(
state: State<'_, AppState>,
app_type: AppType,
proxy_targets_only: bool,
) -> Result<Vec<(String, StreamCheckResult)>, AppError> {
let config = state.db.get_stream_check_config()?;
let providers = state.db.get_all_providers(app_type.as_str())?;
let mut results = Vec::new();
let allowed_ids: Option<HashSet<String>> = if proxy_targets_only {
let mut ids = HashSet::new();
if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) {
ids.insert(current_id);
}
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
for item in queue {
ids.insert(item.provider_id);
}
}
Some(ids)
} else {
None
};
for (id, provider) in providers {
if let Some(ids) = &allowed_ids {
if !ids.contains(&id) {
continue;
}
}
let result = StreamCheckService::check_with_retry(&app_type, &provider, &config)
.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,
});
let _ = state
.db
.save_stream_check_log(&id, &provider.name, app_type.as_str(), &result);
results.push((id, result));
}
Ok(results)
}
/// 获取流式检查配置
#[tauri::command]
pub fn get_stream_check_config(state: State<'_, AppState>) -> Result<StreamCheckConfig, AppError> {
state.db.get_stream_check_config()
}
/// 保存流式检查配置
#[tauri::command]
pub fn save_stream_check_config(
state: State<'_, AppState>,
config: StreamCheckConfig,
) -> Result<(), AppError> {
state.db.save_stream_check_config(&config)
}
+2 -3
View File
@@ -19,10 +19,9 @@ pub fn get_usage_summary(
#[tauri::command] #[tauri::command]
pub fn get_usage_trends( pub fn get_usage_trends(
state: State<'_, AppState>, state: State<'_, AppState>,
start_date: Option<i64>, days: u32,
end_date: Option<i64>,
) -> Result<Vec<DailyStats>, AppError> { ) -> Result<Vec<DailyStats>, AppError> {
state.db.get_daily_trends(start_date, end_date) state.db.get_daily_trends(days)
} }
/// 获取 Provider 统计 /// 获取 Provider 统计
+22 -23
View File
@@ -13,8 +13,6 @@ use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
impl Database { impl Database {
/// 导出为 SQLite 兼容的 SQL 文本 /// 导出为 SQLite 兼容的 SQL 文本
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> { pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
@@ -38,8 +36,7 @@ impl Database {
} }
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?; let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
let sql_content = sql_raw.trim_start_matches('\u{feff}'); let sql_content = Self::sanitize_import_sql(&sql_raw);
Self::validate_cc_switch_sql_export(sql_content)?;
// 导入前备份现有数据库 // 导入前备份现有数据库
let backup_path = self.backup_database_file()?; let backup_path = self.backup_database_file()?;
@@ -54,7 +51,7 @@ impl Database {
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?; Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
temp_conn temp_conn
.execute_batch(sql_content) .execute_batch(&sql_content)
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?; .map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
// 补齐缺失表/索引并进行基础校验 // 补齐缺失表/索引并进行基础校验
@@ -96,17 +93,26 @@ impl Database {
Ok(snapshot) Ok(snapshot)
} }
fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> { /// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错
let trimmed = sql.trim_start(); fn sanitize_import_sql(sql: &str) -> String {
if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) { let mut cleaned = String::new();
return Ok(()); let lower_keyword = "sqlite_sequence";
for stmt in sql.split(';') {
let trimmed = stmt.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.to_ascii_lowercase().contains(lower_keyword) {
continue;
}
cleaned.push_str(trimmed);
cleaned.push_str(";\n");
} }
Err(AppError::localized( cleaned
"backup.sql.invalid_format",
"仅支持导入由 CC Switch 导出的 SQL 备份文件。",
"Only SQL backups exported by CC Switch are supported.",
))
} }
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None) /// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
@@ -123,15 +129,8 @@ impl Database {
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); let backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
let mut backup_id = base_id.clone(); let backup_path = backup_dir.join(format!("{backup_id}.db"));
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
let mut counter = 1;
while backup_path.exists() {
backup_id = format!("{base_id}_{counter}");
backup_path = backup_dir.join(format!("{backup_id}.db"));
counter += 1;
}
{ {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
-146
View File
@@ -1,146 +0,0 @@
//! 故障转移队列 DAO
//!
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::provider::Provider;
use serde::{Deserialize, Serialize};
/// 故障转移队列条目(简化版,用于前端展示)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FailoverQueueItem {
pub provider_id: String,
pub provider_name: String,
pub sort_index: Option<usize>,
}
impl Database {
/// 获取故障转移队列(按 sort_index 排序)
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, name, sort_index
FROM providers
WHERE app_type = ?1 AND in_failover_queue = 1
ORDER BY COALESCE(sort_index, 999999), id ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let items = stmt
.query_map([app_type], |row| {
Ok(FailoverQueueItem {
provider_id: row.get(0)?,
provider_name: row.get(1)?,
sort_index: row.get(2)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(items)
}
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
let all_providers = self.get_all_providers(app_type)?;
let result: Vec<Provider> = all_providers
.into_values()
.filter(|p| p.in_failover_queue)
.collect();
Ok(result)
}
/// 添加供应商到故障转移队列
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE providers SET in_failover_queue = 1 WHERE id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 从故障转移队列中移除供应商
pub fn remove_from_failover_queue(
&self,
app_type: &str,
provider_id: &str,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 1. 从队列中移除
conn.execute(
"UPDATE providers SET in_failover_queue = 0 WHERE id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 2. 清除该供应商的健康状态(退出队列后不再需要健康监控)
conn.execute(
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::info!("已从故障转移队列移除供应商 {provider_id} ({app_type}), 并清除其健康状态");
Ok(())
}
/// 清空故障转移队列
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE providers SET in_failover_queue = 0 WHERE app_type = ?1",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 检查供应商是否在故障转移队列中
pub fn is_in_failover_queue(
&self,
app_type: &str,
provider_id: &str,
) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let in_queue: bool = conn
.query_row(
"SELECT in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
|row| row.get(0),
)
.unwrap_or(false);
Ok(in_queue)
}
/// 获取可添加到故障转移队列的供应商(不在队列中的)
pub fn get_available_providers_for_failover(
&self,
app_type: &str,
) -> Result<Vec<Provider>, AppError> {
let all_providers = self.get_all_providers(app_type)?;
let available: Vec<Provider> = all_providers
.into_values()
.filter(|p| !p.in_failover_queue)
.collect();
Ok(available)
}
}
-5
View File
@@ -2,16 +2,11 @@
//! //!
//! Database access operations for each domain //! Database access operations for each domain
pub mod failover;
pub mod mcp; pub mod mcp;
pub mod prompts; pub mod prompts;
pub mod providers; pub mod providers;
pub mod proxy; pub mod proxy;
pub mod settings; pub mod settings;
pub mod skills; pub mod skills;
pub mod stream_check;
pub mod universal_providers;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出 // 所有 DAO 方法都通过 Database impl 提供,无需单独导出
// 导出 FailoverQueueItem 供外部使用
pub use failover::FailoverQueueItem;
+69 -29
View File
@@ -17,7 +17,7 @@ impl Database {
) -> Result<IndexMap<String, Provider>, AppError> { ) -> Result<IndexMap<String, Provider>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue "SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, is_proxy_target
FROM providers WHERE app_type = ?1 FROM providers WHERE app_type = ?1
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC" ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?; ).map_err(|e| AppError::Database(e.to_string()))?;
@@ -35,7 +35,7 @@ impl Database {
let icon: Option<String> = row.get(8)?; let icon: Option<String> = row.get(8)?;
let icon_color: Option<String> = row.get(9)?; let icon_color: Option<String> = row.get(9)?;
let meta_str: String = row.get(10)?; let meta_str: String = row.get(10)?;
let in_failover_queue: bool = row.get(11)?; let is_proxy_target: bool = row.get(11)?;
let settings_config = let settings_config =
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null); serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
@@ -55,7 +55,7 @@ impl Database {
meta: Some(meta), meta: Some(meta),
icon, icon,
icon_color, icon_color,
in_failover_queue, is_proxy_target: Some(is_proxy_target),
}, },
)) ))
}) })
@@ -131,7 +131,7 @@ impl Database {
) -> Result<Option<Provider>, AppError> { ) -> Result<Option<Provider>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let result = conn.query_row( let result = conn.query_row(
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue "SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, is_proxy_target
FROM providers WHERE id = ?1 AND app_type = ?2", FROM providers WHERE id = ?1 AND app_type = ?2",
params![id, app_type], params![id, app_type],
|row| { |row| {
@@ -145,7 +145,7 @@ impl Database {
let icon: Option<String> = row.get(7)?; let icon: Option<String> = row.get(7)?;
let icon_color: Option<String> = row.get(8)?; let icon_color: Option<String> = row.get(8)?;
let meta_str: String = row.get(9)?; let meta_str: String = row.get(9)?;
let in_failover_queue: bool = row.get(10)?; let is_proxy_target: bool = row.get(10)?;
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null); let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default(); let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
@@ -162,7 +162,7 @@ impl Database {
meta: Some(meta), meta: Some(meta),
icon, icon,
icon_color, icon_color,
in_failover_queue, is_proxy_target: Some(is_proxy_target),
}) })
}, },
); );
@@ -174,6 +174,26 @@ impl Database {
} }
} }
/// 获取代理目标供应商 ID
pub fn get_proxy_target_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_proxy_target = 1 LIMIT 1")
.map_err(|e| AppError::Database(e.to_string()))?;
let mut rows = stmt
.query(params![app_type])
.map_err(|e| AppError::Database(e.to_string()))?;
if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
Ok(Some(
row.get(0).map_err(|e| AppError::Database(e.to_string()))?,
))
} else {
Ok(None)
}
}
/// 保存供应商(新增或更新) /// 保存供应商(新增或更新)
/// ///
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理 /// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
@@ -188,18 +208,17 @@ impl Database {
let mut meta_clone = provider.meta.clone().unwrap_or_default(); let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints); let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue // 检查是否存在(用于判断新增/更新,以及保留 is_current 和 is_proxy_target
let existing: Option<(bool, bool)> = tx let existing: Option<(bool, bool)> = tx
.query_row( .query_row(
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2", "SELECT is_current, is_proxy_target FROM providers WHERE id = ?1 AND app_type = ?2",
params![provider.id, app_type], params![provider.id, app_type],
|row| Ok((row.get(0)?, row.get(1)?)), |row| Ok((row.get(0)?, row.get(1)?)),
) )
.ok(); .ok();
let is_update = existing.is_some(); let is_update = existing.is_some();
let (is_current, in_failover_queue) = let (is_current, is_proxy_target) = existing.unwrap_or((false, false));
existing.unwrap_or((false, provider.in_failover_queue));
if is_update { if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE // 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
@@ -216,7 +235,7 @@ impl Database {
icon_color = ?9, icon_color = ?9,
meta = ?10, meta = ?10,
is_current = ?11, is_current = ?11,
in_failover_queue = ?12 is_proxy_target = ?12
WHERE id = ?13 AND app_type = ?14", WHERE id = ?13 AND app_type = ?14",
params![ params![
provider.name, provider.name,
@@ -230,7 +249,7 @@ impl Database {
provider.icon_color, provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(), serde_json::to_string(&meta_clone).unwrap(),
is_current, is_current,
in_failover_queue, is_proxy_target,
provider.id, provider.id,
app_type, app_type,
], ],
@@ -241,7 +260,7 @@ impl Database {
tx.execute( tx.execute(
"INSERT INTO providers ( "INSERT INTO providers (
id, app_type, name, settings_config, website_url, category, id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue created_at, sort_index, notes, icon, icon_color, meta, is_current, is_proxy_target
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![ params![
provider.id, provider.id,
@@ -257,7 +276,7 @@ impl Database {
provider.icon_color, provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(), serde_json::to_string(&meta_clone).unwrap(),
is_current, is_current,
in_failover_queue, is_proxy_target,
], ],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -313,26 +332,47 @@ impl Database {
Ok(()) Ok(())
} }
/// 更新供应商的 settings_config(仅更新配置,不改变其他字段) /// 设置代理目标供应商
pub fn update_provider_settings_config( pub fn set_proxy_target_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
&self, let mut conn = lock_conn!(self.conn);
app_type: &str, let tx = conn
provider_id: &str, .transaction()
settings_config: &serde_json::Value, .map_err(|e| AppError::Database(e.to_string()))?;
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); // 重置所有为 0
conn.execute( tx.execute(
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3", "UPDATE providers SET is_proxy_target = 0 WHERE app_type = ?1",
params![ params![app_type],
serde_json::to_string(settings_config).unwrap(),
provider_id,
app_type
],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
// 设置新的代理目标供应商
tx.execute(
"UPDATE providers SET is_proxy_target = 1 WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
Ok(()) Ok(())
} }
/// 获取所有活跃的代理目标
pub fn get_all_proxy_targets(&self) -> Result<Vec<(String, String, String)>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT app_type, name, id FROM providers WHERE is_proxy_target = 1")
.map_err(|e| AppError::Database(e.to_string()))?;
let targets = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.map_err(|e| AppError::Database(e.to_string()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(targets)
}
/// 添加自定义端点 /// 添加自定义端点
pub fn add_custom_endpoint( pub fn add_custom_endpoint(
&self, &self,
+107 -480
View File
@@ -8,254 +8,61 @@ use crate::proxy::types::*;
use super::super::{lock_conn, Database}; use super::super::{lock_conn, Database};
impl Database { impl Database {
// ==================== Global Proxy Config ==================== // ==================== Proxy Config ====================
/// 获取全局代理配置(统一字段) /// 获取代理配置
///
/// 从 claude 行读取(三行镜像一致)
pub async fn get_global_proxy_config(&self) -> Result<GlobalProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT proxy_enabled, listen_address, listen_port, enable_logging
FROM proxy_config WHERE app_type = 'claude'",
[],
|row| {
Ok(GlobalProxyConfig {
proxy_enabled: row.get::<_, i32>(0)? != 0,
listen_address: row.get(1)?,
listen_port: row.get::<_, i32>(2)? as u16,
enable_logging: row.get::<_, i32>(3)? != 0,
})
},
)
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,创建默认配置
self.init_proxy_config_rows().await?;
Ok(GlobalProxyConfig {
proxy_enabled: false,
listen_address: "127.0.0.1".to_string(),
listen_port: 5000,
enable_logging: true,
})
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新全局代理配置(镜像写三行)
pub async fn update_global_proxy_config(
&self,
config: GlobalProxyConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE proxy_config SET
proxy_enabled = ?1,
listen_address = ?2,
listen_port = ?3,
enable_logging = ?4,
updated_at = datetime('now')",
rusqlite::params![
if config.proxy_enabled { 1 } else { 0 },
config.listen_address,
config.listen_port as i32,
if config.enable_logging { 1 } else { 0 },
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 获取应用级代理配置
pub async fn get_proxy_config_for_app(
&self,
app_type: &str,
) -> Result<AppProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let app_type_owned = app_type.to_string();
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT app_type, enabled, auto_failover_enabled,
max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
FROM proxy_config WHERE app_type = ?1",
[app_type],
|row| {
Ok(AppProxyConfig {
app_type: row.get(0)?,
enabled: row.get::<_, i32>(1)? != 0,
auto_failover_enabled: row.get::<_, i32>(2)? != 0,
max_retries: row.get::<_, i32>(3)? as u32,
streaming_first_byte_timeout: row.get::<_, i32>(4)? as u32,
streaming_idle_timeout: row.get::<_, i32>(5)? as u32,
non_streaming_timeout: row.get::<_, i32>(6)? as u32,
circuit_failure_threshold: row.get::<_, i32>(7)? as u32,
circuit_success_threshold: row.get::<_, i32>(8)? as u32,
circuit_timeout_seconds: row.get::<_, i32>(9)? as u32,
circuit_error_rate_threshold: row.get(10)?,
circuit_min_requests: row.get::<_, i32>(11)? as u32,
})
},
)
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,创建默认配置
self.init_proxy_config_rows().await?;
Ok(AppProxyConfig {
app_type: app_type_owned,
enabled: false,
auto_failover_enabled: false,
max_retries: 3,
streaming_first_byte_timeout: 30,
streaming_idle_timeout: 60,
non_streaming_timeout: 300,
circuit_failure_threshold: 5,
circuit_success_threshold: 2,
circuit_timeout_seconds: 60,
circuit_error_rate_threshold: 0.5,
circuit_min_requests: 10,
})
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新应用级代理配置
pub async fn update_proxy_config_for_app(
&self,
config: AppProxyConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE proxy_config SET
enabled = ?2,
auto_failover_enabled = ?3,
max_retries = ?4,
streaming_first_byte_timeout = ?5,
streaming_idle_timeout = ?6,
non_streaming_timeout = ?7,
circuit_failure_threshold = ?8,
circuit_success_threshold = ?9,
circuit_timeout_seconds = ?10,
circuit_error_rate_threshold = ?11,
circuit_min_requests = ?12,
updated_at = datetime('now')
WHERE app_type = ?1",
rusqlite::params![
config.app_type,
if config.enabled { 1 } else { 0 },
if config.auto_failover_enabled { 1 } else { 0 },
config.max_retries as i32,
config.streaming_first_byte_timeout as i32,
config.streaming_idle_timeout as i32,
config.non_streaming_timeout as i32,
config.circuit_failure_threshold as i32,
config.circuit_success_threshold as i32,
config.circuit_timeout_seconds as i32,
config.circuit_error_rate_threshold,
config.circuit_min_requests as i32,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 初始化 proxy_config 表的三行数据
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
for app_type in &["claude", "codex", "gemini"] {
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
}
// ==================== Legacy Proxy Config (兼容旧代码) ====================
/// 获取代理配置(兼容旧接口,返回 claude 行的配置)
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> { pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁 // 在一个作用域内获取锁并查询,确保锁在await之前释放
let result = { let result = {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
conn.query_row( conn.query_row(
"SELECT listen_address, listen_port, max_retries, "SELECT enabled, listen_address, listen_port, max_retries,
enable_logging, request_timeout, enable_logging
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout FROM proxy_config WHERE id = 1",
FROM proxy_config WHERE app_type = 'claude'",
[], [],
|row| { |row| {
Ok(ProxyConfig { Ok(ProxyConfig {
listen_address: row.get(0)?, enabled: row.get::<_, i32>(0)? != 0,
listen_port: row.get::<_, i32>(1)? as u16, listen_address: row.get(1)?,
max_retries: row.get::<_, i32>(2)? as u8, listen_port: row.get::<_, i32>(2)? as u16,
request_timeout: 300, // 废弃字段,返回默认值 max_retries: row.get::<_, i32>(3)? as u8,
enable_logging: row.get::<_, i32>(3)? != 0, request_timeout: row.get::<_, i32>(4)? as u64,
live_takeover_active: false, // 废弃字段 enable_logging: row.get::<_, i32>(5)? != 0,
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(30) as u64,
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(60) as u64,
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(300) as u64,
}) })
}, },
) )
}; }; // conn锁在这里释放
// conn 已在 block 结束时释放
match result { match result {
Ok(config) => Ok(config), Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => { Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,初始化默认配置 // 如果不存在,插入默认配置
self.init_proxy_config_rows().await?; let default_config = ProxyConfig::default();
Ok(ProxyConfig::default()) self.update_proxy_config(default_config.clone()).await?;
Ok(default_config)
} }
Err(e) => Err(AppError::Database(e.to_string())), Err(e) => Err(AppError::Database(e.to_string())),
} }
} }
/// 更新代理配置(兼容旧接口,更新所有三行的公共字段) /// 更新代理配置
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> { pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
// 更新所有三行的公共字段
conn.execute( conn.execute(
"UPDATE proxy_config SET "INSERT OR REPLACE INTO proxy_config
listen_address = ?1, (id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, target_app, created_at, updated_at)
listen_port = ?2, VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7,
max_retries = ?3, COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
enable_logging = ?4, datetime('now'))",
streaming_first_byte_timeout = ?5,
streaming_idle_timeout = ?6,
non_streaming_timeout = ?7,
updated_at = datetime('now')",
rusqlite::params![ rusqlite::params![
if config.enabled { 1 } else { 0 },
config.listen_address, config.listen_address,
config.listen_port as i32, config.listen_port as i32,
config.max_retries as i32, config.max_retries as i32,
config.request_timeout as i32,
if config.enable_logging { 1 } else { 0 }, if config.enable_logging { 1 } else { 0 },
config.streaming_first_byte_timeout as i32, "claude", // 兼容旧字段,写入默认值
config.streaming_idle_timeout as i32,
config.non_streaming_timeout as i32,
], ],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -263,28 +70,6 @@ impl Database {
Ok(()) Ok(())
} }
/// 设置 Live 接管状态(兼容旧版本,更新 enabled 字段)
pub async fn set_live_takeover_active(&self, _active: bool) -> Result<(), AppError> {
// 不再使用此字段,由 enabled 字段替代
// 保留空实现以兼容旧代码
Ok(())
}
/// 检查是否处于 Live 接管模式
///
/// 检查是否有任一 app 的 enabled = true
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM proxy_config WHERE enabled = 1",
[],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
}
// ==================== Provider Health ==================== // ==================== Provider Health ====================
/// 获取Provider健康状态 /// 获取Provider健康状态
@@ -293,73 +78,37 @@ impl Database {
provider_id: &str, provider_id: &str,
app_type: &str, app_type: &str,
) -> Result<ProviderHealth, AppError> { ) -> Result<ProviderHealth, AppError> {
let result = { let conn = lock_conn!(self.conn);
let conn = lock_conn!(self.conn);
conn.query_row( conn.query_row(
"SELECT provider_id, app_type, is_healthy, consecutive_failures, "SELECT provider_id, app_type, is_healthy, consecutive_failures,
last_success_at, last_failure_at, last_error, updated_at last_success_at, last_failure_at, last_error, updated_at
FROM provider_health FROM provider_health
WHERE provider_id = ?1 AND app_type = ?2", WHERE provider_id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type], rusqlite::params![provider_id, app_type],
|row| { |row| {
Ok(ProviderHealth { Ok(ProviderHealth {
provider_id: row.get(0)?, provider_id: row.get(0)?,
app_type: row.get(1)?, app_type: row.get(1)?,
is_healthy: row.get::<_, i64>(2)? != 0, is_healthy: row.get::<_, i64>(2)? != 0,
consecutive_failures: row.get::<_, i64>(3)? as u32, consecutive_failures: row.get::<_, i64>(3)? as u32,
last_success_at: row.get(4)?, last_success_at: row.get(4)?,
last_failure_at: row.get(5)?, last_failure_at: row.get(5)?,
last_error: row.get(6)?, last_error: row.get(6)?,
updated_at: row.get(7)?, updated_at: row.get(7)?,
}) })
}, },
) )
}; .map_err(|e| AppError::Database(e.to_string()))
match result {
Ok(health) => Ok(health),
// 缺少记录时视为健康(关闭后清空状态,再次打开时默认正常)
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ProviderHealth {
provider_id: provider_id.to_string(),
app_type: app_type.to_string(),
is_healthy: true,
consecutive_failures: 0,
last_success_at: None,
last_failure_at: None,
last_error: None,
updated_at: chrono::Utc::now().to_rfc3339(),
}),
Err(e) => Err(AppError::Database(e.to_string())),
}
} }
/// 更新Provider健康状态 /// 更新Provider健康状态
///
/// 使用默认阈值(5)判断是否健康,建议使用 `update_provider_health_with_threshold` 传入配置的阈值
pub async fn update_provider_health( pub async fn update_provider_health(
&self, &self,
provider_id: &str, provider_id: &str,
app_type: &str, app_type: &str,
success: bool, success: bool,
error_msg: Option<String>, error_msg: Option<String>,
) -> Result<(), AppError> {
// 默认阈值与 CircuitBreakerConfig::default() 保持一致
self.update_provider_health_with_threshold(provider_id, app_type, success, error_msg, 5)
.await
}
/// 更新Provider健康状态(带阈值参数)
///
/// # Arguments
/// * `failure_threshold` - 连续失败多少次后标记为不健康
pub async fn update_provider_health_with_threshold(
&self,
provider_id: &str,
app_type: &str,
success: bool,
error_msg: Option<String>,
failure_threshold: u32,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
@@ -367,7 +116,7 @@ impl Database {
// 先查询当前状态 // 先查询当前状态
let current = conn.query_row( let current = conn.query_row(
"SELECT consecutive_failures FROM provider_health "SELECT consecutive_failures FROM provider_health
WHERE provider_id = ?1 AND app_type = ?2", WHERE provider_id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type], rusqlite::params![provider_id, app_type],
|row| Ok(row.get::<_, i64>(0)? as u32), |row| Ok(row.get::<_, i64>(0)? as u32),
@@ -379,8 +128,7 @@ impl Database {
} else { } else {
// 失败:增加失败计数 // 失败:增加失败计数
let failures = current.unwrap_or(0) + 1; let failures = current.unwrap_or(0) + 1;
// 使用传入的阈值而非硬编码 let healthy = if failures >= 3 { 0 } else { 1 };
let healthy = if failures >= failure_threshold { 0 } else { 1 };
(healthy, failures) (healthy, failures)
}; };
@@ -395,10 +143,10 @@ impl Database {
"INSERT OR REPLACE INTO provider_health "INSERT OR REPLACE INTO provider_health
(provider_id, app_type, is_healthy, consecutive_failures, (provider_id, app_type, is_healthy, consecutive_failures,
last_success_at, last_failure_at, last_error, updated_at) last_success_at, last_failure_at, last_error, updated_at)
VALUES (?1, ?2, ?3, ?4, VALUES (?1, ?2, ?3, ?4,
COALESCE(?5, (SELECT last_success_at FROM provider_health COALESCE(?5, (SELECT last_success_at FROM provider_health
WHERE provider_id = ?1 AND app_type = ?2)), WHERE provider_id = ?1 AND app_type = ?2)),
COALESCE(?6, (SELECT last_failure_at FROM provider_health COALESCE(?6, (SELECT last_failure_at FROM provider_health
WHERE provider_id = ?1 AND app_type = ?2)), WHERE provider_id = ?1 AND app_type = ?2)),
?7, ?8)", ?7, ?8)",
rusqlite::params![ rusqlite::params![
@@ -417,116 +165,28 @@ impl Database {
Ok(()) Ok(())
} }
/// 重置Provider健康状态 // ==================== Proxy Usage (可选) ====================
pub async fn reset_provider_health(
&self, /// 记录代理使用统计
provider_id: &str, #[allow(dead_code)]
app_type: &str, pub async fn record_proxy_usage(&self, record: &ProxyUsageRecord) -> Result<(), AppError> {
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
conn.execute( conn.execute(
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2", "INSERT INTO proxy_usage
rusqlite::params![provider_id, app_type], (provider_id, app_type, endpoint, request_tokens, response_tokens,
) status_code, latency_ms, error, timestamp)
.map_err(|e| AppError::Database(e.to_string()))?; VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
log::debug!("Reset health status for provider {provider_id} (app: {app_type})");
Ok(())
}
/// 清空指定应用的健康状态(关闭单个代理时使用)
pub async fn clear_provider_health_for_app(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM provider_health WHERE app_type = ?1",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::debug!("Cleared provider health records for app {app_type}");
Ok(())
}
/// 清空所有Provider健康状态(代理停止时调用)
pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM provider_health", [])
.map_err(|e| AppError::Database(e.to_string()))?;
log::debug!("Cleared all provider health records");
Ok(())
}
// ==================== Circuit Breaker Config (Legacy Compatibility) ====================
/// 获取熔断器配置(兼容旧接口,从 claude 行读取)
///
/// 熔断器配置已合并到 proxy_config 表,每 app 独立
/// 此方法保留用于兼容旧代码,建议使用 get_proxy_config_for_app
pub async fn get_circuit_breaker_config(
&self,
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
FROM proxy_config WHERE app_type = 'claude'",
[],
|row| {
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
failure_threshold: row.get::<_, i32>(0)? as u32,
success_threshold: row.get::<_, i32>(1)? as u32,
timeout_seconds: row.get::<_, i64>(2)? as u64,
error_rate_threshold: row.get(3)?,
min_requests: row.get::<_, i32>(4)? as u32,
})
},
)
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,初始化默认配置
self.init_proxy_config_rows().await?;
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig::default())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新熔断器配置(兼容旧接口,更新所有三行)
///
/// 熔断器配置已合并到 proxy_config 表
/// 此方法保留用于兼容旧代码,建议使用 update_proxy_config_for_app
pub async fn update_circuit_breaker_config(
&self,
config: &crate::proxy::circuit_breaker::CircuitBreakerConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 更新所有三行的熔断器配置
conn.execute(
"UPDATE proxy_config SET
circuit_failure_threshold = ?1,
circuit_success_threshold = ?2,
circuit_timeout_seconds = ?3,
circuit_error_rate_threshold = ?4,
circuit_min_requests = ?5,
updated_at = datetime('now')",
rusqlite::params![ rusqlite::params![
config.failure_threshold as i32, &record.provider_id,
config.success_threshold as i32, &record.app_type,
config.timeout_seconds as i64, &record.endpoint,
config.error_rate_threshold, record.request_tokens,
config.min_requests as i32, record.response_tokens,
record.status_code as i64,
record.latency_ms as i64,
&record.error,
&record.timestamp,
], ],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -534,84 +194,51 @@ impl Database {
Ok(()) Ok(())
} }
// ==================== Live Backup ==================== /// 查询最近的使用统计
#[allow(dead_code)]
/// 保存 Live 配置备份 pub async fn get_recent_usage(
pub async fn save_live_backup(
&self, &self,
provider_id: &str,
app_type: &str, app_type: &str,
config_json: &str, limit: usize,
) -> Result<(), AppError> { ) -> Result<Vec<ProxyUsageRecord>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let now = chrono::Utc::now().to_rfc3339();
conn.execute( let mut stmt = conn
"INSERT OR REPLACE INTO proxy_live_backup (app_type, original_config, backed_up_at) .prepare(
VALUES (?1, ?2, ?3)", "SELECT provider_id, app_type, endpoint, request_tokens, response_tokens,
rusqlite::params![app_type, config_json, now], status_code, latency_ms, error, timestamp
) FROM proxy_usage
.map_err(|e| AppError::Database(e.to_string()))?; WHERE provider_id = ?1 AND app_type = ?2
ORDER BY timestamp DESC
log::info!("已备份 {app_type} Live 配置"); LIMIT ?3",
Ok(()) )
}
/// 检查是否存在任意 Live 配置备份
pub async fn has_any_live_backup(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM proxy_live_backup", [], |row| {
row.get(0)
})
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
}
/// 获取 Live 配置备份 let rows = stmt
pub async fn get_live_backup(&self, app_type: &str) -> Result<Option<LiveBackup>, AppError> { .query_map(
let conn = lock_conn!(self.conn); rusqlite::params![provider_id, app_type, limit as i64],
|row| {
Ok(ProxyUsageRecord {
provider_id: row.get(0)?,
app_type: row.get(1)?,
endpoint: row.get(2)?,
request_tokens: row.get(3)?,
response_tokens: row.get(4)?,
status_code: row.get::<_, i64>(5)? as u16,
latency_ms: row.get::<_, i64>(6)? as u64,
error: row.get(7)?,
timestamp: row.get(8)?,
})
},
)
.map_err(|e| AppError::Database(e.to_string()))?;
let result = conn.query_row( let mut records = Vec::new();
"SELECT app_type, original_config, backed_up_at FROM proxy_live_backup WHERE app_type = ?1", for row in rows {
rusqlite::params![app_type], records.push(row.map_err(|e| AppError::Database(e.to_string()))?);
|row| {
Ok(LiveBackup {
app_type: row.get(0)?,
original_config: row.get(1)?,
backed_up_at: row.get(2)?,
})
},
);
match result {
Ok(backup) => Ok(Some(backup)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
} }
}
/// 删除 Live 配置备份 Ok(records)
pub async fn delete_live_backup(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM proxy_live_backup WHERE app_type = ?1",
rusqlite::params![app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::info!("已删除 {app_type} Live 配置备份");
Ok(())
}
/// 删除所有 Live 配置备份
pub async fn delete_all_live_backups(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM proxy_live_backup", [])
.map_err(|e| AppError::Database(e.to_string()))?;
log::info!("已删除所有 Live 配置备份");
Ok(())
} }
} }
-66
View File
@@ -62,70 +62,4 @@ impl Database {
Ok(()) Ok(())
} }
} }
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
/// 获取指定应用的代理接管状态
///
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
/// 此方法仅用于数据库迁移时读取旧数据
#[deprecated(since = "3.9.0", note = "使用 get_proxy_config_for_app().enabled 替代")]
pub fn get_proxy_takeover_enabled(&self, app_type: &str) -> Result<bool, AppError> {
let key = format!("proxy_takeover_{app_type}");
match self.get_setting(&key)? {
Some(value) => Ok(value == "true"),
None => Ok(false),
}
}
/// 设置指定应用的代理接管状态
///
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
#[deprecated(
since = "3.9.0",
note = "使用 update_proxy_config_for_app() 修改 enabled 字段"
)]
pub fn set_proxy_takeover_enabled(
&self,
app_type: &str,
enabled: bool,
) -> Result<(), AppError> {
let key = format!("proxy_takeover_{app_type}");
let value = if enabled { "true" } else { "false" };
self.set_setting(&key, value)
}
/// 检查是否有任一应用开启了代理接管
///
/// **已废弃**: 请使用 `is_live_takeover_active()` 替代
#[deprecated(since = "3.9.0", note = "使用 is_live_takeover_active() 替代")]
pub fn has_any_proxy_takeover(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM settings WHERE key LIKE 'proxy_takeover_%' AND value = 'true'",
[],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
}
/// 清除所有代理接管状态(将所有 proxy_takeover_* 设置为 false
///
/// **已废弃**: settings 表不再用于存储代理状态
#[deprecated(
since = "3.9.0",
note = "使用 update_proxy_config_for_app() 清除各应用的 enabled 字段"
)]
pub fn clear_all_proxy_takeover(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE settings SET value = 'false' WHERE key LIKE 'proxy_takeover_%'",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::info!("已清除所有代理接管状态");
Ok(())
}
} }
@@ -1,57 +0,0 @@
//! 流式健康检查日志 DAO
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::services::stream_check::{StreamCheckConfig, StreamCheckResult};
impl Database {
/// 保存流式检查日志
pub fn save_stream_check_log(
&self,
provider_id: &str,
provider_name: &str,
app_type: &str,
result: &StreamCheckResult,
) -> Result<i64, AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT INTO stream_check_logs
(provider_id, provider_name, app_type, status, success, message,
response_time_ms, http_status, model_used, retry_count, tested_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
provider_id,
provider_name,
app_type,
format!("{:?}", result.status).to_lowercase(),
result.success,
result.message,
result.response_time_ms.map(|t| t as i64),
result.http_status.map(|s| s as i64),
result.model_used,
result.retry_count as i64,
result.tested_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(conn.last_insert_rowid())
}
/// 获取流式检查配置
pub fn get_stream_check_config(&self) -> Result<StreamCheckConfig, AppError> {
match self.get_setting("stream_check_config")? {
Some(json) => serde_json::from_str(&json)
.map_err(|e| AppError::Message(format!("解析配置失败: {e}"))),
None => Ok(StreamCheckConfig::default()),
}
}
/// 保存流式检查配置
pub fn save_stream_check_config(&self, config: &StreamCheckConfig) -> Result<(), AppError> {
let json = serde_json::to_string(config)
.map_err(|e| AppError::Message(format!("序列化配置失败: {e}")))?;
self.set_setting("stream_check_config", &json)
}
}
@@ -1,74 +0,0 @@
//! 统一供应商 (Universal Provider) DAO
//!
//! 提供统一供应商的 CRUD 操作。
use crate::database::{lock_conn, to_json_string, Database};
use crate::error::AppError;
use crate::provider::UniversalProvider;
use std::collections::HashMap;
/// 统一供应商的 Settings Key
const UNIVERSAL_PROVIDERS_KEY: &str = "universal_providers";
impl Database {
/// 获取所有统一供应商
pub fn get_all_universal_providers(
&self,
) -> Result<HashMap<String, UniversalProvider>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT value FROM settings WHERE key = ?")
.map_err(|e| AppError::Database(e.to_string()))?;
let result: Option<String> = stmt
.query_row([UNIVERSAL_PROVIDERS_KEY], |row| row.get(0))
.ok();
match result {
Some(json) => serde_json::from_str(&json)
.map_err(|e| AppError::Database(format!("解析统一供应商数据失败: {e}"))),
None => Ok(HashMap::new()),
}
}
/// 获取单个统一供应商
pub fn get_universal_provider(&self, id: &str) -> Result<Option<UniversalProvider>, AppError> {
let providers = self.get_all_universal_providers()?;
Ok(providers.get(id).cloned())
}
/// 保存统一供应商(添加或更新)
pub fn save_universal_provider(&self, provider: &UniversalProvider) -> Result<(), AppError> {
let mut providers = self.get_all_universal_providers()?;
providers.insert(provider.id.clone(), provider.clone());
self.save_all_universal_providers(&providers)
}
/// 删除统一供应商
pub fn delete_universal_provider(&self, id: &str) -> Result<bool, AppError> {
let mut providers = self.get_all_universal_providers()?;
let existed = providers.remove(id).is_some();
if existed {
self.save_all_universal_providers(&providers)?;
}
Ok(existed)
}
/// 保存所有统一供应商(内部方法)
fn save_all_universal_providers(
&self,
providers: &HashMap<String, UniversalProvider>,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
let json = to_json_string(providers)?;
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
[UNIVERSAL_PROVIDERS_KEY, &json],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
-3
View File
@@ -31,9 +31,6 @@ mod schema;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
// DAO 类型导出供外部使用
pub use dao::FailoverQueueItem;
use crate::config::get_app_config_dir; use crate::config::get_app_config_dir;
use crate::error::AppError; use crate::error::AppError;
use rusqlite::Connection; use rusqlite::Connection;
File diff suppressed because it is too large Load Diff
+8 -54
View File
@@ -53,6 +53,7 @@ const LEGACY_SCHEMA_SQL: &str = r#"
#[derive(Debug)] #[derive(Debug)]
struct ColumnInfo { struct ColumnInfo {
name: String,
r#type: String, r#type: String,
notnull: i64, notnull: i64,
default: Option<String>, default: Option<String>,
@@ -64,9 +65,10 @@ fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo {
.expect("prepare pragma"); .expect("prepare pragma");
let mut rows = stmt.query([]).expect("query pragma"); let mut rows = stmt.query([]).expect("query pragma");
while let Some(row) = rows.next().expect("read row") { while let Some(row) = rows.next().expect("read row") {
let column_name: String = row.get(1).expect("name"); let name: String = row.get(1).expect("name");
if column_name.eq_ignore_ascii_case(column) { if name.eq_ignore_ascii_case(column) {
return ColumnInfo { return ColumnInfo {
name,
r#type: row.get::<_, String>(2).expect("type"), r#type: row.get::<_, String>(2).expect("type"),
notnull: row.get::<_, i64>(3).expect("notnull"), notnull: row.get::<_, i64>(3).expect("notnull"),
default: row.get::<_, Option<String>>(4).ok().flatten(), default: row.get::<_, Option<String>>(4).ok().flatten(),
@@ -199,53 +201,6 @@ fn migration_aligns_column_defaults_and_types() {
); );
} }
#[test]
fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
let conn = Connection::open_in_memory().expect("open memory db");
// 模拟测试版 v2user_version=2,但 proxy_config 仍是单例结构(无 app_type
Database::set_user_version(&conn, 2).expect("set user_version");
conn.execute_batch(
r#"
CREATE TABLE proxy_config (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000,
max_retries INTEGER NOT NULL DEFAULT 3,
request_timeout INTEGER NOT NULL DEFAULT 300,
enable_logging INTEGER NOT NULL DEFAULT 1,
target_app TEXT NOT NULL DEFAULT 'claude',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO proxy_config (id, enabled) VALUES (1, 1);
"#,
)
.expect("seed legacy proxy_config");
Database::create_tables_on_conn(&conn).expect("create tables should repair proxy_config");
assert!(
Database::has_column(&conn, "proxy_config", "app_type").expect("check app_type"),
"proxy_config should be migrated to per-app structure"
);
let count: i32 = conn
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
.expect("count rows");
assert_eq!(count, 3, "per-app proxy_config should have 3 rows");
// 新结构下应能按 app_type 查询
let _: i32 = conn
.query_row(
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'claude'",
[],
|r| r.get(0),
)
.expect("query by app_type");
}
#[test] #[test]
fn dry_run_does_not_write_to_disk() { fn dry_run_does_not_write_to_disk() {
// Create minimal valid config for migration // Create minimal valid config for migration
@@ -290,14 +245,13 @@ fn dry_run_validates_schema_compatibility() {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false, is_proxy_target: Some(false),
}, },
); );
let manager = ProviderManager { let mut manager = ProviderManager::default();
providers, manager.providers = providers;
current: "test-provider".to_string(), manager.current = "test-provider".to_string();
};
let mut apps = HashMap::new(); let mut apps = HashMap::new();
apps.insert("claude".to_string(), manager); apps.insert("claude".to_string(), manager);
-23
View File
@@ -113,27 +113,4 @@ pub struct DeepLinkImportRequest {
/// Remote config URL /// Remote config URL
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub config_url: Option<String>, pub config_url: Option<String>,
// ============ Usage script fields (v3.9+) ============
/// Whether to enable usage query (default: true if usage_script is provided)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_enabled: Option<bool>,
/// Base64 encoded usage query script code
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<String>,
/// Usage query API key (if different from provider API key)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_api_key: Option<String>,
/// Usage query base URL (if different from provider endpoint)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_base_url: Option<String>,
/// Usage query access token (for NewAPI template)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_access_token: Option<String>,
/// Usage query user ID (for NewAPI template)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_user_id: Option<String>,
/// Auto query interval in minutes (0 to disable)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_auto_interval: Option<u64>,
} }
-41
View File
@@ -122,19 +122,6 @@ fn parse_provider_deeplink(
let config_url = params.get("configUrl").cloned(); let config_url = params.get("configUrl").cloned();
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok()); let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
// Extract usage script fields (v3.9+)
let usage_enabled = params
.get("usageEnabled")
.and_then(|v| v.parse::<bool>().ok());
let usage_script = params.get("usageScript").cloned();
let usage_api_key = params.get("usageApiKey").cloned();
let usage_base_url = params.get("usageBaseUrl").cloned();
let usage_access_token = params.get("usageAccessToken").cloned();
let usage_user_id = params.get("usageUserId").cloned();
let usage_auto_interval = params
.get("usageAutoInterval")
.and_then(|v| v.parse::<u64>().ok());
Ok(DeepLinkImportRequest { Ok(DeepLinkImportRequest {
version, version,
resource, resource,
@@ -159,13 +146,6 @@ fn parse_provider_deeplink(
config, config,
config_format, config_format,
config_url, config_url,
usage_enabled,
usage_script,
usage_api_key,
usage_base_url,
usage_access_token,
usage_user_id,
usage_auto_interval,
}) })
} }
@@ -226,13 +206,6 @@ fn parse_prompt_deeplink(
config: None, config: None,
config_format: None, config_format: None,
config_url: None, config_url: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
}) })
} }
@@ -288,13 +261,6 @@ fn parse_mcp_deeplink(
directory: None, directory: None,
branch: None, branch: None,
config_url: None, config_url: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
}) })
} }
@@ -343,12 +309,5 @@ fn parse_skill_deeplink(
config: None, config: None,
config_format: None, config_format: None,
config_url: None, config_url: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
}) })
} }
+3 -57
View File
@@ -5,7 +5,7 @@
use super::utils::{decode_base64_param, infer_homepage_from_endpoint}; use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
use super::DeepLinkImportRequest; use super::DeepLinkImportRequest;
use crate::error::AppError; use crate::error::AppError;
use crate::provider::{Provider, ProviderMeta, UsageScript}; use crate::provider::Provider;
use crate::services::ProviderService; use crate::services::ProviderService;
use crate::store::AppState; use crate::store::AppState;
use crate::AppType; use crate::AppType;
@@ -117,9 +117,6 @@ pub(crate) fn build_provider_from_request(
AppType::Gemini => build_gemini_settings(request), AppType::Gemini => build_gemini_settings(request),
}; };
// Build usage script configuration if provided
let meta = build_provider_meta(request)?;
let provider = Provider { let provider = Provider {
id: String::new(), // Will be generated by caller id: String::new(), // Will be generated by caller
name: request.name.clone().unwrap_or_default(), name: request.name.clone().unwrap_or_default(),
@@ -129,66 +126,15 @@ pub(crate) fn build_provider_from_request(
created_at: None, created_at: None,
sort_index: None, sort_index: None,
notes: request.notes.clone(), notes: request.notes.clone(),
meta, meta: None,
icon: request.icon.clone(), icon: request.icon.clone(),
icon_color: None, icon_color: None,
in_failover_queue: false, is_proxy_target: None,
}; };
Ok(provider) Ok(provider)
} }
/// Build provider meta with usage script configuration
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
// Check if any usage script fields are provided
if request.usage_script.is_none()
&& request.usage_enabled.is_none()
&& request.usage_api_key.is_none()
&& request.usage_base_url.is_none()
&& request.usage_access_token.is_none()
&& request.usage_user_id.is_none()
&& request.usage_auto_interval.is_none()
{
return Ok(None);
}
// Decode usage script code if provided
let code = if let Some(script_b64) = &request.usage_script {
let decoded = decode_base64_param("usage_script", script_b64)?;
String::from_utf8(decoded)
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))?
} else {
String::new()
};
// Determine enabled state: explicit param > has code > false
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
// Build UsageScript - use provider's API key and endpoint as defaults
let usage_script = UsageScript {
enabled,
language: "javascript".to_string(),
code,
timeout: Some(10),
api_key: request
.usage_api_key
.clone()
.or_else(|| request.api_key.clone()),
base_url: request
.usage_base_url
.clone()
.or_else(|| request.endpoint.clone()),
access_token: request.usage_access_token.clone(),
user_id: request.usage_user_id.clone(),
auto_query_interval: request.usage_auto_interval,
};
Ok(Some(ProviderMeta {
usage_script: Some(usage_script),
..Default::default()
}))
}
/// Build Claude settings configuration /// Build Claude settings configuration
fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value { fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let mut env = serde_json::Map::new(); let mut env = serde_json::Map::new();
+3 -31
View File
@@ -145,13 +145,6 @@ fn test_build_gemini_provider_with_model() {
content: None, content: None,
description: None, description: None,
enabled: None, enabled: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
}; };
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap(); let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
@@ -198,13 +191,6 @@ fn test_build_gemini_provider_without_model() {
content: None, content: None,
description: None, description: None,
enabled: None, enabled: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
}; };
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap(); let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
@@ -246,13 +232,6 @@ fn test_parse_and_merge_config_claude() {
content: None, content: None,
description: None, description: None,
enabled: None, enabled: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
}; };
let merged = parse_and_merge_config(&request).unwrap(); let merged = parse_and_merge_config(&request).unwrap();
@@ -296,13 +275,6 @@ fn test_parse_and_merge_config_url_override() {
content: None, content: None,
description: None, description: None,
enabled: None, enabled: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
}; };
let merged = parse_and_merge_config(&request).unwrap(); let merged = parse_and_merge_config(&request).unwrap();
@@ -375,7 +347,7 @@ fn test_parse_prompt_deeplink() {
assert_eq!(request.name.unwrap(), "test"); assert_eq!(request.name.unwrap(), "test");
assert_eq!(request.content.unwrap(), content_b64); assert_eq!(request.content.unwrap(), content_b64);
assert_eq!(request.description.unwrap(), "desc"); assert_eq!(request.description.unwrap(), "desc");
assert!(request.enabled.unwrap()); assert_eq!(request.enabled.unwrap(), true);
} }
#[test] #[test]
@@ -391,13 +363,13 @@ fn test_parse_mcp_deeplink() {
assert_eq!(request.resource, "mcp"); assert_eq!(request.resource, "mcp");
assert_eq!(request.apps.unwrap(), "claude,codex"); assert_eq!(request.apps.unwrap(), "claude,codex");
assert_eq!(request.config.unwrap(), config_b64); assert_eq!(request.config.unwrap(), config_b64);
assert!(request.enabled.unwrap()); assert_eq!(request.enabled.unwrap(), true);
} }
#[test] #[test]
fn test_parse_skill_deeplink() { fn test_parse_skill_deeplink() {
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev"; let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
let request = parse_deeplink_url(url).unwrap(); let request = parse_deeplink_url(&url).unwrap();
assert_eq!(request.resource, "skill"); assert_eq!(request.resource, "skill");
assert_eq!(request.repo.unwrap(), "owner/repo"); assert_eq!(request.repo.unwrap(), "owner/repo");
-4
View File
@@ -52,10 +52,6 @@ pub enum AppError {
}, },
#[error("数据库错误: {0}")] #[error("数据库错误: {0}")]
Database(String), Database(String),
#[error("所有供应商已熔断,无可用渠道")]
AllProvidersCircuitOpen,
#[error("未配置供应商")]
NoProvidersConfigured,
} }
impl AppError { impl AppError {
+4 -11
View File
@@ -42,8 +42,8 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
/// ///
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性: /// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
/// - httpUrl → url + type: "http" /// - httpUrl → url + type: "http"
/// - 仅有 url 字段 → 补齐 type: "sse"Gemini 以字段名推断传输类型) /// - 仅有 url 字段 → 保持不变(SSE 类型)
/// - 仅有 command 字段 → 补齐 type: "stdio" /// - 仅有 command 字段 → 保持不变(stdio 类型)
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> { pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path(); let path = user_config_path();
if !path.exists() { if !path.exists() {
@@ -65,15 +65,8 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
obj.insert("url".to_string(), http_url); obj.insert("url".to_string(), http_url);
obj.insert("type".to_string(), Value::String("http".to_string())); obj.insert("type".to_string(), Value::String("http".to_string()));
} }
// 如果有 url 但没有 type,不添加 type(默认为 SSE
// Gemini CLI 不使用 type 字段:这里补齐成统一结构,便于校验与导入 // 如果有 command 但没有 type,不添加 type(默认为 stdio
if obj.get("type").is_none() {
if obj.contains_key("command") {
obj.insert("type".to_string(), Value::String("stdio".to_string()));
} else if obj.contains_key("url") {
obj.insert("type".to_string(), Value::String("sse".to_string()));
}
}
} }
} }
+71 -217
View File
@@ -39,8 +39,8 @@ pub use mcp::{
}; };
pub use provider::{Provider, ProviderMeta}; pub use provider::{Provider, ProviderMeta};
pub use services::{ pub use services::{
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService, ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SkillService,
SkillService, SpeedtestService, SpeedtestService,
}; };
pub use settings::{update_settings, AppSettings}; pub use settings::{update_settings, AppSettings};
pub use store::AppState; pub use store::AppState;
@@ -48,9 +48,8 @@ use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc; use std::sync::Arc;
#[cfg(target_os = "macos")]
use tauri::image::Image;
use tauri::tray::{TrayIconBuilder, TrayIconEvent}; use tauri::tray::{TrayIconBuilder, TrayIconEvent};
#[cfg(target_os = "macos")]
use tauri::RunEvent; use tauri::RunEvent;
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
@@ -135,19 +134,6 @@ async fn update_tray_menu(
} }
} }
#[cfg(target_os = "macos")]
fn macos_tray_icon() -> Option<Image<'static>> {
const ICON_BYTES: &[u8] = include_bytes!("../icons/tray/macos/statusbar_template_3x.png");
match Image::from_bytes(ICON_BYTES) {
Ok(icon) => Some(icon),
Err(err) => {
log::warn!("Failed to load macOS tray icon: {err}");
None
}
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let mut builder = tauri::Builder::default(); let mut builder = tauri::Builder::default();
@@ -223,6 +209,44 @@ pub fn run() {
log::warn!("初始化 Updater 插件失败,已跳过:{e}"); log::warn!("初始化 Updater 插件失败,已跳过:{e}");
} }
} }
#[cfg(target_os = "macos")]
{
// 设置 macOS 标题栏背景色为主界面蓝色
if let Some(window) = app.get_webview_window("main") {
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2_app_kit::NSColor;
match window.ns_window() {
Ok(ns_window_ptr) => {
if let Some(ns_window) =
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject) }
{
// 使用与主界面 banner 相同的蓝色 #3498db
// #3498db = RGB(52, 152, 219)
let bg_color = unsafe {
NSColor::colorWithRed_green_blue_alpha(
52.0 / 255.0, // R: 52
152.0 / 255.0, // G: 152
219.0 / 255.0, // B: 219
1.0, // Alpha: 1.0
)
};
unsafe {
use objc2::msg_send;
let _: () =
msg_send![&*ns_window, setBackgroundColor: &*bg_color];
}
} else {
log::warn!("Failed to retain NSWindow reference");
}
}
Err(e) => log::warn!("Failed to get NSWindow pointer: {e}"),
}
}
}
// 初始化日志 // 初始化日志
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
app.handle().plugin( app.handle().plugin(
@@ -308,9 +332,6 @@ pub fn run() {
let app_state = AppState::new(db); let app_state = AppState::new(db);
// 设置 AppHandle 用于代理故障转移时的 UI 更新
app_state.proxy_service.set_app_handle(app.handle().clone());
// ============================================================ // ============================================================
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响) // 按表独立判断的导入逻辑(各类数据独立检查,互不影响)
// ============================================================ // ============================================================
@@ -480,26 +501,11 @@ pub fn run() {
}) })
.show_menu_on_left_click(true); .show_menu_on_left_click(true);
// 使用平台对应的托盘图标(macOS 使用模板图标适配深浅色) // 统一使用应用默认图标;待托盘模板图标就绪后再启用
#[cfg(target_os = "macos")] if let Some(icon) = app.default_window_icon() {
{ tray_builder = tray_builder.icon(icon.clone());
if let Some(icon) = macos_tray_icon() { } else {
tray_builder = tray_builder.icon(icon).icon_as_template(true); log::warn!("Failed to get default window icon for tray");
} else if let Some(icon) = app.default_window_icon() {
log::warn!("Falling back to default window icon for tray");
tray_builder = tray_builder.icon(icon.clone());
} else {
log::warn!("Failed to load macOS tray icon for tray");
}
}
#[cfg(not(target_os = "macos"))]
{
if let Some(icon) = app.default_window_icon() {
tray_builder = tray_builder.icon(icon.clone());
} else {
log::warn!("Failed to get default window icon for tray");
}
} }
let _tray = tray_builder.build(app)?; let _tray = tray_builder.build(app)?;
@@ -516,33 +522,26 @@ pub fn run() {
} }
} }
// 异常退出恢复 + 代理状态自动恢复 // 自动启动代理服务器
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
match state.db.get_proxy_config().await {
// 检查是否有 Live 备份(表示上次异常退出时可能处于接管状态) Ok(config) => {
let has_backups = match state.db.has_any_live_backup().await { if config.enabled {
Ok(v) => v, log::info!("代理服务配置为启用,正在启动...");
Err(e) => { match state.proxy_service.start().await {
log::error!("检查 Live 备份失败: {e}"); Ok(info) => log::info!(
false "代理服务器自动启动成功: {}:{}",
} info.address,
}; info.port
// 检查 Live 配置是否仍处于被接管状态(包含占位符) ),
let live_taken_over = state.proxy_service.detect_takeover_in_live_configs(); Err(e) => log::error!("代理服务器自动启动失败: {e}"),
}
if has_backups || live_taken_over { }
log::warn!("检测到上次异常退出(存在接管残留),正在恢复 Live 配置...");
if let Err(e) = state.proxy_service.recover_from_crash().await {
log::error!("恢复 Live 配置失败: {e}");
} else {
log::info!("Live 配置已恢复");
} }
Err(e) => log::error!("启动时获取代理配置失败: {e}"),
} }
// 检查 settings 表中的代理状态,自动恢复代理服务
restore_proxy_state_on_startup(&state).await;
}); });
Ok(()) Ok(())
@@ -554,6 +553,7 @@ pub fn run() {
commands::update_provider, commands::update_provider,
commands::delete_provider, commands::delete_provider,
commands::switch_provider, commands::switch_provider,
commands::set_proxy_target_provider,
commands::import_default_config, commands::import_default_config,
commands::get_claude_config_status, commands::get_claude_config_status,
commands::get_config_status, commands::get_config_status,
@@ -580,8 +580,6 @@ pub fn run() {
commands::read_claude_plugin_config, commands::read_claude_plugin_config,
commands::apply_claude_plugin_config, commands::apply_claude_plugin_config,
commands::is_claude_plugin_applied, commands::is_claude_plugin_applied,
commands::apply_claude_onboarding_skip,
commands::clear_claude_onboarding_skip,
// Claude MCP management // Claude MCP management
commands::get_claude_mcp_status, commands::get_claude_mcp_status,
commands::read_claude_mcp_config, commands::read_claude_mcp_config,
@@ -596,7 +594,7 @@ pub fn run() {
commands::upsert_mcp_server_in_config, commands::upsert_mcp_server_in_config,
commands::delete_mcp_server_in_config, commands::delete_mcp_server_in_config,
commands::set_mcp_enabled, commands::set_mcp_enabled,
// Unified MCP management // v3.7.0: Unified MCP management
commands::get_mcp_servers, commands::get_mcp_servers,
commands::upsert_mcp_server, commands::upsert_mcp_server,
commands::delete_mcp_server, commands::delete_mcp_server,
@@ -650,33 +648,11 @@ pub fn run() {
commands::get_auto_launch_status, commands::get_auto_launch_status,
// Proxy server management // Proxy server management
commands::start_proxy_server, commands::start_proxy_server,
commands::stop_proxy_with_restore, commands::stop_proxy_server,
commands::get_proxy_takeover_status,
commands::set_proxy_takeover_for_app,
commands::get_proxy_status, commands::get_proxy_status,
commands::get_proxy_config, commands::get_proxy_config,
commands::update_proxy_config, commands::update_proxy_config,
// Global & Per-App Config
commands::get_global_proxy_config,
commands::update_global_proxy_config,
commands::get_proxy_config_for_app,
commands::update_proxy_config_for_app,
commands::is_proxy_running, commands::is_proxy_running,
commands::is_live_takeover_active,
commands::switch_proxy_provider,
// Proxy failover commands
commands::get_provider_health,
commands::reset_circuit_breaker,
commands::get_circuit_breaker_config,
commands::update_circuit_breaker_config,
commands::get_circuit_breaker_stats,
// Failover queue management
commands::get_failover_queue,
commands::get_available_providers_for_failover,
commands::add_to_failover_queue,
commands::remove_from_failover_queue,
commands::get_auto_failover_enabled,
commands::set_auto_failover_enabled,
// Usage statistics // Usage statistics
commands::get_usage_summary, commands::get_usage_summary,
commands::get_usage_trends, commands::get_usage_trends,
@@ -688,18 +664,13 @@ pub fn run() {
commands::update_model_pricing, commands::update_model_pricing,
commands::delete_model_pricing, commands::delete_model_pricing,
commands::check_provider_limits, commands::check_provider_limits,
// Stream health check // Model testing
commands::stream_check_provider, commands::test_provider_model,
commands::stream_check_all_providers, commands::test_all_providers_model,
commands::get_stream_check_config, commands::get_model_test_config,
commands::save_stream_check_config, commands::save_model_test_config,
commands::get_tool_versions, commands::get_model_test_logs,
// Universal Provider management commands::cleanup_model_test_logs,
commands::get_universal_providers,
commands::get_universal_provider,
commands::upsert_universal_provider,
commands::delete_universal_provider,
commands::sync_universal_provider,
]); ]);
let app = builder let app = builder
@@ -707,26 +678,6 @@ pub fn run() {
.expect("error while running tauri application"); .expect("error while running tauri application");
app.run(|app_handle, event| { app.run(|app_handle, event| {
// 处理退出请求(所有平台)
if let RunEvent::ExitRequested { api, .. } = &event {
log::info!("收到退出请求,开始清理...");
// 阻止立即退出,执行清理
api.prevent_exit();
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
cleanup_before_exit(&app_handle).await;
log::info!("清理完成,退出应用");
// 短暂等待确保所有 I/O 操作(如数据库写入)刷新到磁盘
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// 使用 std::process::exit 避免再次触发 ExitRequested
std::process::exit(0);
});
return;
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
match event { match event {
@@ -806,103 +757,6 @@ pub fn run() {
}); });
} }
// ============================================================
// 应用退出清理
// ============================================================
/// 应用退出前的清理工作
///
/// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。
/// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。
/// 使用 stop_with_restore_keep_state 保留 settings 表中的代理状态,下次启动时自动恢复。
pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
if let Some(state) = app_handle.try_state::<store::AppState>() {
let proxy_service = &state.proxy_service;
// 退出时也需要兜底:代理可能已崩溃/未运行,但 Live 接管残留仍在(占位符/备份)。
let has_backups = match state.db.has_any_live_backup().await {
Ok(v) => v,
Err(e) => {
log::error!("退出时检查 Live 备份失败: {e}");
false
}
};
let live_taken_over = proxy_service.detect_takeover_in_live_configs();
let needs_restore = has_backups || live_taken_over;
if needs_restore {
log::info!("检测到接管残留,开始恢复 Live 配置(保留代理状态)...");
// 使用 keep_state 版本,保留 settings 表中的代理状态
if let Err(e) = proxy_service.stop_with_restore_keep_state().await {
log::error!("退出时恢复 Live 配置失败: {e}");
} else {
log::info!("已恢复 Live 配置(代理状态已保留,下次启动将自动恢复)");
}
return;
}
// 非接管模式:代理在运行则仅停止代理
if proxy_service.is_running().await {
log::info!("检测到代理服务器正在运行,开始停止...");
if let Err(e) = proxy_service.stop().await {
log::error!("退出时停止代理失败: {e}");
}
log::info!("代理服务器清理完成");
}
}
}
// ============================================================
// 启动时恢复代理状态
// ============================================================
/// 启动时根据 proxy_config 表中的代理状态自动恢复代理服务
///
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`,
/// 则自动启动代理服务并接管对应应用的 Live 配置。
async fn restore_proxy_state_on_startup(state: &store::AppState) {
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
let mut apps_to_restore = Vec::new();
for app_type in ["claude", "codex", "gemini"] {
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {
if config.enabled {
apps_to_restore.push(app_type);
}
}
}
if apps_to_restore.is_empty() {
log::debug!("启动时无需恢复代理状态");
return;
}
log::info!("检测到上次代理状态需要恢复,应用列表: {apps_to_restore:?}");
// 逐个恢复接管状态
for app_type in apps_to_restore {
match state
.proxy_service
.set_takeover_for_app(app_type, true)
.await
{
Ok(()) => {
log::info!("✓ 已恢复 {app_type} 的代理接管状态");
}
Err(e) => {
log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}");
// 失败时清除该应用的状态,避免下次启动再次尝试
if let Err(clear_err) = state
.proxy_service
.set_takeover_for_app(app_type, false)
.await
{
log::error!("清除 {app_type} 代理状态失败: {clear_err}");
}
}
}
}
}
// ============================================================ // ============================================================
// 迁移错误对话框辅助函数 // 迁移错误对话框辅助函数
// ============================================================ // ============================================================
-15
View File
@@ -8,12 +8,6 @@ use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec}; use super::validation::{extract_server_spec, validate_server_spec};
fn should_sync_claude_mcp() -> bool {
// Claude 未安装/未初始化时:通常 ~/.claude 目录与 ~/.claude.json 都不存在。
// 按用户偏好:此时跳过写入/删除,不创建任何文件或目录。
crate::config::get_claude_config_dir().exists() || crate::config::get_claude_mcp_path().exists()
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true /// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> { fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new(); let mut out = HashMap::new();
@@ -39,9 +33,6 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json /// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> { pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
let enabled = collect_enabled_servers(&config.mcp.claude); let enabled = collect_enabled_servers(&config.mcp.claude);
crate::claude_mcp::set_mcp_servers_map(&enabled) crate::claude_mcp::set_mcp_servers_map(&enabled)
} }
@@ -116,9 +107,6 @@ pub fn sync_single_server_to_claude(
id: &str, id: &str,
server_spec: &Value, server_spec: &Value,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置 // 读取现有的 MCP 配置
let current = crate::claude_mcp::read_mcp_servers_map()?; let current = crate::claude_mcp::read_mcp_servers_map()?;
@@ -132,9 +120,6 @@ pub fn sync_single_server_to_claude(
/// 从 Claude live 配置中移除单个 MCP 服务器 /// 从 Claude live 配置中移除单个 MCP 服务器
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> { pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置 // 读取现有的 MCP 配置
let mut current = crate::claude_mcp::read_mcp_servers_map()?; let mut current = crate::claude_mcp::read_mcp_servers_map()?;
+8 -35
View File
@@ -13,12 +13,6 @@ use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec}; use super::validation::{extract_server_spec, validate_server_spec};
fn should_sync_codex_mcp() -> bool {
// Codex 未安装/未初始化时:~/.codex 目录不存在。
// 按用户偏好:目录缺失时跳过写入/删除,不创建任何文件或目录。
crate::codex_config::get_codex_config_dir().exists()
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true /// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> { fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new(); let mut out = HashMap::new();
@@ -279,9 +273,6 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
/// - 仅更新 `mcp_servers` 表,保留其它键 /// - 仅更新 `mcp_servers` 表,保留其它键
/// - 仅写入启用项;无启用项时清理 mcp_servers 表 /// - 仅写入启用项;无启用项时清理 mcp_servers 表
pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> { pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
use toml_edit::{Item, Table}; use toml_edit::{Item, Table};
// 1) 收集启用项(Codex 维度) // 1) 收集启用项(Codex 维度)
@@ -348,9 +339,6 @@ pub fn sync_single_server_to_codex(
id: &str, id: &str,
server_spec: &Value, server_spec: &Value,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
use toml_edit::Item; use toml_edit::Item;
// 读取现有的 config.toml // 读取现有的 config.toml
@@ -359,14 +347,9 @@ pub fn sync_single_server_to_codex(
let mut doc = if config_path.exists() { let mut doc = if config_path.exists() {
let content = let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
// 尝试解析现有配置,如果失败则创建新文档(容错处理) content
match content.parse::<toml_edit::DocumentMut>() { .parse::<toml_edit::DocumentMut>()
Ok(doc) => doc, .map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
Err(e) => {
log::warn!("解析 Codex config.toml 失败: {e},将创建新配置");
toml_edit::DocumentMut::new()
}
}
} else { } else {
toml_edit::DocumentMut::new() toml_edit::DocumentMut::new()
}; };
@@ -393,8 +376,7 @@ pub fn sync_single_server_to_codex(
doc["mcp_servers"][id] = Item::Table(toml_table); doc["mcp_servers"][id] = Item::Table(toml_table);
// 写回文件 // 写回文件
let new_text = doc.to_string(); std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
crate::config::write_text_file(&config_path, &new_text)?;
Ok(()) Ok(())
} }
@@ -402,9 +384,6 @@ pub fn sync_single_server_to_codex(
/// 从 Codex live 配置中移除单个 MCP 服务器 /// 从 Codex live 配置中移除单个 MCP 服务器
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据 /// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> { pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
let config_path = crate::codex_config::get_codex_config_path(); let config_path = crate::codex_config::get_codex_config_path();
if !config_path.exists() { if !config_path.exists() {
@@ -414,14 +393,9 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
let content = let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
// 尝试解析现有配置,如果失败则直接返回(无法删除不存在的内容) let mut doc = content
let mut doc = match content.parse::<toml_edit::DocumentMut>() { .parse::<toml_edit::DocumentMut>()
Ok(doc) => doc, .map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
Err(e) => {
log::warn!("解析 Codex config.toml 失败: {e},跳过删除操作");
return Ok(());
}
};
// 从正确的位置删除:[mcp_servers] // 从正确的位置删除:[mcp_servers]
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) { if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
@@ -438,8 +412,7 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
} }
// 写回文件 // 写回文件
let new_text = doc.to_string(); std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
crate::config::write_text_file(&config_path, &new_text)?;
Ok(()) Ok(())
} }
-15
View File
@@ -8,12 +8,6 @@ use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec}; use super::validation::{extract_server_spec, validate_server_spec};
fn should_sync_gemini_mcp() -> bool {
// Gemini 未安装/未初始化时:~/.gemini 目录不存在。
// 按用户偏好:目录缺失时跳过写入/删除,不创建任何文件或目录。
crate::gemini_config::get_gemini_dir().exists()
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true /// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> { fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new(); let mut out = HashMap::new();
@@ -39,9 +33,6 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
/// 将 config.json 中 Gemini 的 enabled==true 项写入 Gemini MCP 配置 /// 将 config.json 中 Gemini 的 enabled==true 项写入 Gemini MCP 配置
pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> { pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
if !should_sync_gemini_mcp() {
return Ok(());
}
let enabled = collect_enabled_servers(&config.mcp.gemini); let enabled = collect_enabled_servers(&config.mcp.gemini);
crate::gemini_mcp::set_mcp_servers_map(&enabled) crate::gemini_mcp::set_mcp_servers_map(&enabled)
} }
@@ -112,9 +103,6 @@ pub fn sync_single_server_to_gemini(
id: &str, id: &str,
server_spec: &Value, server_spec: &Value,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
if !should_sync_gemini_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置 // 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?; let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
@@ -127,9 +115,6 @@ pub fn sync_single_server_to_gemini(
/// 从 Gemini live 配置中移除单个 MCP 服务器 /// 从 Gemini live 配置中移除单个 MCP 服务器
pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> { pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
if !should_sync_gemini_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置 // 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?; let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
+5 -287
View File
@@ -36,10 +36,10 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")] #[serde(rename = "iconColor")]
pub icon_color: Option<String>, pub icon_color: Option<String>,
/// 是否加入故障转移队列 /// 是否为代理目标(数据库专用字段,不写入配置文件)
#[serde(default)] #[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "inFailoverQueue")] #[serde(rename = "isProxyTarget")]
pub in_failover_queue: bool, pub is_proxy_target: Option<bool>,
} }
impl Provider { impl Provider {
@@ -62,7 +62,7 @@ impl Provider {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false, is_proxy_target: None,
} }
} }
} }
@@ -173,285 +173,3 @@ impl ProviderManager {
&self.providers &self.providers
} }
} }
// ============================================================================
// 统一供应商(Universal Provider- 跨应用共享配置
// ============================================================================
/// 统一供应商的应用启用状态
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UniversalProviderApps {
#[serde(default)]
pub claude: bool,
#[serde(default)]
pub codex: bool,
#[serde(default)]
pub gemini: bool,
}
/// Claude 模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClaudeModelConfig {
/// 主模型
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Haiku 默认模型
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "haikuModel")]
pub haiku_model: Option<String>,
/// Sonnet 默认模型
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "sonnetModel")]
pub sonnet_model: Option<String>,
/// Opus 默认模型
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "opusModel")]
pub opus_model: Option<String>,
}
/// Codex 模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CodexModelConfig {
/// 模型名称
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// 推理强度
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "reasoningEffort")]
pub reasoning_effort: Option<String>,
}
/// Gemini 模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GeminiModelConfig {
/// 模型名称
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
/// 各应用的模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UniversalProviderModels {
#[serde(skip_serializing_if = "Option::is_none")]
pub claude: Option<ClaudeModelConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub codex: Option<CodexModelConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gemini: Option<GeminiModelConfig>,
}
/// 统一供应商(跨应用共享配置)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UniversalProvider {
/// 唯一标识
pub id: String,
/// 供应商名称
pub name: String,
/// 供应商类型(如 "newapi", "custom"
#[serde(rename = "providerType")]
pub provider_type: String,
/// 应用启用状态
pub apps: UniversalProviderApps,
/// API 基础地址
#[serde(rename = "baseUrl")]
pub base_url: String,
/// API 密钥
#[serde(rename = "apiKey")]
pub api_key: String,
/// 各应用的模型配置
#[serde(default)]
pub models: UniversalProviderModels,
/// 网站链接
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "websiteUrl")]
pub website_url: Option<String>,
/// 备注信息
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
/// 图标名称
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// 图标颜色
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")]
pub icon_color: Option<String>,
/// 元数据
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
/// 创建时间戳
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
/// 排序索引
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "sortIndex")]
pub sort_index: Option<usize>,
}
impl UniversalProvider {
/// 创建新的统一供应商
pub fn new(
id: String,
name: String,
provider_type: String,
base_url: String,
api_key: String,
) -> Self {
Self {
id,
name,
provider_type,
apps: UniversalProviderApps::default(),
base_url,
api_key,
models: UniversalProviderModels::default(),
website_url: None,
notes: None,
icon: None,
icon_color: None,
meta: None,
created_at: Some(chrono::Utc::now().timestamp_millis()),
sort_index: None,
}
}
/// 生成 Claude 供应商配置
pub fn to_claude_provider(&self) -> Option<Provider> {
if !self.apps.claude {
return None;
}
let models = self.models.claude.as_ref();
let model = models
.and_then(|m| m.model.clone())
.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
let haiku = models
.and_then(|m| m.haiku_model.clone())
.unwrap_or_else(|| model.clone());
let sonnet = models
.and_then(|m| m.sonnet_model.clone())
.unwrap_or_else(|| model.clone());
let opus = models
.and_then(|m| m.opus_model.clone())
.unwrap_or_else(|| model.clone());
let settings_config = serde_json::json!({
"env": {
"ANTHROPIC_BASE_URL": self.base_url,
"ANTHROPIC_AUTH_TOKEN": self.api_key,
"ANTHROPIC_MODEL": model,
"ANTHROPIC_DEFAULT_HAIKU_MODEL": haiku,
"ANTHROPIC_DEFAULT_SONNET_MODEL": sonnet,
"ANTHROPIC_DEFAULT_OPUS_MODEL": opus,
}
});
Some(Provider {
id: format!("universal-claude-{}", self.id),
name: self.name.clone(),
settings_config,
website_url: self.website_url.clone(),
category: Some("aggregator".to_string()),
created_at: self.created_at,
sort_index: self.sort_index,
notes: self.notes.clone(),
meta: self.meta.clone(),
icon: self.icon.clone(),
icon_color: self.icon_color.clone(),
in_failover_queue: false,
})
}
/// 生成 Codex 供应商配置
pub fn to_codex_provider(&self) -> Option<Provider> {
if !self.apps.codex {
return None;
}
let models = self.models.codex.as_ref();
let model = models
.and_then(|m| m.model.clone())
.unwrap_or_else(|| "gpt-4o".to_string());
let reasoning_effort = models
.and_then(|m| m.reasoning_effort.clone())
.unwrap_or_else(|| "high".to_string());
// 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API
let codex_base_url = if self.base_url.ends_with("/v1") {
self.base_url.clone()
} else {
format!("{}/v1", self.base_url.trim_end_matches('/'))
};
// 生成 Codex 的 config.toml 内容
let config_toml = format!(
r#"model_provider = "newapi"
model = "{model}"
model_reasoning_effort = "{reasoning_effort}"
disable_response_storage = true
[model_providers.newapi]
name = "NewAPI"
base_url = "{codex_base_url}"
wire_api = "responses"
requires_openai_auth = true"#
);
let settings_config = serde_json::json!({
"auth": {
"OPENAI_API_KEY": self.api_key
},
"config": config_toml
});
Some(Provider {
id: format!("universal-codex-{}", self.id),
name: self.name.clone(),
settings_config,
website_url: self.website_url.clone(),
category: Some("aggregator".to_string()),
created_at: self.created_at,
sort_index: self.sort_index,
notes: self.notes.clone(),
meta: self.meta.clone(),
icon: self.icon.clone(),
icon_color: self.icon_color.clone(),
in_failover_queue: false,
})
}
/// 生成 Gemini 供应商配置
pub fn to_gemini_provider(&self) -> Option<Provider> {
if !self.apps.gemini {
return None;
}
let models = self.models.gemini.as_ref();
let model = models
.and_then(|m| m.model.clone())
.unwrap_or_else(|| "gemini-2.5-pro".to_string());
let settings_config = serde_json::json!({
"env": {
"GOOGLE_GEMINI_BASE_URL": self.base_url,
"GEMINI_API_KEY": self.api_key,
"GEMINI_MODEL": model,
}
});
Some(Provider {
id: format!("universal-gemini-{}", self.id),
name: self.name.clone(),
settings_config,
website_url: self.website_url.clone(),
category: Some("aggregator".to_string()),
created_at: self.created_at,
sort_index: self.sort_index,
notes: self.notes.clone(),
meta: self.meta.clone(),
icon: self.icon.clone(),
icon_color: self.icon_color.clone(),
in_failover_queue: false,
})
}
}
-303
View File
@@ -1,303 +0,0 @@
//! 请求体过滤模块
//!
//! 过滤不应透传到上游的私有参数,防止内部信息泄露。
//!
//! ## 过滤规则
//! - 以 `_` 开头的字段被视为私有参数,会被递归过滤
//! - 支持白名单机制,允许透传特定的 `_` 前缀字段
//! - 支持嵌套对象和数组的深度过滤
//!
//! ## 使用场景
//! - `_internal_id`: 内部追踪 ID
//! - `_debug_mode`: 调试标记
//! - `_session_token`: 会话令牌
//! - `_client_version`: 客户端版本
use serde_json::Value;
use std::collections::HashSet;
/// 过滤私有参数(以 `_` 开头的字段)
///
/// 递归遍历 JSON 结构,移除所有以下划线开头的字段。
///
/// # Arguments
/// * `body` - 原始请求体
///
/// # Returns
/// 过滤后的请求体
///
/// # Example
/// ```ignore
/// let input = json!({
/// "model": "claude-3",
/// "_internal_id": "abc123",
/// "messages": [{"role": "user", "content": "hello", "_token": "secret"}]
/// });
/// let output = filter_private_params(input);
/// // output 中不包含 _internal_id 和 _token
/// ```
#[cfg(test)]
pub fn filter_private_params(body: Value) -> Value {
filter_private_params_with_whitelist(body, &[])
}
/// 过滤私有参数(支持白名单)
///
/// 递归遍历 JSON 结构,移除所有以下划线开头的字段,
/// 但保留白名单中指定的字段。
///
/// # Arguments
/// * `body` - 原始请求体
/// * `whitelist` - 白名单字段列表(不过滤这些字段)
///
/// # Returns
/// 过滤后的请求体
///
/// # Example
/// ```ignore
/// let input = json!({
/// "model": "claude-3",
/// "_metadata": {"key": "value"}, // 白名单中,保留
/// "_internal_id": "abc123" // 不在白名单中,过滤
/// });
/// let output = filter_private_params_with_whitelist(input, &["_metadata"]);
/// // output 包含 _metadata,不包含 _internal_id
/// ```
pub fn filter_private_params_with_whitelist(body: Value, whitelist: &[String]) -> Value {
let whitelist_set: HashSet<&str> = whitelist.iter().map(|s| s.as_str()).collect();
filter_recursive_with_whitelist(body, &mut Vec::new(), &whitelist_set)
}
/// 递归过滤实现
#[cfg(test)]
fn filter_recursive(value: Value, removed_keys: &mut Vec<String>) -> Value {
filter_recursive_with_whitelist(value, removed_keys, &HashSet::new())
}
/// 递归过滤实现(支持白名单)
fn filter_recursive_with_whitelist(
value: Value,
removed_keys: &mut Vec<String>,
whitelist: &HashSet<&str>,
) -> Value {
match value {
Value::Object(map) => {
let filtered: serde_json::Map<String, Value> = map
.into_iter()
.filter_map(|(key, val)| {
// 以 _ 开头且不在白名单中的字段被过滤
if key.starts_with('_') && !whitelist.contains(key.as_str()) {
removed_keys.push(key);
None
} else {
Some((
key,
filter_recursive_with_whitelist(val, removed_keys, whitelist),
))
}
})
.collect();
// 仅在有过滤时记录日志(避免每次请求都打印)
if !removed_keys.is_empty() {
log::debug!("[BodyFilter] 过滤私有参数: {removed_keys:?}");
removed_keys.clear();
}
Value::Object(filtered)
}
Value::Array(arr) => Value::Array(
arr.into_iter()
.map(|v| filter_recursive_with_whitelist(v, removed_keys, whitelist))
.collect(),
),
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_filter_top_level_private_params() {
let input = json!({
"model": "claude-3",
"_internal_id": "abc123",
"_debug": true,
"max_tokens": 1024
});
let output = filter_private_params(input);
assert!(output.get("model").is_some());
assert!(output.get("max_tokens").is_some());
assert!(output.get("_internal_id").is_none());
assert!(output.get("_debug").is_none());
}
#[test]
fn test_filter_nested_private_params() {
let input = json!({
"model": "claude-3",
"messages": [
{
"role": "user",
"content": "hello",
"_session_token": "secret"
}
],
"metadata": {
"user_id": "user-1",
"_tracking_id": "track-1"
}
});
let output = filter_private_params(input);
// 顶级字段保留
assert!(output.get("model").is_some());
assert!(output.get("messages").is_some());
assert!(output.get("metadata").is_some());
// messages 数组中的私有参数被过滤
let messages = output.get("messages").unwrap().as_array().unwrap();
assert!(messages[0].get("role").is_some());
assert!(messages[0].get("content").is_some());
assert!(messages[0].get("_session_token").is_none());
// metadata 对象中的私有参数被过滤
let metadata = output.get("metadata").unwrap();
assert!(metadata.get("user_id").is_some());
assert!(metadata.get("_tracking_id").is_none());
}
#[test]
fn test_filter_deeply_nested() {
let input = json!({
"level1": {
"level2": {
"level3": {
"keep": "value",
"_remove": "secret"
}
}
}
});
let output = filter_private_params(input);
let level3 = output
.get("level1")
.unwrap()
.get("level2")
.unwrap()
.get("level3")
.unwrap();
assert!(level3.get("keep").is_some());
assert!(level3.get("_remove").is_none());
}
#[test]
fn test_filter_array_of_objects() {
let input = json!({
"items": [
{"id": 1, "_secret": "a"},
{"id": 2, "_secret": "b"},
{"id": 3, "_secret": "c"}
]
});
let output = filter_private_params(input);
let items = output.get("items").unwrap().as_array().unwrap();
for item in items {
assert!(item.get("id").is_some());
assert!(item.get("_secret").is_none());
}
}
#[test]
fn test_no_private_params() {
let input = json!({
"model": "claude-3",
"messages": [{"role": "user", "content": "hello"}]
});
let output = filter_private_params(input.clone());
// 无私有参数时,输出应与输入相同
assert_eq!(input, output);
}
#[test]
fn test_empty_object() {
let input = json!({});
let output = filter_private_params(input);
assert_eq!(output, json!({}));
}
#[test]
fn test_primitive_values() {
// 原始值不应被修改
assert_eq!(filter_private_params(json!(42)), json!(42));
assert_eq!(filter_private_params(json!("string")), json!("string"));
assert_eq!(filter_private_params(json!(true)), json!(true));
assert_eq!(filter_private_params(json!(null)), json!(null));
}
#[test]
fn test_whitelist_preserves_private_params() {
let input = json!({
"model": "claude-3",
"_metadata": {"key": "value"},
"_internal_id": "abc123",
"_stream_options": {"include_usage": true}
});
let whitelist = vec!["_metadata".to_string(), "_stream_options".to_string()];
let output = filter_private_params_with_whitelist(input, &whitelist);
// 白名单中的字段保留
assert!(output.get("_metadata").is_some());
assert!(output.get("_stream_options").is_some());
// 不在白名单中的私有字段被过滤
assert!(output.get("_internal_id").is_none());
// 普通字段保留
assert!(output.get("model").is_some());
}
#[test]
fn test_whitelist_nested() {
let input = json!({
"data": {
"_allowed": "keep",
"_forbidden": "remove",
"normal": "value"
}
});
let whitelist = vec!["_allowed".to_string()];
let output = filter_private_params_with_whitelist(input, &whitelist);
let data = output.get("data").unwrap();
assert!(data.get("_allowed").is_some());
assert!(data.get("_forbidden").is_none());
assert!(data.get("normal").is_some());
}
#[test]
fn test_empty_whitelist_same_as_default() {
let input = json!({
"model": "claude-3",
"_internal_id": "abc123"
});
let output1 = filter_private_params(input.clone());
let output2 = filter_private_params_with_whitelist(input, &[]);
assert_eq!(output1, output2);
}
}
-506
View File
@@ -1,506 +0,0 @@
//! 熔断器模块
//!
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::RwLock;
/// 熔断器状态
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CircuitState {
/// 关闭状态 - 正常工作
Closed,
/// 打开状态 - 熔断激活,拒绝请求
Open,
/// 半开状态 - 尝试恢复,允许部分请求通过
HalfOpen,
}
impl std::fmt::Display for CircuitState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CircuitState::Closed => write!(f, "closed"),
CircuitState::Open => write!(f, "open"),
CircuitState::HalfOpen => write!(f, "half_open"),
}
}
}
/// 熔断器配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CircuitBreakerConfig {
/// 失败阈值 - 连续失败多少次后打开熔断器
pub failure_threshold: u32,
/// 成功阈值 - 半开状态下成功多少次后关闭熔断器
pub success_threshold: u32,
/// 超时时间 - 熔断器打开后多久尝试半开(秒)
pub timeout_seconds: u64,
/// 错误率阈值 - 错误率超过此值时打开熔断器 (0.0-1.0)
pub error_rate_threshold: f64,
/// 最小请求数 - 计算错误率前的最小请求数
pub min_requests: u32,
}
impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
failure_threshold: 5,
success_threshold: 2,
timeout_seconds: 60,
error_rate_threshold: 0.5,
min_requests: 10,
}
}
}
/// 熔断器实例
pub struct CircuitBreaker {
/// 当前状态
state: Arc<RwLock<CircuitState>>,
/// 连续失败计数
consecutive_failures: Arc<AtomicU32>,
/// 连续成功计数(半开状态)
consecutive_successes: Arc<AtomicU32>,
/// 总请求计数
total_requests: Arc<AtomicU32>,
/// 失败请求计数
failed_requests: Arc<AtomicU32>,
/// 上次打开时间
last_opened_at: Arc<RwLock<Option<Instant>>>,
/// 配置(支持热更新)
config: Arc<RwLock<CircuitBreakerConfig>>,
/// 半开状态已放行的请求数(用于限流)
half_open_requests: Arc<AtomicU32>,
}
/// 熔断器放行结果
///
/// `used_half_open_permit` 表示本次放行是否占用了 HalfOpen 探测名额。
/// 调用方应在请求结束后把该值传回 `record_success` / `record_failure` 用于正确释放名额。
#[derive(Debug, Clone, Copy)]
pub struct AllowResult {
pub allowed: bool,
pub used_half_open_permit: bool,
}
impl CircuitBreaker {
/// 创建新的熔断器
pub fn new(config: CircuitBreakerConfig) -> Self {
Self {
state: Arc::new(RwLock::new(CircuitState::Closed)),
consecutive_failures: Arc::new(AtomicU32::new(0)),
consecutive_successes: Arc::new(AtomicU32::new(0)),
total_requests: Arc::new(AtomicU32::new(0)),
failed_requests: Arc::new(AtomicU32::new(0)),
last_opened_at: Arc::new(RwLock::new(None)),
config: Arc::new(RwLock::new(config)),
half_open_requests: Arc::new(AtomicU32::new(0)),
}
}
/// 更新熔断器配置(热更新,不重置状态)
pub async fn update_config(&self, new_config: CircuitBreakerConfig) {
*self.config.write().await = new_config;
log::debug!("Circuit breaker config updated");
}
/// 判断当前 Provider 是否“可被纳入候选链路”
///
/// 这个方法不会占用 HalfOpen 探测名额,仅用于路由选择阶段的“可用性判断”:
/// - Closed / HalfOpen:可用(返回 true
/// - Open:若超时到达则切到 HalfOpen 并返回 true,否则返回 false
///
/// 注意:真正发起请求前仍需调用 `allow_request()` 来获取 HalfOpen 探测名额,
/// 并在请求结束后通过 `record_success()` / `record_failure()` 释放。
pub async fn is_available(&self) -> bool {
let state = *self.state.read().await;
let config = self.config.read().await;
match state {
CircuitState::Closed | CircuitState::HalfOpen => true,
CircuitState::Open => {
if let Some(opened_at) = *self.last_opened_at.read().await {
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态
log::info!(
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
);
self.transition_to_half_open().await;
return true;
}
}
false
}
}
}
/// 检查是否允许请求通过
pub async fn allow_request(&self) -> AllowResult {
let state = *self.state.read().await;
match state {
CircuitState::Closed => AllowResult {
allowed: true,
used_half_open_permit: false,
},
CircuitState::Open => {
let config = self.config.read().await;
// 检查是否应该尝试半开
if let Some(opened_at) = *self.last_opened_at.read().await {
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态
log::info!(
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
);
self.transition_to_half_open().await;
// 转换后按当前状态决定是否需要获取 HalfOpen 探测名额
let current_state = *self.state.read().await;
return match current_state {
CircuitState::Closed => AllowResult {
allowed: true,
used_half_open_permit: false,
},
CircuitState::HalfOpen => self.allow_half_open_probe(),
CircuitState::Open => AllowResult {
allowed: false,
used_half_open_permit: false,
},
};
}
}
AllowResult {
allowed: false,
used_half_open_permit: false,
}
}
CircuitState::HalfOpen => self.allow_half_open_probe(),
}
}
/// 记录成功
pub async fn record_success(&self, used_half_open_permit: bool) {
let state = *self.state.read().await;
let config = self.config.read().await;
if used_half_open_permit {
self.release_half_open_permit();
}
// 重置失败计数
self.consecutive_failures.store(0, Ordering::SeqCst);
self.total_requests.fetch_add(1, Ordering::SeqCst);
match state {
CircuitState::HalfOpen => {
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
log::debug!(
"Circuit breaker HalfOpen: {} consecutive successes (threshold: {})",
successes,
config.success_threshold
);
if successes >= config.success_threshold {
drop(config); // 释放读锁再转换状态
log::info!("Circuit breaker transitioning from HalfOpen to Closed (success threshold reached)");
self.transition_to_closed().await;
}
}
CircuitState::Closed => {
log::debug!("Circuit breaker Closed: request succeeded");
}
_ => {}
}
}
/// 记录失败
pub async fn record_failure(&self, used_half_open_permit: bool) {
let state = *self.state.read().await;
let config = self.config.read().await;
if used_half_open_permit {
self.release_half_open_permit();
}
// 更新计数器
let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
self.total_requests.fetch_add(1, Ordering::SeqCst);
self.failed_requests.fetch_add(1, Ordering::SeqCst);
// 重置成功计数
self.consecutive_successes.store(0, Ordering::SeqCst);
log::debug!(
"Circuit breaker {:?}: {} consecutive failures (threshold: {})",
state,
failures,
config.failure_threshold
);
// 检查是否应该打开熔断器
match state {
CircuitState::HalfOpen => {
// HalfOpen 状态下失败,立即转为 Open
log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open");
drop(config);
self.transition_to_open().await;
}
CircuitState::Closed => {
// 检查连续失败次数
if failures >= config.failure_threshold {
log::warn!(
"Circuit breaker opening due to {} consecutive failures (threshold: {})",
failures,
config.failure_threshold
);
drop(config); // 释放读锁再转换状态
self.transition_to_open().await;
} else {
// 检查错误率
let total = self.total_requests.load(Ordering::SeqCst);
let failed = self.failed_requests.load(Ordering::SeqCst);
if total >= config.min_requests {
let error_rate = failed as f64 / total as f64;
log::debug!(
"Circuit breaker error rate: {:.2}% ({}/{} requests)",
error_rate * 100.0,
failed,
total
);
if error_rate >= config.error_rate_threshold {
log::warn!(
"Circuit breaker opening due to high error rate: {:.2}% (threshold: {:.2}%)",
error_rate * 100.0,
config.error_rate_threshold * 100.0
);
drop(config); // 释放读锁再转换状态
self.transition_to_open().await;
}
}
}
}
_ => {}
}
}
/// 获取当前状态
#[allow(dead_code)]
pub async fn get_state(&self) -> CircuitState {
*self.state.read().await
}
/// 获取统计信息
#[allow(dead_code)]
pub async fn get_stats(&self) -> CircuitBreakerStats {
CircuitBreakerStats {
state: *self.state.read().await,
consecutive_failures: self.consecutive_failures.load(Ordering::SeqCst),
consecutive_successes: self.consecutive_successes.load(Ordering::SeqCst),
total_requests: self.total_requests.load(Ordering::SeqCst),
failed_requests: self.failed_requests.load(Ordering::SeqCst),
}
}
/// 重置熔断器(手动恢复)
#[allow(dead_code)]
pub async fn reset(&self) {
log::info!("Circuit breaker manually reset to Closed state");
self.transition_to_closed().await;
}
fn allow_half_open_probe(&self) -> AllowResult {
// 半开状态限流:只允许有限请求通过进行探测
// 默认最多允许 1 个请求(可在配置中扩展)
let max_half_open_requests = 1u32;
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
if current < max_half_open_requests {
log::debug!(
"Circuit breaker HalfOpen: allowing probe request ({}/{})",
current + 1,
max_half_open_requests
);
AllowResult {
allowed: true,
used_half_open_permit: true,
}
} else {
// 超过限额,回退计数,拒绝请求
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
log::debug!(
"Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
);
AllowResult {
allowed: false,
used_half_open_permit: false,
}
}
}
fn release_half_open_permit(&self) {
let mut current = self.half_open_requests.load(Ordering::SeqCst);
loop {
if current == 0 {
// 理论上不应该发生:说明调用方传入的 used_half_open_permit 与实际占用不一致
log::debug!("Circuit breaker HalfOpen permit already released (counter=0)");
return;
}
match self.half_open_requests.compare_exchange(
current,
current - 1,
Ordering::SeqCst,
Ordering::SeqCst,
) {
Ok(_) => return,
Err(actual) => current = actual,
}
}
}
/// 转换到打开状态
async fn transition_to_open(&self) {
*self.state.write().await = CircuitState::Open;
*self.last_opened_at.write().await = Some(Instant::now());
self.consecutive_failures.store(0, Ordering::SeqCst);
self.consecutive_successes.store(0, Ordering::SeqCst);
}
/// 转换到半开状态
async fn transition_to_half_open(&self) {
let mut state = self.state.write().await;
if *state != CircuitState::Open {
return;
}
*state = CircuitState::HalfOpen;
self.consecutive_successes.store(0, Ordering::SeqCst);
// 重置半开状态的请求限流计数
self.half_open_requests.store(0, Ordering::SeqCst);
}
/// 转换到关闭状态
async fn transition_to_closed(&self) {
*self.state.write().await = CircuitState::Closed;
self.consecutive_failures.store(0, Ordering::SeqCst);
self.consecutive_successes.store(0, Ordering::SeqCst);
// 重置计数器
self.total_requests.store(0, Ordering::SeqCst);
self.failed_requests.store(0, Ordering::SeqCst);
}
}
/// 熔断器统计信息
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CircuitBreakerStats {
pub state: CircuitState,
pub consecutive_failures: u32,
pub consecutive_successes: u32,
pub total_requests: u32,
pub failed_requests: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_circuit_breaker_closed_to_open() {
let config = CircuitBreakerConfig {
failure_threshold: 3,
..Default::default()
};
let breaker = CircuitBreaker::new(config);
// 初始状态应该是关闭
assert_eq!(breaker.get_state().await, CircuitState::Closed);
assert!(breaker.allow_request().await.allowed);
// 记录 3 次失败
for _ in 0..3 {
breaker.record_failure(false).await;
}
// 应该转换到打开状态
assert_eq!(breaker.get_state().await, CircuitState::Open);
assert!(!breaker.allow_request().await.allowed);
}
#[tokio::test]
async fn test_circuit_breaker_half_open_to_closed() {
let config = CircuitBreakerConfig {
failure_threshold: 2,
success_threshold: 2,
..Default::default()
};
let breaker = CircuitBreaker::new(config);
// 打开熔断器
breaker.record_failure(false).await;
breaker.record_failure(false).await;
assert_eq!(breaker.get_state().await, CircuitState::Open);
// 手动转换到半开状态
breaker.transition_to_half_open().await;
assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);
// 记录 2 次成功
breaker.record_success(false).await;
breaker.record_success(false).await;
// 应该转换到关闭状态
assert_eq!(breaker.get_state().await, CircuitState::Closed);
}
#[tokio::test]
async fn test_half_open_transition_does_not_reset_inflight_permit() {
let config = CircuitBreakerConfig {
timeout_seconds: 0,
..Default::default()
};
let breaker = CircuitBreaker::new(config);
// 进入 Open,然后由于 timeout_seconds=0allow_request 会立即切换到 HalfOpen 并占用探测名额
breaker.transition_to_open().await;
let first = breaker.allow_request().await;
assert!(first.allowed);
assert!(first.used_half_open_permit);
assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);
// 模拟并发下的“重复 HalfOpen 转换调用”,不应重置 in-flight 计数
breaker.transition_to_half_open().await;
// 由于名额仍被占用,第二次请求应被拒绝
let second = breaker.allow_request().await;
assert!(!second.allowed);
assert!(!second.used_half_open_permit);
}
#[tokio::test]
async fn test_circuit_breaker_reset() {
let config = CircuitBreakerConfig {
failure_threshold: 2,
..Default::default()
};
let breaker = CircuitBreaker::new(config);
// 打开熔断器
breaker.record_failure(false).await;
breaker.record_failure(false).await;
assert_eq!(breaker.get_state().await, CircuitState::Open);
// 重置
breaker.reset().await;
assert_eq!(breaker.get_state().await, CircuitState::Closed);
assert!(breaker.allow_request().await.allowed);
}
}
-12
View File
@@ -23,12 +23,6 @@ pub enum ProxyError {
#[error("无可用的Provider")] #[error("无可用的Provider")]
NoAvailableProvider, NoAvailableProvider,
#[error("所有供应商已熔断,无可用渠道")]
AllProvidersCircuitOpen,
#[error("未配置供应商")]
NoProvidersConfigured,
#[allow(dead_code)] #[allow(dead_code)]
#[error("Provider不健康: {0}")] #[error("Provider不健康: {0}")]
ProviderUnhealthy(String), ProviderUnhealthy(String),
@@ -117,12 +111,6 @@ impl IntoResponse for ProxyError {
ProxyError::NoAvailableProvider => { ProxyError::NoAvailableProvider => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string()) (StatusCode::SERVICE_UNAVAILABLE, self.to_string())
} }
ProxyError::AllProvidersCircuitOpen => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
}
ProxyError::NoProvidersConfigured => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
}
ProxyError::ProviderUnhealthy(_) => { ProxyError::ProviderUnhealthy(_) => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string()) (StatusCode::SERVICE_UNAVAILABLE, self.to_string())
} }
-118
View File
@@ -1,118 +0,0 @@
//! 错误类型到 HTTP 状态码的映射
//!
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录
use super::ProxyError;
/// 将 ProxyError 映射到 HTTP 状态码
///
/// 映射规则:
/// - 上游错误:直接使用上游返回的状态码
/// - 超时:504 Gateway Timeout
/// - 连接失败:502 Bad Gateway
/// - 无可用 Provider503 Service Unavailable
/// - 重试耗尽:503 Service Unavailable
/// - 其他错误:500 Internal Server Error
pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
match error {
// 上游错误:使用实际状态码
ProxyError::UpstreamError { status, .. } => *status,
// 超时错误:504 Gateway Timeout
ProxyError::Timeout(_) => 504,
// 转发失败/连接失败:502 Bad Gateway
ProxyError::ForwardFailed(_) => 502,
// 无可用 Provider503 Service Unavailable
ProxyError::NoAvailableProvider => 503,
// 所有供应商已熔断:503 Service Unavailable
ProxyError::AllProvidersCircuitOpen => 503,
// 未配置供应商:503 Service Unavailable
ProxyError::NoProvidersConfigured => 503,
// 重试耗尽:503 Service Unavailable
ProxyError::MaxRetriesExceeded => 503,
// Provider 不健康:503 Service Unavailable
ProxyError::ProviderUnhealthy(_) => 503,
// 数据库错误:500 Internal Server Error
ProxyError::DatabaseError(_) => 500,
// 转换错误:500 Internal Server Error
ProxyError::TransformError(_) => 500,
// 其他未知错误:500 Internal Server Error
_ => 500,
}
}
/// 将 ProxyError 转换为用户友好的错误消息
pub fn get_error_message(error: &ProxyError) -> String {
match error {
ProxyError::UpstreamError { status, body } => {
if let Some(body) = body {
format!("上游错误 ({status}): {body}")
} else {
format!("上游错误 ({status})")
}
}
ProxyError::Timeout(msg) => format!("请求超时: {msg}"),
ProxyError::ForwardFailed(msg) => format!("转发失败: {msg}"),
ProxyError::NoAvailableProvider => "无可用 Provider".to_string(),
ProxyError::AllProvidersCircuitOpen => "所有供应商已熔断,无可用渠道".to_string(),
ProxyError::NoProvidersConfigured => "未配置供应商".to_string(),
ProxyError::MaxRetriesExceeded => "所有 Provider 都失败,重试耗尽".to_string(),
ProxyError::ProviderUnhealthy(msg) => format!("Provider 不健康: {msg}"),
ProxyError::DatabaseError(msg) => format!("数据库错误: {msg}"),
ProxyError::TransformError(msg) => format!("请求/响应转换错误: {msg}"),
_ => error.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_map_upstream_error() {
let error = ProxyError::UpstreamError {
status: 401,
body: Some("Unauthorized".to_string()),
};
assert_eq!(map_proxy_error_to_status(&error), 401);
}
#[test]
fn test_map_timeout_error() {
let error = ProxyError::Timeout("Request timeout".to_string());
assert_eq!(map_proxy_error_to_status(&error), 504);
}
#[test]
fn test_map_connection_error() {
let error = ProxyError::ForwardFailed("Connection refused".to_string());
assert_eq!(map_proxy_error_to_status(&error), 502);
}
#[test]
fn test_map_no_provider_error() {
let error = ProxyError::NoAvailableProvider;
assert_eq!(map_proxy_error_to_status(&error), 503);
}
#[test]
fn test_get_error_message() {
let error = ProxyError::UpstreamError {
status: 500,
body: Some("Internal Server Error".to_string()),
};
let msg = get_error_message(&error);
assert!(msg.contains("上游错误"));
assert!(msg.contains("500"));
assert!(msg.contains("Internal Server Error"));
}
}
-149
View File
@@ -1,149 +0,0 @@
//! 故障转移切换模块
//!
//! 处理故障转移成功后的供应商切换逻辑,包括:
//! - 去重控制(避免多个请求同时触发)
//! - 数据库更新
//! - 托盘菜单更新
//! - 前端事件发射
//! - Live 备份更新
use crate::database::Database;
use crate::error::AppError;
use std::collections::HashSet;
use std::str::FromStr;
use std::sync::Arc;
use tauri::{Emitter, Manager};
use tokio::sync::RwLock;
/// 故障转移切换管理器
///
/// 负责处理故障转移成功后的供应商切换,确保 UI 能够直观反映当前使用的供应商。
#[derive(Clone)]
pub struct FailoverSwitchManager {
/// 正在处理中的切换(key = "app_type:provider_id"
pending_switches: Arc<RwLock<HashSet<String>>>,
db: Arc<Database>,
}
impl FailoverSwitchManager {
pub fn new(db: Arc<Database>) -> Self {
Self {
pending_switches: Arc::new(RwLock::new(HashSet::new())),
db,
}
}
/// 尝试执行故障转移切换
///
/// 如果相同的切换已在进行中,则跳过;否则执行切换逻辑。
///
/// # Returns
/// - `Ok(true)` - 切换成功执行
/// - `Ok(false)` - 切换已在进行中,跳过
/// - `Err(e)` - 切换过程中发生错误
pub async fn try_switch(
&self,
app_handle: Option<&tauri::AppHandle>,
app_type: &str,
provider_id: &str,
provider_name: &str,
) -> Result<bool, AppError> {
let switch_key = format!("{app_type}:{provider_id}");
// 去重检查:如果相同切换已在进行中,跳过
{
let mut pending = self.pending_switches.write().await;
if pending.contains(&switch_key) {
log::debug!("[Failover] 切换已在进行中,跳过: {app_type} -> {provider_id}");
return Ok(false);
}
pending.insert(switch_key.clone());
}
// 执行切换(确保最后清理 pending 标记)
let result = self
.do_switch(app_handle, app_type, provider_id, provider_name)
.await;
// 清理 pending 标记
{
let mut pending = self.pending_switches.write().await;
pending.remove(&switch_key);
}
result
}
async fn do_switch(
&self,
app_handle: Option<&tauri::AppHandle>,
app_type: &str,
provider_id: &str,
provider_name: &str,
) -> Result<bool, AppError> {
// 检查该应用是否已被代理接管(enabled=true
// 只有被接管的应用才允许执行故障转移切换
let app_enabled = match self.db.get_proxy_config_for_app(app_type).await {
Ok(config) => config.enabled,
Err(e) => {
log::warn!("[Failover] 无法读取 {app_type} 配置: {e},跳过切换");
return Ok(false);
}
};
if !app_enabled {
log::info!("[Failover] {app_type} 未被代理接管(enabled=false),跳过切换");
return Ok(false);
}
log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})");
// 1. 更新数据库 is_current
self.db.set_current_provider(app_type, provider_id)?;
// 2. 更新本地 settings(设备级)
let app_type_enum = crate::app_config::AppType::from_str(app_type)
.map_err(|_| AppError::Message(format!("无效的应用类型: {app_type}")))?;
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?;
// 3. 更新托盘菜单和发射事件
if let Some(app) = app_handle {
// 更新托盘菜单
if let Some(app_state) = app.try_state::<crate::store::AppState>() {
// 更新 Live 备份(确保代理停止时恢复正确配置)
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
if let Err(e) = app_state
.proxy_service
.update_live_backup_from_provider(app_type, &provider)
.await
{
log::warn!("[Failover] 更新 Live 备份失败: {e}");
}
}
// 重建托盘菜单
if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("[Failover] 更新托盘菜单失败: {e}");
}
}
}
}
// 发射事件到前端
let event_data = serde_json::json!({
"appType": app_type,
"providerId": provider_id,
"source": "failover" // 标识来源是故障转移
});
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("[Failover] 发射供应商切换事件失败: {e}");
}
}
log::info!("[Failover] 供应商切换完成: {app_type} -> {provider_name} ({provider_id})");
Ok(true)
}
}
+83 -452
View File
@@ -1,136 +1,38 @@
//! 请求转发器 //! 请求转发器
//! //!
//! 负责将请求转发到上游Provider,支持故障转移 //! 负责将请求转发到上游Provider,支持重试和故障转移
use super::{ use super::{
body_filter::filter_private_params_with_whitelist,
error::*, error::*,
failover_switch::FailoverSwitchManager,
provider_router::ProviderRouter,
providers::{get_adapter, ProviderAdapter}, providers::{get_adapter, ProviderAdapter},
router::ProviderRouter,
types::ProxyStatus, types::ProxyStatus,
ProxyError, ProxyError,
}; };
use crate::{app_config::AppType, provider::Provider}; use crate::{app_config::AppType, database::Database, provider::Provider};
use reqwest::{Client, Response}; use reqwest::{Client, Response};
use serde_json::Value; use serde_json::Value;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::RwLock; use tokio::sync::RwLock;
/// Headers 黑名单 - 不透传到上游的 Headers
///
/// 参考 Claude Code Hub 设计,过滤以下类别:
/// 1. 认证类(会被覆盖)
/// 2. 连接类(由 HTTP 客户端管理)
/// 3. 代理转发类
/// 4. CDN/云服务商特定头
/// 5. 请求追踪类
/// 6. 浏览器特定头(可能被上游检测)
///
/// 注意:客户端 IP 类(x-forwarded-for, x-real-ip)默认透传
const HEADER_BLACKLIST: &[&str] = &[
// 认证类(会被覆盖)
"authorization",
"x-api-key",
// 连接类
"host",
"content-length",
"connection",
"transfer-encoding",
// 编码类(会被覆盖为 identity)
"accept-encoding",
// 代理转发类(保留 x-forwarded-for 和 x-real-ip
"x-forwarded-host",
"x-forwarded-port",
"x-forwarded-proto",
"forwarded",
// CDN/云服务商特定头
"cf-connecting-ip",
"cf-ipcountry",
"cf-ray",
"cf-visitor",
"true-client-ip",
"fastly-client-ip",
"x-azure-clientip",
"x-azure-fdid",
"x-azure-ref",
"akamai-origin-hop",
"x-akamai-config-log-detail",
// 请求追踪类
"x-request-id",
"x-correlation-id",
"x-trace-id",
"x-amzn-trace-id",
"x-b3-traceid",
"x-b3-spanid",
"x-b3-parentspanid",
"x-b3-sampled",
"traceparent",
"tracestate",
// 浏览器特定头(可能被上游检测为非 CLI 请求)
"sec-fetch-mode",
"sec-fetch-site",
"sec-fetch-dest",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"accept-language",
// anthropic-beta 单独处理,避免重复
"anthropic-beta",
// 客户端 IP 单独处理(默认透传)
"x-forwarded-for",
"x-real-ip",
];
pub struct ForwardResult {
pub response: Response,
pub provider: Provider,
}
pub struct ForwardError {
pub error: ProxyError,
pub provider: Option<Provider>,
}
pub struct RequestForwarder { pub struct RequestForwarder {
client: Client, client: Client,
/// 共享的 ProviderRouter(持有熔断器状态) router: ProviderRouter,
router: Arc<ProviderRouter>, max_retries: u8,
status: Arc<RwLock<ProxyStatus>>, status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
/// 故障转移切换管理器
failover_manager: Arc<FailoverSwitchManager>,
/// AppHandle,用于发射事件和更新托盘
app_handle: Option<tauri::AppHandle>,
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
current_provider_id_at_start: String,
} }
impl RequestForwarder { impl RequestForwarder {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
router: Arc<ProviderRouter>, db: Arc<Database>,
non_streaming_timeout: u64, timeout_secs: u64,
max_retries: u8,
status: Arc<RwLock<ProxyStatus>>, status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
failover_manager: Arc<FailoverSwitchManager>,
app_handle: Option<tauri::AppHandle>,
current_provider_id_at_start: String,
_streaming_first_byte_timeout: u64,
_streaming_idle_timeout: u64,
) -> Self { ) -> Self {
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
// 参考 Claude Code Hub 的 undici 全局超时设计
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
let mut client_builder = Client::builder(); let mut client_builder = Client::builder();
if non_streaming_timeout > 0 { if timeout_secs > 0 {
// 使用配置的非流式超时 client_builder = client_builder.timeout(Duration::from_secs(timeout_secs));
client_builder = client_builder.timeout(Duration::from_secs(non_streaming_timeout));
} else {
// 禁用超时时使用全局超时作为保底
client_builder = client_builder.timeout(Duration::from_secs(GLOBAL_TIMEOUT_SECS));
} }
let client = client_builder let client = client_builder
@@ -139,87 +41,35 @@ impl RequestForwarder {
Self { Self {
client, client,
router, router: ProviderRouter::new(db),
max_retries,
status, status,
current_providers,
failover_manager,
app_handle,
current_provider_id_at_start,
} }
} }
/// 转发请求(带故障转移) /// 转发请求(带重试和故障转移)
///
/// # Arguments
/// * `app_type` - 应用类型
/// * `endpoint` - API 端点
/// * `body` - 请求体
/// * `headers` - 请求头
/// * `providers` - 已选择的 Provider 列表(由 RequestContext 提供,避免重复调用 select_providers
pub async fn forward_with_retry( pub async fn forward_with_retry(
&self, &self,
app_type: &AppType, app_type: &AppType,
endpoint: &str, endpoint: &str,
body: Value, body: Value,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
providers: Vec<Provider>, ) -> Result<Response, ProxyError> {
) -> Result<ForwardResult, ForwardError> { let mut failed_ids = Vec::new();
let mut failover_happened = false;
// 获取适配器 // 获取适配器
let adapter = get_adapter(app_type); let adapter = get_adapter(app_type);
let app_type_str = app_type.as_str();
if providers.is_empty() { for attempt in 0..self.max_retries {
return Err(ForwardError { // 选择Provider
error: ProxyError::NoAvailableProvider, let provider = self.router.select_provider(app_type, &failed_ids).await?;
provider: None,
});
}
log::info!( log::debug!(
"[{}] 故障转移链: {} 个可用供应商", "尝试 {} - 使用Provider: {} ({})",
app_type_str, attempt + 1,
providers.len()
);
let mut last_error = None;
let mut last_provider = None;
let mut attempted_providers = 0usize;
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
let bypass_circuit_breaker = providers.len() == 1;
// 依次尝试每个供应商
for provider in providers.iter() {
// 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额)
// 单 Provider 场景下跳过此检查,避免熔断器阻塞所有请求
let (allowed, used_half_open_permit) = if bypass_circuit_breaker {
(true, false)
} else {
let permit = self
.router
.allow_provider_request(&provider.id, app_type_str)
.await;
(permit.allowed, permit.used_half_open_permit)
};
if !allowed {
log::debug!(
"[{}] Provider {} 熔断器拒绝本次请求,跳过",
app_type_str,
provider.name
);
continue;
}
attempted_providers += 1;
log::info!(
"[{}] 尝试 {}/{} - 使用Provider: {} (sort_index: {})",
app_type_str,
attempted_providers,
providers.len(),
provider.name, provider.name,
provider.sort_index.unwrap_or(999999) provider.id
); );
// 更新状态中的当前Provider信息 // 更新状态中的当前Provider信息
@@ -229,71 +79,33 @@ impl RequestForwarder {
status.current_provider_id = Some(provider.id.clone()); status.current_provider_id = Some(provider.id.clone());
status.total_requests += 1; status.total_requests += 1;
status.last_request_at = Some(chrono::Utc::now().to_rfc3339()); status.last_request_at = Some(chrono::Utc::now().to_rfc3339());
if attempt > 0 {
failover_happened = true;
}
} }
let start = Instant::now(); let start = Instant::now();
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制) // 转发请求
match self match self
.forward(provider, endpoint, &body, &headers, adapter.as_ref()) .forward(&provider, endpoint, &body, &headers, adapter.as_ref())
.await .await
{ {
Ok(response) => { Ok(response) => {
let latency = start.elapsed().as_millis() as u64; let _latency = start.elapsed().as_millis() as u64;
// 成功:记录成功并更新熔断器 // 成功:更新健康状态
if let Err(e) = self self.router
.router .update_health(&provider, app_type, true, None)
.record_result( .await;
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await
{
log::warn!("Failed to record success: {e}");
}
// 更新当前应用类型使用的 provider
{
let mut current_providers = self.current_providers.write().await;
current_providers.insert(
app_type_str.to_string(),
(provider.id.clone(), provider.name.clone()),
);
}
// 更新成功统计 // 更新成功统计
{ {
let mut status = self.status.write().await; let mut status = self.status.write().await;
status.success_requests += 1; status.success_requests += 1;
status.last_error = None; status.last_error = None;
let should_switch = if failover_happened {
self.current_provider_id_at_start.as_str() != provider.id.as_str();
if should_switch {
status.failover_count += 1; status.failover_count += 1;
log::info!(
"[{}] 代理目标已切换到 Provider: {} (耗时: {}ms)",
app_type_str,
provider.name,
latency
);
// 异步触发供应商切换,更新 UI/托盘,并把“当前供应商”同步为实际使用的 provider
let fm = self.failover_manager.clone();
let ah = self.app_handle.clone();
let pid = provider.id.clone();
let pname = provider.name.clone();
let at = app_type_str.to_string();
tokio::spawn(async move {
if let Err(e) = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await
{
log::error!("[Failover] 切换供应商失败: {e}");
}
});
} }
// 重新计算成功率 // 重新计算成功率
if status.total_requests > 0 { if status.total_requests > 0 {
@@ -303,42 +115,23 @@ impl RequestForwarder {
} }
} }
log::info!( return Ok(response);
"[{}] 请求成功 - Provider: {} - {}ms",
app_type_str,
provider.name,
latency
);
return Ok(ForwardResult {
response,
provider: provider.clone(),
});
} }
Err(e) => { Err(e) => {
let latency = start.elapsed().as_millis() as u64; let latency = start.elapsed().as_millis() as u64;
// 失败:记录失败并更新熔断器 // 失败:分类错误
if let Err(record_err) = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
false,
Some(e.to_string()),
)
.await
{
log::warn!("Failed to record failure: {record_err}");
}
// 分类错误
let category = self.categorize_proxy_error(&e); let category = self.categorize_proxy_error(&e);
match category { match category {
ErrorCategory::Retryable => { ErrorCategory::Retryable => {
// 可重试:更新错误信息,继续尝试下一个供应商 // 可重试:更新健康状态,添加到失败列表
self.router
.update_health(&provider, app_type, false, Some(e.to_string()))
.await;
failed_ids.push(provider.id.clone());
// 更新错误信息
{ {
let mut status = self.status.write().await; let mut status = self.status.write().await;
status.last_error = status.last_error =
@@ -346,20 +139,15 @@ impl RequestForwarder {
} }
log::warn!( log::warn!(
"[{}] Provider {} 失败(可重试): {} - {}ms", "请求失败(可重试): Provider {} - {} - {}ms",
app_type_str,
provider.name, provider.name,
e, e,
latency latency
); );
last_error = Some(e);
last_provider = Some(provider.clone());
// 继续尝试下一个供应商
continue; continue;
} }
ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => { ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => {
// 不可重试:直接返回错误 // 不可重试:更新失败统计并返回
{ {
let mut status = self.status.write().await; let mut status = self.status.write().await;
status.failed_requests += 1; status.failed_requests += 1;
@@ -370,60 +158,26 @@ impl RequestForwarder {
* 100.0; * 100.0;
} }
} }
log::error!( log::error!("请求失败(不可重试): {e}");
"[{}] Provider {} 失败(不可重试): {}", return Err(e);
app_type_str,
provider.name,
e
);
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
} }
} }
} }
} }
} }
if attempted_providers == 0 { // 所有重试都失败
// providers 列表非空,但全部被熔断器拒绝(典型:HalfOpen 探测名额被占用)
{
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some("所有供应商暂时不可用(熔断器限制)".to_string());
if status.total_requests > 0 {
status.success_rate =
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
}
}
return Err(ForwardError {
error: ProxyError::NoAvailableProvider,
provider: None,
});
}
// 所有供应商都失败了
{ {
let mut status = self.status.write().await; let mut status = self.status.write().await;
status.failed_requests += 1; status.failed_requests += 1;
status.last_error = Some("所有供应商都失败".to_string()); status.last_error = Some("已达到最大重试次数".to_string());
if status.total_requests > 0 { if status.total_requests > 0 {
status.success_rate = status.success_rate =
(status.success_requests as f32 / status.total_requests as f32) * 100.0; (status.success_requests as f32 / status.total_requests as f32) * 100.0;
} }
} }
log::error!( Err(ProxyError::MaxRetriesExceeded)
"[{}] 所有 {} 个供应商都失败了",
app_type_str,
providers.len()
);
Err(ForwardError {
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
provider: last_provider,
})
} }
/// 转发单个请求(使用适配器) /// 转发单个请求(使用适配器)
@@ -439,19 +193,12 @@ impl RequestForwarder {
let base_url = adapter.extract_base_url(provider)?; let base_url = adapter.extract_base_url(provider)?;
log::info!("[{}] base_url: {}", adapter.name(), base_url); log::info!("[{}] base_url: {}", adapter.name(), base_url);
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, endpoint);
// 检查是否需要格式转换 // 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider); let needs_transform = adapter.needs_transform(provider);
let effective_endpoint =
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
"/v1/chat/completions"
} else {
endpoint
};
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, effective_endpoint);
// 记录原始请求 JSON // 记录原始请求 JSON
log::info!( log::info!(
"[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}", "[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}",
@@ -459,23 +206,10 @@ impl RequestForwarder {
serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string()) serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string())
); );
// 应用模型映射(独立于格式转换)
let (mapped_body, _original_model, mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
if let Some(ref mapped) = mapped_model {
log::info!(
"[{}] >>> 模型映射后的请求 JSON:\n{}",
adapter.name(),
serde_json::to_string_pretty(&mapped_body).unwrap_or_default()
);
log::info!("[{}] 模型已映射到: {}", adapter.name(), mapped);
}
// 转换请求体(如果需要) // 转换请求体(如果需要)
let request_body = if needs_transform { let request_body = if needs_transform {
log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name()); log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name());
let transformed = adapter.transform_request(mapped_body, provider)?; let transformed = adapter.transform_request(body.clone(), provider)?;
log::info!( log::info!(
"[{}] >>> 转换后的请求 JSON:\n{}", "[{}] >>> 转换后的请求 JSON:\n{}",
adapter.name(), adapter.name(),
@@ -483,31 +217,9 @@ impl RequestForwarder {
); );
transformed transformed
} else { } else {
mapped_body body.clone()
}; };
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
// 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
// ========== 请求体日志(截断显示) ==========
let body_str = serde_json::to_string_pretty(&filtered_body)
.unwrap_or_else(|_| filtered_body.to_string());
let body_preview = if body_str.len() > 2000 {
format!(
"{}...\n[截断,总长度: {} 字符]",
&body_str[..2000],
body_str.len()
)
} else {
body_str
};
log::info!(
"[{}] ====== 最终请求体 ======\n{}",
adapter.name(),
body_preview
);
log::info!( log::info!(
"[{}] 转发请求: {} -> {}", "[{}] 转发请求: {} -> {}",
adapter.name(), adapter.name(),
@@ -518,73 +230,28 @@ impl RequestForwarder {
// 构建请求 // 构建请求
let mut request = self.client.post(&url); let mut request = self.client.post(&url);
// ========== 详细 Headers 日志 ========== // 只透传必要的 Headers(白名单模式)
log::info!("[{}] ====== 客户端原始 Headers ======", adapter.name()); let allowed_headers = [
for (key, value) in headers { "accept",
log::info!( "user-agent",
"[{}] {}: {:?}", "x-request-id",
adapter.name(), "x-stainless-arch",
key.as_str(), "x-stainless-lang",
value.to_str().unwrap_or("<binary>") "x-stainless-os",
); "x-stainless-package-version",
} "x-stainless-runtime",
"x-stainless-runtime-version",
// 过滤黑名单 Headers,保护隐私并避免冲突 ];
let mut filtered_headers: Vec<String> = Vec::new();
let mut passed_headers: Vec<(String, String)> = Vec::new();
for (key, value) in headers { for (key, value) in headers {
let key_str = key.as_str().to_lowercase(); let key_str = key.as_str().to_lowercase();
if HEADER_BLACKLIST.contains(&key_str.as_str()) { if allowed_headers.contains(&key_str.as_str()) {
filtered_headers.push(key_str); request = request.header(key, value);
continue;
}
let value_str = value.to_str().unwrap_or("<binary>").to_string();
passed_headers.push((key.as_str().to_string(), value_str.clone()));
request = request.header(key, value);
}
if !filtered_headers.is_empty() {
log::info!(
"[{}] ====== 被过滤的 Headers ({}) ======",
adapter.name(),
filtered_headers.len()
);
for h in &filtered_headers {
log::info!("[{}] - {}", adapter.name(), h);
} }
} }
// 处理 anthropic-beta Header(透传) // 确保 Content-Type 是 json
// 参考 Claude Code Hub 的实现,直接透传客户端的 beta 标记 request = request.header("Content-Type", "application/json");
if let Some(beta) = headers.get("anthropic-beta") {
if let Ok(beta_str) = beta.to_str() {
request = request.header("anthropic-beta", beta_str);
passed_headers.push(("anthropic-beta".to_string(), beta_str.to_string()));
log::info!("[{}] 透传 anthropic-beta: {}", adapter.name(), beta_str);
}
}
// 客户端 IP 透传(默认开启)
if let Some(xff) = headers.get("x-forwarded-for") {
if let Ok(xff_str) = xff.to_str() {
request = request.header("x-forwarded-for", xff_str);
passed_headers.push(("x-forwarded-for".to_string(), xff_str.to_string()));
log::debug!("[{}] 透传 x-forwarded-for: {}", adapter.name(), xff_str);
}
}
if let Some(real_ip) = headers.get("x-real-ip") {
if let Ok(real_ip_str) = real_ip.to_str() {
request = request.header("x-real-ip", real_ip_str);
passed_headers.push(("x-real-ip".to_string(), real_ip_str.to_string()));
log::debug!("[{}] 透传 x-real-ip: {}", adapter.name(), real_ip_str);
}
}
// 禁用压缩,避免 gzip 流式响应解析错误
// 参考 CCH: undici 在连接提前关闭时会对不完整的 gzip 流抛出错误
request = request.header("accept-encoding", "identity");
passed_headers.push(("accept-encoding".to_string(), "identity".to_string()));
// 使用适配器添加认证头 // 使用适配器添加认证头
if let Some(auth) = adapter.extract_auth(provider) { if let Some(auth) = adapter.extract_auth(provider) {
@@ -595,15 +262,6 @@ impl RequestForwarder {
auth.masked_key() auth.masked_key()
); );
request = adapter.add_auth_headers(request, &auth); request = adapter.add_auth_headers(request, &auth);
// 记录认证头(脱敏)
passed_headers.push((
"authorization".to_string(),
format!("Bearer {}...", &auth.api_key[..8.min(auth.api_key.len())]),
));
passed_headers.push((
"x-api-key".to_string(),
format!("{}...", &auth.api_key[..8.min(auth.api_key.len())]),
));
} else { } else {
log::error!( log::error!(
"[{}] 未找到 API KeyProvider: {}", "[{}] 未找到 API KeyProvider: {}",
@@ -612,34 +270,9 @@ impl RequestForwarder {
); );
} }
// anthropic-version 透传:优先使用客户端的版本号
// 参考 Claude Code Hub:透传客户端值而非固定版本
if let Some(version) = headers.get("anthropic-version") {
if let Ok(version_str) = version.to_str() {
// 覆盖适配器设置的默认版本
request = request.header("anthropic-version", version_str);
passed_headers.push(("anthropic-version".to_string(), version_str.to_string()));
log::info!(
"[{}] 透传 anthropic-version: {}",
adapter.name(),
version_str
);
}
}
// ========== 最终发送的 Headers 日志 ==========
log::info!(
"[{}] ====== 最终发送的 Headers ({}) ======",
adapter.name(),
passed_headers.len()
);
for (k, v) in &passed_headers {
log::info!("[{}] {}: {}", adapter.name(), k, v);
}
// 发送请求 // 发送请求
log::info!("[{}] 发送请求到: {}", adapter.name(), url); log::info!("[{}] 发送请求到: {}", adapter.name(), url);
let response = request.json(&filtered_body).send().await.map_err(|e| { let response = request.json(&request_body).send().await.map_err(|e| {
log::error!("[{}] 请求失败: {}", adapter.name(), e); log::error!("[{}] 请求失败: {}", adapter.name(), e);
if e.is_timeout() { if e.is_timeout() {
ProxyError::Timeout(format!("请求超时: {e}")) ProxyError::Timeout(format!("请求超时: {e}"))
@@ -673,24 +306,22 @@ impl RequestForwarder {
} }
} }
/// 分类ProxyError
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory { fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
match error { match error {
// 网络和上游错误:都应该尝试下一个供应商
ProxyError::Timeout(_) => ErrorCategory::Retryable, ProxyError::Timeout(_) => ErrorCategory::Retryable,
ProxyError::ForwardFailed(_) => ErrorCategory::Retryable, ProxyError::ForwardFailed(_) => ErrorCategory::Retryable,
ProxyError::UpstreamError { status, .. } => {
if *status >= 500 {
ErrorCategory::Retryable
} else if *status >= 400 && *status < 500 {
ErrorCategory::NonRetryable
} else {
ErrorCategory::Retryable
}
}
ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable, ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable,
// 上游 HTTP 错误:无论状态码如何,都尝试下一个供应商
// 原因:不同供应商有不同的限制和认证,一个供应商的 4xx 错误
// 不代表其他供应商也会失败
ProxyError::UpstreamError { .. } => ErrorCategory::Retryable,
// Provider 级配置/转换问题:换一个 Provider 可能就能成功
ProxyError::ConfigError(_) => ErrorCategory::Retryable,
ProxyError::TransformError(_) => ErrorCategory::Retryable,
ProxyError::AuthError(_) => ErrorCategory::Retryable,
ProxyError::StreamIdleTimeout(_) => ErrorCategory::Retryable,
// 无可用供应商:所有供应商都试过了,无法重试
ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable, ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
// 其他错误(数据库/内部错误等):不是换供应商能解决的问题
_ => ErrorCategory::NonRetryable, _ => ErrorCategory::NonRetryable,
} }
} }
-188
View File
@@ -1,188 +0,0 @@
//! Handler 配置模块
//!
//! 定义各 API 处理器的配置结构和使用量解析器
use crate::app_config::AppType;
use crate::proxy::usage::parser::TokenUsage;
use serde_json::Value;
/// 使用量解析器类型别名
pub type StreamUsageParser = fn(&[Value]) -> Option<TokenUsage>;
pub type ResponseUsageParser = fn(&Value) -> Option<TokenUsage>;
/// 模型提取器类型别名
/// 参数: (流式事件列表, 请求中的模型名称) -> 最终使用的模型名称
pub type StreamModelExtractor = fn(&[Value], &str) -> String;
/// 各 API 的使用量解析配置
#[derive(Clone, Copy)]
pub struct UsageParserConfig {
/// 流式响应解析器
pub stream_parser: StreamUsageParser,
/// 非流式响应解析器
pub response_parser: ResponseUsageParser,
/// 流式响应中的模型提取器
pub model_extractor: StreamModelExtractor,
/// 应用类型字符串(用于日志记录)
pub app_type_str: &'static str,
}
// ============================================================================
// 模型提取器实现
// ============================================================================
/// Claude 流式响应模型提取(优先使用 usage.model
fn claude_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_claude_stream_events(events) {
if let Some(model) = usage.model {
return model;
}
}
request_model.to_string()
}
/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model
fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_openai_stream_events(events) {
if let Some(model) = usage.model {
return model;
}
}
// 回退:从事件中直接提取
events
.iter()
.find_map(|e| e.get("model")?.as_str())
.unwrap_or(request_model)
.to_string()
}
/// Codex 智能流式响应模型提取(自动检测格式)
fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) {
if let Some(model) = usage.model {
return model;
}
}
// 回退:从 response.completed 事件中提取
events
.iter()
.find_map(|e| {
if e.get("type")?.as_str()? == "response.completed" {
e.get("response")?.get("model")?.as_str()
} else {
None
}
})
.or_else(|| {
// 再回退:从 OpenAI 格式事件中提取
events.iter().find_map(|e| e.get("model")?.as_str())
})
.unwrap_or(request_model)
.to_string()
}
/// Gemini 流式响应模型提取(优先使用 usage.model
fn gemini_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_gemini_stream_chunks(events) {
if let Some(model) = usage.model {
return model;
}
}
request_model.to_string()
}
// ============================================================================
// 预定义配置
// ============================================================================
/// Claude API 解析配置
pub const CLAUDE_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_claude_stream_events,
response_parser: TokenUsage::from_claude_response,
model_extractor: claude_model_extractor,
app_type_str: "claude",
};
/// OpenAI Chat Completions API 解析配置(用于 Codex /v1/chat/completions
pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_openai_stream_events,
response_parser: TokenUsage::from_openai_response,
model_extractor: openai_model_extractor,
app_type_str: "codex",
};
/// Codex 智能解析配置(自动检测 OpenAI 或 Codex 格式)
pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_codex_stream_events_auto,
response_parser: TokenUsage::from_codex_response_auto,
model_extractor: codex_auto_model_extractor,
app_type_str: "codex",
};
/// Gemini API 解析配置
pub const GEMINI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_gemini_stream_chunks,
response_parser: TokenUsage::from_gemini_response,
model_extractor: gemini_model_extractor,
app_type_str: "gemini",
};
// ============================================================================
// Handler 配置(预留,用于进一步简化)
// ============================================================================
/// Handler 基础配置
///
/// 预留结构,可用于进一步统一各 handler 的配置
#[allow(dead_code)]
#[derive(Clone)]
pub struct HandlerConfig {
/// 应用类型
pub app_type: AppType,
/// 日志标签
pub tag: &'static str,
/// 应用类型字符串
pub app_type_str: &'static str,
/// 使用量解析配置
pub parser_config: &'static UsageParserConfig,
}
/// Claude Handler 配置
#[allow(dead_code)]
pub const CLAUDE_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
app_type: AppType::Claude,
tag: "Claude",
app_type_str: "claude",
parser_config: &CLAUDE_PARSER_CONFIG,
};
/// Codex Chat Completions Handler 配置
#[allow(dead_code)]
pub const CODEX_CHAT_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
app_type: AppType::Codex,
tag: "Codex",
app_type_str: "codex",
parser_config: &OPENAI_PARSER_CONFIG,
};
/// Codex Responses Handler 配置
#[allow(dead_code)]
pub const CODEX_RESPONSES_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
app_type: AppType::Codex,
tag: "Codex",
app_type_str: "codex",
parser_config: &CODEX_PARSER_CONFIG,
};
/// Gemini Handler 配置
#[allow(dead_code)]
pub const GEMINI_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
app_type: AppType::Gemini,
tag: "Gemini",
app_type_str: "gemini",
parser_config: &GEMINI_PARSER_CONFIG,
};
-247
View File
@@ -1,247 +0,0 @@
//! 请求上下文模块
//!
//! 提供请求生命周期的上下文管理,封装通用初始化逻辑
use crate::app_config::AppType;
use crate::provider::Provider;
use crate::proxy::{
extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig,
ProxyError,
};
use axum::http::HeaderMap;
use std::time::Instant;
/// 流式超时配置
#[derive(Debug, Clone, Copy)]
pub struct StreamingTimeoutConfig {
/// 首字节超时(秒),0 表示禁用
pub first_byte_timeout: u64,
/// 静默期超时(秒),0 表示禁用
pub idle_timeout: u64,
}
/// 请求上下文
///
/// 贯穿整个请求生命周期,包含:
/// - 计时信息
/// - 应用级代理配置(per-app
/// - 选中的 Provider 列表(用于故障转移)
/// - 请求模型名称
/// - 日志标签
/// - Session ID(用于日志关联)
pub struct RequestContext {
/// 请求开始时间
pub start_time: Instant,
/// 应用级代理配置(per-app,包含重试次数和超时配置)
pub app_config: AppProxyConfig,
/// 选中的 Provider(故障转移链的第一个)
pub provider: Provider,
/// 完整的 Provider 列表(用于故障转移)
providers: Vec<Provider>,
/// 请求开始时的"当前供应商"(用于判断是否需要同步 UI/托盘)
///
/// 这里使用本地 settings 的设备级 current provider。
/// 代理模式下如果实际使用的 provider 与此不一致,会触发切换以确保 UI 始终准确。
pub current_provider_id: String,
/// 请求中的模型名称
pub request_model: String,
/// 日志标签(如 "Claude"、"Codex"、"Gemini"
pub tag: &'static str,
/// 应用类型字符串(如 "claude"、"codex"、"gemini"
pub app_type_str: &'static str,
/// 应用类型(预留,目前通过 app_type_str 使用)
#[allow(dead_code)]
pub app_type: AppType,
/// Session ID(从客户端请求提取或新生成)
pub session_id: String,
}
impl RequestContext {
/// 创建请求上下文
///
/// # Arguments
/// * `state` - 代理服务器状态
/// * `body` - 请求体 JSON
/// * `headers` - 请求头(用于提取 Session ID
/// * `app_type` - 应用类型
/// * `tag` - 日志标签
/// * `app_type_str` - 应用类型字符串
///
/// # Errors
/// 返回 `ProxyError` 如果 Provider 选择失败
pub async fn new(
state: &ProxyState,
body: &serde_json::Value,
headers: &HeaderMap,
app_type: AppType,
tag: &'static str,
app_type_str: &'static str,
) -> Result<Self, ProxyError> {
let start_time = Instant::now();
// 从数据库读取应用级代理配置(per-app)
let app_config = state
.db
.get_proxy_config_for_app(app_type_str)
.await
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
let current_provider_id =
crate::settings::get_current_provider(&app_type).unwrap_or_default();
// 从请求体提取模型名称
let request_model = body
.get("model")
.and_then(|m| m.as_str())
.unwrap_or("unknown")
.to_string();
// 提取 Session ID
let session_result = extract_session_id(headers, body, app_type_str);
let session_id = session_result.session_id.clone();
log::debug!(
"[{}] Session ID: {} (from {:?}, client_provided: {})",
tag,
session_id,
session_result.source,
session_result.client_provided
);
// 使用共享的 ProviderRouter 选择 Provider(熔断器状态跨请求保持)
// 注意:只在这里调用一次,结果传递给 forwarder,避免重复消耗 HalfOpen 名额
let providers = state
.provider_router
.select_providers(app_type_str)
.await
.map_err(|e| match e {
crate::error::AppError::AllProvidersCircuitOpen => {
ProxyError::AllProvidersCircuitOpen
}
crate::error::AppError::NoProvidersConfigured => ProxyError::NoProvidersConfigured,
_ => ProxyError::DatabaseError(e.to_string()),
})?;
let provider = providers
.first()
.cloned()
.ok_or(ProxyError::NoAvailableProvider)?;
log::info!(
"[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}",
tag,
provider.name,
request_model,
providers.len(),
session_id
);
Ok(Self {
start_time,
app_config,
provider,
providers,
current_provider_id,
request_model,
tag,
app_type_str,
app_type,
session_id,
})
}
/// 从 URI 提取模型名称(Gemini 专用)
///
/// Gemini API 的模型名称在 URI 中,格式如:
/// `/v1beta/models/gemini-pro:generateContent`
pub fn with_model_from_uri(mut self, uri: &axum::http::Uri) -> Self {
let endpoint = uri
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or(uri.path());
self.request_model = endpoint
.split('/')
.find(|s| s.starts_with("models/"))
.and_then(|s| s.strip_prefix("models/"))
.map(|s| s.split(':').next().unwrap_or(s))
.unwrap_or("unknown")
.to_string();
log::info!("[{}] 从 URI 提取模型: {}", self.tag, self.request_model);
self
}
/// 创建 RequestForwarder
///
/// 使用共享的 ProviderRouter,确保熔断器状态跨请求保持
///
/// 配置生效规则:
/// - 故障转移开启:超时配置正常生效(0 表示禁用超时)
/// - 故障转移关闭:超时配置不生效(全部传入 0)
pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder {
let (non_streaming_timeout, first_byte_timeout, idle_timeout) =
if self.app_config.auto_failover_enabled {
// 故障转移开启:使用配置的值(0 = 禁用超时)
(
self.app_config.non_streaming_timeout as u64,
self.app_config.streaming_first_byte_timeout as u64,
self.app_config.streaming_idle_timeout as u64,
)
} else {
// 故障转移关闭:不启用超时配置
log::info!(
"[{}] Failover disabled, timeout configs are bypassed",
self.tag
);
(0, 0, 0)
};
RequestForwarder::new(
state.provider_router.clone(),
non_streaming_timeout,
state.status.clone(),
state.current_providers.clone(),
state.failover_manager.clone(),
state.app_handle.clone(),
self.current_provider_id.clone(),
first_byte_timeout,
idle_timeout,
)
}
/// 获取 Provider 列表(用于故障转移)
///
/// 返回在创建上下文时已选择的 providers,避免重复调用 select_providers()
pub fn get_providers(&self) -> Vec<Provider> {
self.providers.clone()
}
/// 计算请求延迟(毫秒)
#[inline]
pub fn latency_ms(&self) -> u64 {
self.start_time.elapsed().as_millis() as u64
}
/// 获取流式超时配置
///
/// 配置生效规则:
/// - 故障转移开启:返回配置的值(0 表示禁用超时检查)
/// - 故障转移关闭:返回 0(禁用超时检查)
#[inline]
pub fn streaming_timeout_config(&self) -> StreamingTimeoutConfig {
if self.app_config.auto_failover_enabled {
// 故障转移开启:使用配置的值(0 = 禁用超时)
StreamingTimeoutConfig {
first_byte_timeout: self.app_config.streaming_first_byte_timeout as u64,
idle_timeout: self.app_config.streaming_idle_timeout as u64,
}
} else {
// 故障转移关闭:禁用流式超时检查
StreamingTimeoutConfig {
first_byte_timeout: 0,
idle_timeout: 0,
}
}
}
}
File diff suppressed because it is too large Load Diff
+2 -18
View File
@@ -2,21 +2,13 @@
//! //!
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传 //! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
pub mod body_filter;
pub mod circuit_breaker;
pub mod error; pub mod error;
pub mod error_mapper;
pub(crate) mod failover_switch;
mod forwarder; mod forwarder;
pub mod handler_config;
pub mod handler_context;
mod handlers; mod handlers;
mod health; mod health;
pub mod model_mapper;
pub mod provider_router;
pub mod providers; pub mod providers;
pub mod response_handler; pub mod response_handler;
pub mod response_processor; mod router;
pub(crate) mod server; pub(crate) mod server;
pub mod session; pub mod session;
pub(crate) mod types; pub(crate) mod types;
@@ -24,19 +16,11 @@ pub mod usage;
// 公开导出给外部使用(commands, services等模块需要) // 公开导出给外部使用(commands, services等模块需要)
#[allow(unused_imports)] #[allow(unused_imports)]
pub use circuit_breaker::{
CircuitBreaker, CircuitBreakerConfig, CircuitBreakerStats, CircuitState,
};
#[allow(unused_imports)]
pub use error::ProxyError; pub use error::ProxyError;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use provider_router::ProviderRouter;
#[allow(unused_imports)]
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler}; pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use session::{ pub use session::{ClientFormat, ProxySession};
extract_session_id, ClientFormat, ProxySession, SessionIdResult, SessionIdSource,
};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus}; pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};
-264
View File
@@ -1,264 +0,0 @@
//! 模型映射模块
//!
//! 在请求转发前,根据 Provider 配置替换请求中的模型名称
use crate::provider::Provider;
use serde_json::Value;
/// 模型映射配置
pub struct ModelMapping {
pub haiku_model: Option<String>,
pub sonnet_model: Option<String>,
pub opus_model: Option<String>,
pub default_model: Option<String>,
pub reasoning_model: Option<String>,
}
impl ModelMapping {
/// 从 Provider 配置中提取模型映射
pub fn from_provider(provider: &Provider) -> Self {
let env = provider.settings_config.get("env");
Self {
haiku_model: env
.and_then(|e| e.get("ANTHROPIC_DEFAULT_HAIKU_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
sonnet_model: env
.and_then(|e| e.get("ANTHROPIC_DEFAULT_SONNET_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
opus_model: env
.and_then(|e| e.get("ANTHROPIC_DEFAULT_OPUS_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
default_model: env
.and_then(|e| e.get("ANTHROPIC_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
reasoning_model: env
.and_then(|e| e.get("ANTHROPIC_REASONING_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
}
}
/// 检查是否配置了任何模型映射
pub fn has_mapping(&self) -> bool {
self.haiku_model.is_some()
|| self.sonnet_model.is_some()
|| self.opus_model.is_some()
|| self.default_model.is_some()
}
/// 根据原始模型名称获取映射后的模型
pub fn map_model(&self, original_model: &str, has_thinking: bool) -> String {
let model_lower = original_model.to_lowercase();
// 1. thinking 模式优先使用推理模型
if has_thinking {
if let Some(ref m) = self.reasoning_model {
return m.clone();
}
}
// 2. 按模型类型匹配
if model_lower.contains("haiku") {
if let Some(ref m) = self.haiku_model {
return m.clone();
}
}
if model_lower.contains("opus") {
if let Some(ref m) = self.opus_model {
return m.clone();
}
}
if model_lower.contains("sonnet") {
if let Some(ref m) = self.sonnet_model {
return m.clone();
}
}
// 3. 默认模型
if let Some(ref m) = self.default_model {
return m.clone();
}
// 4. 无映射,保持原样
original_model.to_string()
}
}
/// 检测请求是否启用了 thinking 模式
pub fn has_thinking_enabled(body: &Value) -> bool {
body.get("thinking")
.and_then(|v| v.as_object())
.and_then(|o| o.get("type"))
.and_then(|t| t.as_str())
== Some("enabled")
}
/// 对请求体应用模型映射
///
/// 返回 (映射后的请求体, 原始模型名, 映射后模型名)
pub fn apply_model_mapping(
mut body: Value,
provider: &Provider,
) -> (Value, Option<String>, Option<String>) {
let mapping = ModelMapping::from_provider(provider);
// 如果没有配置映射,直接返回
if !mapping.has_mapping() {
let original = body.get("model").and_then(|m| m.as_str()).map(String::from);
return (body, original, None);
}
// 提取原始模型名
let original_model = body.get("model").and_then(|m| m.as_str()).map(String::from);
if let Some(ref original) = original_model {
let has_thinking = has_thinking_enabled(&body);
let mapped = mapping.map_model(original, has_thinking);
if mapped != *original {
log::info!("[ModelMapper] 模型映射: {original} → {mapped}");
body["model"] = serde_json::json!(mapped);
return (body, Some(original.clone()), Some(mapped));
}
}
(body, original_model, None)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider_with_mapping() -> Provider {
Provider {
id: "test".to_string(),
name: "Test".to_string(),
settings_config: json!({
"env": {
"ANTHROPIC_MODEL": "default-model",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped",
"ANTHROPIC_REASONING_MODEL": "reasoning-model"
}
}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
fn create_provider_without_mapping() -> Provider {
Provider {
id: "test".to_string(),
name: "Test".to_string(),
settings_config: json!({}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_sonnet_mapping() {
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-sonnet-4-5-20250929"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(original, Some("claude-sonnet-4-5-20250929".to_string()));
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
fn test_haiku_mapping() {
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-haiku-4-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "haiku-mapped");
assert_eq!(mapped, Some("haiku-mapped".to_string()));
}
#[test]
fn test_opus_mapping() {
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-opus-4-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "opus-mapped");
assert_eq!(mapped, Some("opus-mapped".to_string()));
}
#[test]
fn test_thinking_mode() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "enabled"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "reasoning-model");
assert_eq!(mapped, Some("reasoning-model".to_string()));
}
#[test]
fn test_thinking_disabled() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "disabled"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
fn test_unknown_model_uses_default() {
let provider = create_provider_with_mapping();
let body = json!({"model": "some-unknown-model"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "default-model");
assert_eq!(mapped, Some("default-model".to_string()));
}
#[test]
fn test_no_mapping_configured() {
let provider = create_provider_without_mapping();
let body = json!({"model": "claude-sonnet-4-5"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "claude-sonnet-4-5");
assert_eq!(original, Some("claude-sonnet-4-5".to_string()));
assert!(mapped.is_none());
}
#[test]
fn test_case_insensitive() {
let provider = create_provider_with_mapping();
let body = json!({"model": "Claude-SONNET-4-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
}
-417
View File
@@ -1,417 +0,0 @@
//! 供应商路由器模块
//!
//! 负责选择和管理代理目标供应商,实现智能故障转移
use crate::database::Database;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::circuit_breaker::{AllowResult, CircuitBreaker, CircuitBreakerConfig};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
/// 供应商路由器
pub struct ProviderRouter {
/// 数据库连接
db: Arc<Database>,
/// 熔断器管理器 - key 格式: "app_type:provider_id"
circuit_breakers: Arc<RwLock<HashMap<String, Arc<CircuitBreaker>>>>,
}
impl ProviderRouter {
/// 创建新的供应商路由器
pub fn new(db: Arc<Database>) -> Self {
Self {
db,
circuit_breakers: Arc::new(RwLock::new(HashMap::new())),
}
}
/// 选择可用的供应商(支持故障转移)
///
/// 返回按优先级排序的可用供应商列表:
/// - 故障转移关闭时:仅返回当前供应商
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
let mut result = Vec::new();
let mut total_providers = 0usize;
let mut circuit_open_count = 0usize;
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
Ok(config) => {
let enabled = config.auto_failover_enabled;
log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}");
enabled
}
Err(e) => {
log::error!(
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
);
false
}
};
if auto_failover_enabled {
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
let failover_providers = self.db.get_failover_providers(app_type)?;
total_providers = failover_providers.len();
log::debug!("[{app_type}] Found {total_providers} failover queue provider(s)");
log::info!(
"[{app_type}] Failover enabled, using queue order ({total_providers} items)"
);
for provider in failover_providers {
// 检查熔断器状态
let circuit_key = format!("{}:{}", app_type, provider.id);
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
let state = breaker.get_state().await;
if breaker.is_available().await {
log::debug!(
"[{}] Queue provider available: {} ({}) (state: {:?})",
app_type,
provider.name,
provider.id,
state
);
log::info!(
"[{}] Queue provider available: {} ({}) at sort_index {:?}",
app_type,
provider.name,
provider.id,
provider.sort_index
);
result.push(provider);
} else {
circuit_open_count += 1;
log::debug!(
"[{}] Queue provider {} circuit breaker open (state: {:?}), skipping",
app_type,
provider.name,
state
);
}
}
} else {
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
// 原因:单 Provider 场景下,熔断器打开会导致所有请求失败,用户体验差
log::info!("[{app_type}] Failover disabled, using current provider only (circuit breaker bypassed)");
if let Some(current_id) = self.db.get_current_provider(app_type)? {
if let Some(current) = self.db.get_provider_by_id(&current_id, app_type)? {
log::info!(
"[{}] Current provider: {} ({})",
app_type,
current.name,
current.id
);
total_providers = 1;
result.push(current);
} else {
log::debug!(
"[{app_type}] Current provider id {current_id} not found in database"
);
}
} else {
log::debug!("[{app_type}] No current provider configured");
}
}
if result.is_empty() {
// 区分两种情况:全部熔断 vs 未配置供应商
if total_providers > 0 && circuit_open_count == total_providers {
log::warn!("[{app_type}] 所有 {total_providers} 个供应商均已熔断,无可用渠道");
return Err(AppError::AllProvidersCircuitOpen);
} else {
log::warn!("[{app_type}] 未配置供应商或故障转移队列为空");
return Err(AppError::NoProvidersConfigured);
}
}
log::info!(
"[{}] Provider chain: {} provider(s) available",
app_type,
result.len()
);
Ok(result)
}
/// 请求执行前获取熔断器“放行许可”
///
/// - Closed:直接放行
/// - Open:超时到达后切到 HalfOpen 并放行一次探测
/// - HalfOpen:按限流规则放行探测
///
/// 注意:调用方必须在请求结束后通过 `record_result()` 释放 HalfOpen 名额,
/// 否则会导致该 Provider 长时间无法进入探测状态。
pub async fn allow_provider_request(&self, provider_id: &str, app_type: &str) -> AllowResult {
let circuit_key = format!("{app_type}:{provider_id}");
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
breaker.allow_request().await
}
/// 记录供应商请求结果
pub async fn record_result(
&self,
provider_id: &str,
app_type: &str,
used_half_open_permit: bool,
success: bool,
error_msg: Option<String>,
) -> Result<(), AppError> {
// 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用)
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
Ok(app_config) => app_config.circuit_failure_threshold,
Err(e) => {
log::warn!(
"Failed to load circuit config for {app_type}, using default threshold: {e}"
);
5 // 默认值
}
};
// 2. 更新熔断器状态
let circuit_key = format!("{app_type}:{provider_id}");
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
if success {
breaker.record_success(used_half_open_permit).await;
log::debug!("Provider {provider_id} request succeeded");
} else {
breaker.record_failure(used_half_open_permit).await;
log::warn!(
"Provider {} request failed: {}",
provider_id,
error_msg.as_deref().unwrap_or("Unknown error")
);
}
// 3. 更新数据库健康状态(使用配置的阈值)
self.db
.update_provider_health_with_threshold(
provider_id,
app_type,
success,
error_msg.clone(),
failure_threshold,
)
.await?;
Ok(())
}
/// 重置熔断器(手动恢复)
pub async fn reset_circuit_breaker(&self, circuit_key: &str) {
let breakers = self.circuit_breakers.read().await;
if let Some(breaker) = breakers.get(circuit_key) {
log::info!("Manually resetting circuit breaker for {circuit_key}");
breaker.reset().await;
}
}
/// 重置指定供应商的熔断器
pub async fn reset_provider_breaker(&self, provider_id: &str, app_type: &str) {
let circuit_key = format!("{app_type}:{provider_id}");
self.reset_circuit_breaker(&circuit_key).await;
}
/// 更新所有熔断器的配置(热更新)
///
/// 当用户在 UI 中修改熔断器配置后调用此方法,
/// 所有现有的熔断器会立即使用新配置
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
let breakers = self.circuit_breakers.read().await;
let count = breakers.len();
for breaker in breakers.values() {
breaker.update_config(config.clone()).await;
}
log::info!("已更新 {count} 个熔断器的配置");
}
/// 获取熔断器状态
#[allow(dead_code)]
pub async fn get_circuit_breaker_stats(
&self,
provider_id: &str,
app_type: &str,
) -> Option<crate::proxy::circuit_breaker::CircuitBreakerStats> {
let circuit_key = format!("{app_type}:{provider_id}");
let breakers = self.circuit_breakers.read().await;
if let Some(breaker) = breakers.get(&circuit_key) {
Some(breaker.get_stats().await)
} else {
None
}
}
/// 获取或创建熔断器
async fn get_or_create_circuit_breaker(&self, key: &str) -> Arc<CircuitBreaker> {
// 先尝试读锁获取
{
let breakers = self.circuit_breakers.read().await;
if let Some(breaker) = breakers.get(key) {
return breaker.clone();
}
}
// 如果不存在,获取写锁创建
let mut breakers = self.circuit_breakers.write().await;
// 双重检查,防止竞争条件
if let Some(breaker) = breakers.get(key) {
return breaker.clone();
}
// 从 key 中提取 app_type (格式: "app_type:provider_id")
let app_type = key.split(':').next().unwrap_or("claude");
// 按应用独立读取熔断器配置
let config = match self.db.get_proxy_config_for_app(app_type).await {
Ok(app_config) => {
log::debug!(
"Loading circuit breaker config for {key} (app={app_type}): \
failure_threshold={}, success_threshold={}, timeout={}s",
app_config.circuit_failure_threshold,
app_config.circuit_success_threshold,
app_config.circuit_timeout_seconds
);
crate::proxy::circuit_breaker::CircuitBreakerConfig {
failure_threshold: app_config.circuit_failure_threshold,
success_threshold: app_config.circuit_success_threshold,
timeout_seconds: app_config.circuit_timeout_seconds as u64,
error_rate_threshold: app_config.circuit_error_rate_threshold,
min_requests: app_config.circuit_min_requests,
}
}
Err(e) => {
log::warn!(
"Failed to load circuit breaker config for {key} (app={app_type}): {e}, using default"
);
crate::proxy::circuit_breaker::CircuitBreakerConfig::default()
}
};
log::debug!("Creating new circuit breaker for {key} with config: {config:?}");
let breaker = Arc::new(CircuitBreaker::new(config));
breakers.insert(key.to_string(), breaker.clone());
breaker
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
use serde_json::json;
#[tokio::test]
async fn test_provider_router_creation() {
let db = Arc::new(Database::memory().unwrap());
let router = ProviderRouter::new(db);
let breaker = router.get_or_create_circuit_breaker("claude:test").await;
assert!(breaker.allow_request().await.allowed);
}
#[tokio::test]
async fn test_failover_disabled_uses_current_provider() {
let db = Arc::new(Database::memory().unwrap());
let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
let provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.set_current_provider("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
let router = ProviderRouter::new(db.clone());
let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 1);
assert_eq!(providers[0].id, "a");
}
#[tokio::test]
async fn test_failover_enabled_uses_queue_order() {
let db = Arc::new(Database::memory().unwrap());
// 设置 sort_index 来控制顺序:b=1, a=2
let mut provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
provider_a.sort_index = Some(2);
let mut provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
provider_b.sort_index = Some(1);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.set_current_provider("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
// 启用自动故障转移(使用新的 proxy_config API
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
config.auto_failover_enabled = true;
db.update_proxy_config_for_app(config).await.unwrap();
let router = ProviderRouter::new(db.clone());
let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 2);
// 按 sort_index 排序:b(1) 在前,a(2) 在后
assert_eq!(providers[0].id, "b");
assert_eq!(providers[1].id, "a");
}
#[tokio::test]
async fn test_select_providers_does_not_consume_half_open_permit() {
let db = Arc::new(Database::memory().unwrap());
db.update_circuit_breaker_config(&CircuitBreakerConfig {
failure_threshold: 1,
timeout_seconds: 0,
..Default::default()
})
.await
.unwrap();
let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
let provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
// 启用自动故障转移(使用新的 proxy_config API
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
config.auto_failover_enabled = true;
db.update_proxy_config_for_app(config).await.unwrap();
let router = ProviderRouter::new(db.clone());
router
.record_result("b", "claude", false, false, Some("fail".to_string()))
.await
.unwrap();
let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 2);
assert!(router.allow_provider_request("b", "claude").await.allowed);
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ pub trait ProviderAdapter: Send + Sync {
/// 是否需要格式转换 /// 是否需要格式转换
/// ///
/// 默认返回 `false`(透传模式)。 /// 默认返回 `false`(透传模式)。
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。 /// 仅当供应商需要格式转换时(如 Claude + OpenRouter)才返回 `true`。
/// ///
/// # Arguments /// # Arguments
/// * `provider` - Provider 配置 /// * `provider` - Provider 配置
+20 -73
View File
@@ -5,7 +5,7 @@
//! ## 认证模式 //! ## 认证模式
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version) //! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key) //! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用) //! - **OpenRouter**: 需要 Anthropic ↔ OpenAI 格式转换
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType}; use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider; use crate::provider::Provider;
@@ -28,8 +28,10 @@ impl ClaudeAdapter {
/// - Claude: 默认 Anthropic 官方 /// - Claude: 默认 Anthropic 官方
pub fn provider_type(&self, provider: &Provider) -> ProviderType { pub fn provider_type(&self, provider: &Provider) -> ProviderType {
// 检测 OpenRouter // 检测 OpenRouter
if self.is_openrouter(provider) { if let Ok(base_url) = self.extract_base_url(provider) {
return ProviderType::OpenRouter; if base_url.contains("openrouter.ai") {
return ProviderType::OpenRouter;
}
} }
// 检测 ClaudeAuth (仅 Bearer 认证) // 检测 ClaudeAuth (仅 Bearer 认证)
@@ -48,24 +50,6 @@ impl ClaudeAdapter {
false false
} }
/// 检测 OpenRouter 是否启用兼容模式
fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool {
if !self.is_openrouter(provider) {
return false;
}
let raw = provider.settings_config.get("openrouter_compat_mode");
match raw {
Some(serde_json::Value::Bool(enabled)) => *enabled,
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
Some(serde_json::Value::String(value)) => {
let normalized = value.trim().to_lowercase();
normalized == "true" || normalized == "1"
}
_ => true,
}
}
/// 检测是否为仅 Bearer 认证模式 /// 检测是否为仅 Bearer 认证模式
fn is_bearer_only_mode(&self, provider: &Provider) -> bool { fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
// 检查 settings_config 中的 auth_mode // 检查 settings_config 中的 auth_mode
@@ -103,14 +87,6 @@ impl ClaudeAdapter {
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN"); log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
return Some(key.to_string()); return Some(key.to_string());
} }
if let Some(key) = env
.get("ANTHROPIC_API_KEY")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 ANTHROPIC_API_KEY");
return Some(key.to_string());
}
// OpenRouter key // OpenRouter key
if let Some(key) = env if let Some(key) = env
.get("OPENROUTER_API_KEY") .get("OPENROUTER_API_KEY")
@@ -210,13 +186,12 @@ impl ProviderAdapter for ClaudeAdapter {
} }
fn build_url(&self, base_url: &str, endpoint: &str) -> String { fn build_url(&self, base_url: &str, endpoint: &str) -> String {
// NOTE: // OpenRouter 使用 /v1/chat/completions
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages` if base_url.contains("openrouter.ai") {
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。 return format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
// }
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
// Anthropic 直连
format!( format!(
"{}/{}", "{}/{}",
base_url.trim_end_matches('/'), base_url.trim_end_matches('/'),
@@ -232,24 +207,19 @@ impl ProviderAdapter for ClaudeAdapter {
.header("x-api-key", &auth.api_key) .header("x-api-key", &auth.api_key)
.header("anthropic-version", "2023-06-01"), .header("anthropic-version", "2023-06-01"),
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key // ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
AuthStrategy::ClaudeAuth => request AuthStrategy::ClaudeAuth => {
.header("Authorization", format!("Bearer {}", auth.api_key)) request.header("Authorization", format!("Bearer {}", auth.api_key))
.header("anthropic-version", "2023-06-01"), }
// OpenRouter: Bearer // OpenRouter: Bearer
AuthStrategy::Bearer => request AuthStrategy::Bearer => {
.header("Authorization", format!("Bearer {}", auth.api_key)) request.header("Authorization", format!("Bearer {}", auth.api_key))
.header("anthropic-version", "2023-06-01"), }
_ => request, _ => request,
} }
} }
fn needs_transform(&self, _provider: &Provider) -> bool { fn needs_transform(&self, provider: &Provider) -> bool {
// NOTE: self.is_openrouter(provider)
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
// Anthropic ↔ OpenAI 的格式转换。
//
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
self.is_openrouter_compat_enabled(_provider)
} }
fn transform_request( fn transform_request(
@@ -283,7 +253,7 @@ mod tests {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false, is_proxy_target: None,
} }
} }
@@ -315,21 +285,6 @@ mod tests {
assert_eq!(auth.strategy, AuthStrategy::Anthropic); assert_eq!(auth.strategy, AuthStrategy::Anthropic);
} }
#[test]
fn test_extract_auth_anthropic_api_key() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_API_KEY": "sk-ant-test-key"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-ant-test-key");
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
}
#[test] #[test]
fn test_extract_auth_openrouter() { fn test_extract_auth_openrouter() {
let adapter = ClaudeAdapter::new(); let adapter = ClaudeAdapter::new();
@@ -424,7 +379,7 @@ mod tests {
fn test_build_url_openrouter() { fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new(); let adapter = ClaudeAdapter::new();
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages"); let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/messages"); assert_eq!(url, "https://openrouter.ai/api/v1/chat/completions");
} }
#[test] #[test]
@@ -444,13 +399,5 @@ mod tests {
} }
})); }));
assert!(adapter.needs_transform(&openrouter_provider)); assert!(adapter.needs_transform(&openrouter_provider));
let openrouter_disabled = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
},
"openrouter_compat_mode": false
}));
assert!(!adapter.needs_transform(&openrouter_disabled));
} }
} }
+1 -1
View File
@@ -174,7 +174,7 @@ mod tests {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false, is_proxy_target: None,
} }
} }
+12 -8
View File
@@ -120,7 +120,11 @@ impl GeminiAdapter {
/// 从 Provider 配置中提取原始 API Key /// 从 Provider 配置中提取原始 API Key
fn extract_key_raw(&self, provider: &Provider) -> Option<String> { fn extract_key_raw(&self, provider: &Provider) -> Option<String> {
if let Some(env) = provider.settings_config.get("env") { if let Some(env) = provider.settings_config.get("env") {
// 使用 GEMINI_API_KEY // 优先使用 GOOGLE_GEMINI_API_KEY
if let Some(key) = env.get("GOOGLE_GEMINI_API_KEY").and_then(|v| v.as_str()) {
return Some(key.to_string());
}
// 备选 GEMINI_API_KEY
if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) { if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
return Some(key.to_string()); return Some(key.to_string());
} }
@@ -250,7 +254,7 @@ mod tests {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false, is_proxy_target: None,
} }
} }
@@ -272,7 +276,7 @@ mod tests {
let adapter = GeminiAdapter::new(); let adapter = GeminiAdapter::new();
let provider = create_provider(json!({ let provider = create_provider(json!({
"env": { "env": {
"GEMINI_API_KEY": "AIza-test-key-12345678" "GOOGLE_GEMINI_API_KEY": "AIza-test-key-12345678"
} }
})); }));
@@ -287,7 +291,7 @@ mod tests {
let adapter = GeminiAdapter::new(); let adapter = GeminiAdapter::new();
let provider = create_provider(json!({ let provider = create_provider(json!({
"env": { "env": {
"GEMINI_API_KEY": "ya29.test-access-token-12345" "GOOGLE_GEMINI_API_KEY": "ya29.test-access-token-12345"
} }
})); }));
@@ -304,7 +308,7 @@ mod tests {
let adapter = GeminiAdapter::new(); let adapter = GeminiAdapter::new();
let provider = create_provider(json!({ let provider = create_provider(json!({
"env": { "env": {
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test-token\",\"refresh_token\":\"1//refresh\"}" "GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test-token\",\"refresh_token\":\"1//refresh\"}"
} }
})); }));
@@ -320,7 +324,7 @@ mod tests {
// API Key // API Key
let api_key_provider = create_provider(json!({ let api_key_provider = create_provider(json!({
"env": { "env": {
"GEMINI_API_KEY": "AIza-test-key" "GOOGLE_GEMINI_API_KEY": "AIza-test-key"
} }
})); }));
assert_eq!( assert_eq!(
@@ -331,7 +335,7 @@ mod tests {
// OAuth access_token // OAuth access_token
let oauth_provider = create_provider(json!({ let oauth_provider = create_provider(json!({
"env": { "env": {
"GEMINI_API_KEY": "ya29.test-token" "GOOGLE_GEMINI_API_KEY": "ya29.test-token"
} }
})); }));
assert_eq!( assert_eq!(
@@ -342,7 +346,7 @@ mod tests {
// OAuth JSON // OAuth JSON
let oauth_json_provider = create_provider(json!({ let oauth_json_provider = create_provider(json!({
"env": { "env": {
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test\"}" "GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test\"}"
} }
})); }));
assert_eq!( assert_eq!(
+8 -12
View File
@@ -48,21 +48,17 @@ pub enum ProviderType {
Gemini, Gemini,
/// Google Gemini CLI (OAuth Bearer) /// Google Gemini CLI (OAuth Bearer)
GeminiCli, GeminiCli,
/// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用) /// OpenRouter (需要 Anthropic ↔ OpenAI 格式转换)
OpenRouter, OpenRouter,
} }
impl ProviderType { impl ProviderType {
/// 是否需要格式转换 /// 是否需要格式转换
/// ///
/// 过去 OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式 /// OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式
/// 现在默认关闭转换(因为 OpenRouter 已支持 Claude Code 兼容接口)。
#[allow(dead_code)] #[allow(dead_code)]
pub fn needs_transform(&self) -> bool { pub fn needs_transform(&self) -> bool {
match self { matches!(self, ProviderType::OpenRouter)
ProviderType::OpenRouter => false,
_ => false,
}
} }
/// 获取默认端点 /// 获取默认端点
@@ -209,7 +205,7 @@ mod tests {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false, is_proxy_target: None,
} }
} }
@@ -220,7 +216,7 @@ mod tests {
assert!(!ProviderType::Codex.needs_transform()); assert!(!ProviderType::Codex.needs_transform());
assert!(!ProviderType::Gemini.needs_transform()); assert!(!ProviderType::Gemini.needs_transform());
assert!(!ProviderType::GeminiCli.needs_transform()); assert!(!ProviderType::GeminiCli.needs_transform());
assert!(!ProviderType::OpenRouter.needs_transform()); assert!(ProviderType::OpenRouter.needs_transform());
} }
#[test] #[test]
@@ -373,7 +369,7 @@ mod tests {
fn test_from_app_type_gemini_api_key() { fn test_from_app_type_gemini_api_key() {
let provider = create_provider(json!({ let provider = create_provider(json!({
"env": { "env": {
"GEMINI_API_KEY": "AIza-test-key" "GOOGLE_GEMINI_API_KEY": "AIza-test-key"
} }
})); }));
@@ -385,7 +381,7 @@ mod tests {
fn test_from_app_type_gemini_cli_oauth() { fn test_from_app_type_gemini_cli_oauth() {
let provider = create_provider(json!({ let provider = create_provider(json!({
"env": { "env": {
"GEMINI_API_KEY": "ya29.test-access-token" "GOOGLE_GEMINI_API_KEY": "ya29.test-access-token"
} }
})); }));
@@ -397,7 +393,7 @@ mod tests {
fn test_from_app_type_gemini_cli_json() { fn test_from_app_type_gemini_cli_json() {
let provider = create_provider(json!({ let provider = create_provider(json!({
"env": { "env": {
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//test\"}" "GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//test\"}"
} }
})); }));
+1 -1
View File
@@ -394,7 +394,7 @@ mod tests {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false, is_proxy_target: None,
} }
} }
-523
View File
@@ -1,523 +0,0 @@
//! 响应处理器模块
//!
//! 统一处理流式和非流式 API 响应
use super::{
handler_config::UsageParserConfig,
handler_context::{RequestContext, StreamingTimeoutConfig},
server::ProxyState,
usage::parser::TokenUsage,
ProxyError,
};
use axum::response::Response;
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use rust_decimal::Decimal;
use serde_json::Value;
use std::{
str::FromStr,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::Duration,
};
use tokio::sync::Mutex;
// ============================================================================
// 公共接口
// ============================================================================
/// 检测响应是否为 SSE 流式响应
#[inline]
pub fn is_sse_response(response: &reqwest::Response) -> bool {
response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(|ct| ct.contains("text/event-stream"))
.unwrap_or(false)
}
/// 处理流式响应
pub async fn handle_streaming(
response: reqwest::Response,
ctx: &RequestContext,
state: &ProxyState,
parser_config: &UsageParserConfig,
) -> Response {
log::info!("[{}] 流式透传响应 (SSE)", ctx.tag);
let status = response.status();
let mut builder = axum::response::Response::builder().status(status);
// 复制响应头
for (key, value) in response.headers() {
builder = builder.header(key, value);
}
// 创建字节流
let stream = response
.bytes_stream()
.map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string())));
// 创建使用量收集器
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
// 获取流式超时配置
let timeout_config = ctx.streaming_timeout_config();
// 创建带日志和超时的透传流
let logged_stream =
create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector), timeout_config);
let body = axum::body::Body::from_stream(logged_stream);
builder.body(body).unwrap()
}
/// 处理非流式响应
pub async fn handle_non_streaming(
response: reqwest::Response,
ctx: &RequestContext,
state: &ProxyState,
parser_config: &UsageParserConfig,
) -> Result<Response, ProxyError> {
let response_headers = response.headers().clone();
let status = response.status();
// 读取响应体
let body_bytes = response.bytes().await.map_err(|e| {
log::error!("[{}] 读取响应失败: {e}", ctx.tag);
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
})?;
// 解析并记录使用量
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
log::info!(
"[{}] <<< 响应 JSON:\n{}",
ctx.tag,
serde_json::to_string_pretty(&json_value).unwrap_or_default()
);
// 解析使用量
if let Some(usage) = (parser_config.response_parser)(&json_value) {
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
let model = if let Some(ref m) = usage.model {
m.clone()
} else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) {
m.to_string()
} else {
ctx.request_model.clone()
};
spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false);
} else {
let model = json_value
.get("model")
.and_then(|m| m.as_str())
.unwrap_or(&ctx.request_model)
.to_string();
spawn_log_usage(
state,
ctx,
TokenUsage::default(),
&model,
status.as_u16(),
false,
);
log::debug!(
"[{}] 未能解析 usage 信息,跳过记录",
parser_config.app_type_str
);
}
} else {
log::info!(
"[{}] <<< 响应 (非 JSON): {} bytes",
ctx.tag,
body_bytes.len()
);
spawn_log_usage(
state,
ctx,
TokenUsage::default(),
&ctx.request_model,
status.as_u16(),
false,
);
}
log::info!("[{}] ====== 请求结束 ======", ctx.tag);
// 构建响应
let mut builder = axum::response::Response::builder().status(status);
for (key, value) in response_headers.iter() {
builder = builder.header(key, value);
}
let body = axum::body::Body::from(body_bytes);
Ok(builder.body(body).unwrap())
}
/// 通用响应处理入口
///
/// 根据响应类型自动选择流式或非流式处理
pub async fn process_response(
response: reqwest::Response,
ctx: &RequestContext,
state: &ProxyState,
parser_config: &UsageParserConfig,
) -> Result<Response, ProxyError> {
if is_sse_response(&response) {
Ok(handle_streaming(response, ctx, state, parser_config).await)
} else {
handle_non_streaming(response, ctx, state, parser_config).await
}
}
// ============================================================================
// SSE 使用量收集器
// ============================================================================
type UsageCallbackWithTiming = Arc<dyn Fn(Vec<Value>, Option<u64>) + Send + Sync + 'static>;
/// SSE 使用量收集器
#[derive(Clone)]
pub struct SseUsageCollector {
inner: Arc<SseUsageCollectorInner>,
}
struct SseUsageCollectorInner {
events: Mutex<Vec<Value>>,
first_event_time: Mutex<Option<std::time::Instant>>,
start_time: std::time::Instant,
on_complete: UsageCallbackWithTiming,
finished: AtomicBool,
}
impl SseUsageCollector {
/// 创建新的使用量收集器
pub fn new(
start_time: std::time::Instant,
callback: impl Fn(Vec<Value>, Option<u64>) + Send + Sync + 'static,
) -> Self {
let on_complete: UsageCallbackWithTiming = Arc::new(callback);
Self {
inner: Arc::new(SseUsageCollectorInner {
events: Mutex::new(Vec::new()),
first_event_time: Mutex::new(None),
start_time,
on_complete,
finished: AtomicBool::new(false),
}),
}
}
/// 推送 SSE 事件
pub async fn push(&self, event: Value) {
// 记录首个事件时间
{
let mut first_time = self.inner.first_event_time.lock().await;
if first_time.is_none() {
*first_time = Some(std::time::Instant::now());
}
}
let mut events = self.inner.events.lock().await;
events.push(event);
}
/// 完成收集并触发回调
pub async fn finish(&self) {
if self.inner.finished.swap(true, Ordering::SeqCst) {
return;
}
let events = {
let mut guard = self.inner.events.lock().await;
std::mem::take(&mut *guard)
};
let first_token_ms = {
let first_time = self.inner.first_event_time.lock().await;
first_time.map(|t| (t - self.inner.start_time).as_millis() as u64)
};
(self.inner.on_complete)(events, first_token_ms);
}
}
// ============================================================================
// 内部辅助函数
// ============================================================================
/// 创建使用量收集器
fn create_usage_collector(
ctx: &RequestContext,
state: &ProxyState,
status_code: u16,
parser_config: &UsageParserConfig,
) -> SseUsageCollector {
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let request_model = ctx.request_model.clone();
let app_type_str = parser_config.app_type_str;
let tag = ctx.tag;
let start_time = ctx.start_time;
let stream_parser = parser_config.stream_parser;
let model_extractor = parser_config.model_extractor;
let session_id = ctx.session_id.clone();
SseUsageCollector::new(start_time, move |events, first_token_ms| {
if let Some(usage) = stream_parser(&events) {
let model = model_extractor(&events, &request_model);
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
tokio::spawn(async move {
log_usage_internal(
&state,
&provider_id,
app_type_str,
&model,
usage,
latency_ms,
first_token_ms,
true, // is_streaming
status_code,
Some(session_id),
)
.await;
});
} else {
let model = model_extractor(&events, &request_model);
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
tokio::spawn(async move {
log_usage_internal(
&state,
&provider_id,
app_type_str,
&model,
TokenUsage::default(),
latency_ms,
first_token_ms,
true, // is_streaming
status_code,
Some(session_id),
)
.await;
});
log::debug!("[{tag}] 流式响应缺少 usage 统计,跳过消费记录");
}
})
}
/// 异步记录使用量
fn spawn_log_usage(
state: &ProxyState,
ctx: &RequestContext,
usage: TokenUsage,
model: &str,
status_code: u16,
is_streaming: bool,
) {
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let app_type_str = ctx.app_type_str.to_string();
let model = model.to_string();
let latency_ms = ctx.latency_ms();
let session_id = ctx.session_id.clone();
tokio::spawn(async move {
log_usage_internal(
&state,
&provider_id,
&app_type_str,
&model,
usage,
latency_ms,
None,
is_streaming,
status_code,
Some(session_id),
)
.await;
});
}
/// 内部使用量记录函数
#[allow(clippy::too_many_arguments)]
async fn log_usage_internal(
state: &ProxyState,
provider_id: &str,
app_type: &str,
model: &str,
usage: TokenUsage,
latency_ms: u64,
first_token_ms: Option<u64>,
is_streaming: bool,
status_code: u16,
session_id: Option<String>,
) {
use super::usage::logger::UsageLogger;
let logger = UsageLogger::new(&state.db);
// 获取 provider 的 cost_multiplier
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
Ok(Some(p)) => {
if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier {
Decimal::from_str(&cm).unwrap_or(Decimal::from(1))
} else {
Decimal::from(1)
}
} else {
Decimal::from(1)
}
}
_ => Decimal::from(1),
};
let request_id = uuid::Uuid::new_v4().to_string();
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={}",
session_id.as_deref().unwrap_or("none"),
usage.input_tokens,
usage.output_tokens,
usage.cache_read_tokens,
usage.cache_creation_tokens
);
if let Err(e) = logger.log_with_calculation(
request_id,
provider_id.to_string(),
app_type.to_string(),
model.to_string(),
usage,
multiplier,
latency_ms,
first_token_ms,
status_code,
session_id,
None, // provider_type
is_streaming,
) {
log::warn!("记录使用量失败: {e}");
}
}
/// 创建带日志记录和超时控制的透传流
pub fn create_logged_passthrough_stream(
stream: impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
tag: &'static str,
usage_collector: Option<SseUsageCollector>,
timeout_config: StreamingTimeoutConfig,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! {
let mut buffer = String::new();
let mut collector = usage_collector;
let mut is_first_chunk = true;
// 超时配置
let first_byte_timeout = if timeout_config.first_byte_timeout > 0 {
Some(Duration::from_secs(timeout_config.first_byte_timeout))
} else {
None
};
let idle_timeout = if timeout_config.idle_timeout > 0 {
Some(Duration::from_secs(timeout_config.idle_timeout))
} else {
None
};
tokio::pin!(stream);
loop {
// 选择超时时间:首字节超时或静默期超时
let timeout_duration = if is_first_chunk {
first_byte_timeout
} else {
idle_timeout
};
let chunk_result = match timeout_duration {
Some(duration) => {
match tokio::time::timeout(duration, stream.next()).await {
Ok(Some(chunk)) => Some(chunk),
Ok(None) => None, // 流结束
Err(_) => {
// 超时
let timeout_type = if is_first_chunk { "首字节" } else { "静默期" };
log::error!("[{tag}] 流式响应{}超时 ({}秒)", timeout_type, duration.as_secs());
yield Err(std::io::Error::other(format!("流式响应{timeout_type}超时")));
break;
}
}
}
None => stream.next().await, // 无超时限制
};
match chunk_result {
Some(Ok(bytes)) => {
is_first_chunk = false;
let text = String::from_utf8_lossy(&bytes);
buffer.push_str(&text);
// 尝试解析并记录完整的 SSE 事件
while let Some(pos) = buffer.find("\n\n") {
let event_text = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
if !event_text.trim().is_empty() {
// 提取 data 部分并尝试解析为 JSON
for line in event_text.lines() {
if let Some(data) = line.strip_prefix("data: ") {
if data.trim() != "[DONE]" {
if let Ok(json_value) = serde_json::from_str::<Value>(data) {
if let Some(c) = &collector {
c.push(json_value.clone()).await;
}
log::info!(
"[{}] <<< SSE 事件:\n{}",
tag,
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
);
} else {
log::info!("[{tag}] <<< SSE 数据: {data}");
}
} else {
log::info!("[{tag}] <<< SSE: [DONE]");
}
}
}
}
}
yield Ok(bytes);
}
Some(Err(e)) => {
log::error!("[{tag}] 流错误: {e}");
yield Err(std::io::Error::other(e.to_string()));
break;
}
None => {
// 流正常结束
break;
}
}
}
log::info!("[{}] ====== 流结束 ======", tag);
if let Some(c) = collector.take() {
c.finish().await;
}
}
}
+69
View File
@@ -0,0 +1,69 @@
//! Provider路由器
//!
//! 负责选择合适的Provider进行请求转发
use super::ProxyError;
use crate::{app_config::AppType, database::Database, provider::Provider};
use std::sync::Arc;
pub struct ProviderRouter {
db: Arc<Database>,
}
impl ProviderRouter {
pub fn new(db: Arc<Database>) -> Self {
Self { db }
}
/// 选择Provider(只使用标记为代理目标的 Provider)
pub async fn select_provider(
&self,
app_type: &AppType,
_failed_ids: &[String],
) -> Result<Provider, ProxyError> {
// 1. 获取 Proxy Target Provider ID
let proxy_target_id = self
.db
.get_proxy_target_provider(app_type.as_str())
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
let target_id = proxy_target_id.ok_or_else(|| {
log::warn!("[{}] 未设置代理目标 Provider", app_type.as_str());
ProxyError::NoAvailableProvider
})?;
// 2. 获取所有 Provider
let providers = self
.db
.get_all_providers(app_type.as_str())
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
// 3. 找到目标 Provider
let target = providers.get(&target_id).ok_or_else(|| {
log::warn!(
"[{}] 代理目标 Provider 不存在: {}",
app_type.as_str(),
target_id
);
ProxyError::NoAvailableProvider
})?;
log::info!(
"[{}] 使用代理目标 Provider: {}",
app_type.as_str(),
target.name
);
Ok(target.clone())
}
/// 更新Provider健康状态(保留接口但不影响选择)
pub async fn update_health(
&self,
_provider: &Provider,
_app_type: &AppType,
_success: bool,
_error_msg: Option<String>,
) {
// 不再记录健康状态
}
}
+20 -93
View File
@@ -2,10 +2,7 @@
//! //!
//! 基于Axum的HTTP服务器,处理代理请求 //! 基于Axum的HTTP服务器,处理代理请求
use super::{ use super::{handlers, types::*, ProxyError};
failover_switch::FailoverSwitchManager, handlers, provider_router::ProviderRouter, types::*,
ProxyError,
};
use crate::database::Database; use crate::database::Database;
use axum::{ use axum::{
routing::{get, post}, routing::{get, post},
@@ -14,7 +11,6 @@ use axum::{
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{oneshot, RwLock}; use tokio::sync::{oneshot, RwLock};
use tokio::task::JoinHandle;
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
/// 代理服务器状态(共享) /// 代理服务器状态(共享)
@@ -24,14 +20,6 @@ pub struct ProxyState {
pub config: Arc<RwLock<ProxyConfig>>, pub config: Arc<RwLock<ProxyConfig>>,
pub status: Arc<RwLock<ProxyStatus>>, pub status: Arc<RwLock<ProxyStatus>>,
pub start_time: Arc<RwLock<Option<std::time::Instant>>>, pub start_time: Arc<RwLock<Option<std::time::Instant>>>,
/// 每个应用类型当前使用的 provider (app_type -> (provider_id, provider_name))
pub current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
/// 共享的 ProviderRouter(持有熔断器状态,跨请求保持)
pub provider_router: Arc<ProviderRouter>,
/// AppHandle,用于发射事件和更新托盘菜单
pub app_handle: Option<tauri::AppHandle>,
/// 故障转移切换管理器
pub failover_manager: Arc<FailoverSwitchManager>,
} }
/// 代理HTTP服务器 /// 代理HTTP服务器
@@ -39,37 +27,21 @@ pub struct ProxyServer {
config: ProxyConfig, config: ProxyConfig,
state: ProxyState, state: ProxyState,
shutdown_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>, shutdown_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
/// 服务器任务句柄,用于等待服务器实际关闭
server_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
} }
impl ProxyServer { impl ProxyServer {
pub fn new( pub fn new(config: ProxyConfig, db: Arc<Database>) -> Self {
config: ProxyConfig,
db: Arc<Database>,
app_handle: Option<tauri::AppHandle>,
) -> Self {
// 创建共享的 ProviderRouter(熔断器状态将跨所有请求保持)
let provider_router = Arc::new(ProviderRouter::new(db.clone()));
// 创建故障转移切换管理器
let failover_manager = Arc::new(FailoverSwitchManager::new(db.clone()));
let state = ProxyState { let state = ProxyState {
db, db,
config: Arc::new(RwLock::new(config.clone())), config: Arc::new(RwLock::new(config.clone())),
status: Arc::new(RwLock::new(ProxyStatus::default())), status: Arc::new(RwLock::new(ProxyStatus::default())),
start_time: Arc::new(RwLock::new(None)), start_time: Arc::new(RwLock::new(None)),
current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())),
provider_router,
app_handle,
failover_manager,
}; };
Self { Self {
config, config,
state, state,
shutdown_tx: Arc::new(RwLock::new(None)), shutdown_tx: Arc::new(RwLock::new(None)),
server_handle: Arc::new(RwLock::new(None)),
} }
} }
@@ -112,7 +84,7 @@ impl ProxyServer {
// 启动服务器 // 启动服务器
let state = self.state.clone(); let state = self.state.clone();
let handle = tokio::spawn(async move { tokio::spawn(async move {
axum::serve(listener, app) axum::serve(listener, app)
.with_graceful_shutdown(async { .with_graceful_shutdown(async {
shutdown_rx.await.ok(); shutdown_rx.await.ok();
@@ -125,9 +97,6 @@ impl ProxyServer {
*state.start_time.write().await = None; *state.start_time.write().await = None;
}); });
// 保存服务器任务句柄
*self.server_handle.write().await = Some(handle);
Ok(ProxyServerInfo { Ok(ProxyServerInfo {
address: self.config.listen_address.clone(), address: self.config.listen_address.clone(),
port: self.config.listen_port, port: self.config.listen_port,
@@ -136,23 +105,12 @@ impl ProxyServer {
} }
pub async fn stop(&self) -> Result<(), ProxyError> { pub async fn stop(&self) -> Result<(), ProxyError> {
// 1. 发送关闭信号
if let Some(tx) = self.shutdown_tx.write().await.take() { if let Some(tx) = self.shutdown_tx.write().await.take() {
let _ = tx.send(()); let _ = tx.send(());
Ok(())
} else { } else {
return Err(ProxyError::NotRunning); Err(ProxyError::NotRunning)
} }
// 2. 等待服务器任务结束(带 5 秒超时保护)
if let Some(handle) = self.server_handle.write().await.take() {
match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await {
Ok(Ok(())) => log::info!("代理服务器已完全停止"),
Ok(Err(e)) => log::warn!("代理服务器任务异常终止: {e}"),
Err(_) => log::warn!("代理服务器停止超时(5秒),强制继续"),
}
}
Ok(())
} }
pub async fn get_status(&self) -> ProxyStatus { pub async fn get_status(&self) -> ProxyStatus {
@@ -163,16 +121,17 @@ impl ProxyServer {
status.uptime_seconds = start.elapsed().as_secs(); status.uptime_seconds = start.elapsed().as_secs();
} }
// 从 current_providers HashMap 获取每个应用类型当前正在使用的 provider // 获取所有活跃的代理目标
let current_providers = self.state.current_providers.read().await; if let Ok(targets) = self.state.db.get_all_proxy_targets() {
status.active_targets = current_providers status.active_targets = targets
.iter() .into_iter()
.map(|(app_type, (provider_id, provider_name))| ActiveTarget { .map(|(app_type, name, id)| ActiveTarget {
app_type: app_type.clone(), app_type,
provider_id: provider_id.clone(), provider_name: name,
provider_name: provider_name.clone(), provider_id: id,
}) })
.collect(); .collect();
}
status status
} }
@@ -187,31 +146,17 @@ impl ProxyServer {
// 健康检查 // 健康检查
.route("/health", get(handlers::health_check)) .route("/health", get(handlers::health_check))
.route("/status", get(handlers::get_status)) .route("/status", get(handlers::get_status))
// Claude API (支持带前缀和不带前缀两种格式) // Claude API
.route("/v1/messages", post(handlers::handle_messages)) .route("/v1/messages", post(handlers::handle_messages))
.route("/claude/v1/messages", post(handlers::handle_messages)) // OpenAI Chat Completions API (Codex CLI)
// OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀)
.route("/chat/completions", post(handlers::handle_chat_completions))
.route( .route(
"/v1/chat/completions", "/v1/chat/completions",
post(handlers::handle_chat_completions), post(handlers::handle_chat_completions),
) )
.route( // OpenAI Responses API (Codex CLI)
"/v1/v1/chat/completions",
post(handlers::handle_chat_completions),
)
.route(
"/codex/v1/chat/completions",
post(handlers::handle_chat_completions),
)
// OpenAI Responses API (Codex CLI,支持带前缀和不带前缀)
.route("/responses", post(handlers::handle_responses))
.route("/v1/responses", post(handlers::handle_responses)) .route("/v1/responses", post(handlers::handle_responses))
.route("/v1/v1/responses", post(handlers::handle_responses)) // Gemini API
.route("/codex/v1/responses", post(handlers::handle_responses))
// Gemini API (支持带前缀和不带前缀)
.route("/v1beta/*path", post(handlers::handle_gemini)) .route("/v1beta/*path", post(handlers::handle_gemini))
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
.layer(cors) .layer(cors)
.with_state(self.state.clone()) .with_state(self.state.clone())
} }
@@ -220,22 +165,4 @@ impl ProxyServer {
pub async fn apply_runtime_config(&self, config: &ProxyConfig) { pub async fn apply_runtime_config(&self, config: &ProxyConfig) {
*self.state.config.write().await = config.clone(); *self.state.config.write().await = config.clone();
} }
/// 热更新熔断器配置
///
/// 将新配置应用到所有已创建的熔断器实例
pub async fn update_circuit_breaker_configs(
&self,
config: super::circuit_breaker::CircuitBreakerConfig,
) {
self.state.provider_router.update_all_configs(config).await;
}
/// 重置指定 Provider 的熔断器
pub async fn reset_provider_circuit_breaker(&self, provider_id: &str, app_type: &str) {
self.state
.provider_router
.reset_provider_breaker(provider_id, app_type)
.await;
}
} }
-269
View File
@@ -1,15 +1,7 @@
//! Proxy Session - 请求会话管理 //! Proxy Session - 请求会话管理
//! //!
//! 为每个代理请求创建会话上下文,在整个请求生命周期中跟踪状态和元数据。 //! 为每个代理请求创建会话上下文,在整个请求生命周期中跟踪状态和元数据。
//!
//! ## Session ID 提取
//!
//! 支持从客户端请求中提取 Session ID,用于关联同一对话的多个请求:
//! - Claude: 从 `metadata.user_id` (格式: `user_xxx_session_yyy`) 或 `metadata.session_id` 提取
//! - Codex: 从 `previous_response_id` 或 headers 中的 `session_id` 提取
//! - 其他: 生成新的 UUID
use axum::http::HeaderMap;
use std::time::Instant; use std::time::Instant;
use uuid::Uuid; use uuid::Uuid;
@@ -184,179 +176,6 @@ impl ProxySession {
} }
} }
// ============================================================================
// Session ID 提取器
// ============================================================================
/// Session ID 来源
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionIdSource {
/// 从 metadata.user_id 提取 (Claude)
MetadataUserId,
/// 从 metadata.session_id 提取
MetadataSessionId,
/// 从 headers 提取 (Codex)
Header,
/// 从 previous_response_id 提取 (Codex)
PreviousResponseId,
/// 新生成
Generated,
}
/// Session ID 提取结果
#[derive(Debug, Clone)]
pub struct SessionIdResult {
/// 提取或生成的 Session ID
pub session_id: String,
/// Session ID 来源
pub source: SessionIdSource,
/// 是否为客户端提供的 ID(非新生成)
pub client_provided: bool,
}
/// 从请求中提取或生成 Session ID
///
/// 轻量化实现,仅提取 session_id 用于日志记录,不做复杂的 Session 管理。
///
/// ## 提取优先级
///
/// ### Claude 请求
/// 1. `metadata.user_id` (格式: `user_xxx_session_yyy`) → 提取 `yyy` 部分
/// 2. `metadata.session_id` → 直接使用
/// 3. 生成新 UUID
///
/// ### Codex 请求
/// 1. Headers: `session_id` 或 `x-session-id`
/// 2. `metadata.session_id`
/// 3. `previous_response_id` (对话延续)
/// 4. 生成新 UUID
///
/// ## 示例
///
/// ```ignore
/// let result = extract_session_id(&headers, &body, "claude");
/// println!("Session ID: {} (from {:?})", result.session_id, result.source);
/// ```
pub fn extract_session_id(
headers: &HeaderMap,
body: &serde_json::Value,
client_format: &str,
) -> SessionIdResult {
// Codex 请求特殊处理
if client_format == "codex" || client_format == "openai" {
if let Some(result) = extract_codex_session(headers, body) {
return result;
}
}
// Claude 请求:从 metadata 提取
if let Some(result) = extract_from_metadata(body) {
return result;
}
// 兜底:生成新 Session ID
generate_new_session_id()
}
/// 提取 Codex Session ID
fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<SessionIdResult> {
// 1. 从 headers 提取
for header_name in &["session_id", "x-session-id"] {
if let Some(value) = headers.get(*header_name) {
if let Ok(session_id) = value.to_str() {
// Codex Session ID 通常较长(UUID 格式)
if session_id.len() > 20 {
return Some(SessionIdResult {
session_id: format!("codex_{session_id}"),
source: SessionIdSource::Header,
client_provided: true,
});
}
}
}
}
// 2. 从 body.metadata.session_id 提取
if let Some(session_id) = body
.get("metadata")
.and_then(|m| m.get("session_id"))
.and_then(|v| v.as_str())
{
if session_id.len() > 10 {
return Some(SessionIdResult {
session_id: format!("codex_{session_id}"),
source: SessionIdSource::MetadataSessionId,
client_provided: true,
});
}
}
// 3. 从 previous_response_id 提取(对话延续)
if let Some(prev_id) = body.get("previous_response_id").and_then(|v| v.as_str()) {
if prev_id.len() > 10 {
return Some(SessionIdResult {
session_id: format!("codex_{prev_id}"),
source: SessionIdSource::PreviousResponseId,
client_provided: true,
});
}
}
None
}
/// 从 metadata 提取 Session ID (Claude)
fn extract_from_metadata(body: &serde_json::Value) -> Option<SessionIdResult> {
let metadata = body.get("metadata")?;
// 1. 从 metadata.user_id 提取(格式: user_xxx_session_yyy
if let Some(user_id) = metadata.get("user_id").and_then(|v| v.as_str()) {
if let Some(session_id) = parse_session_from_user_id(user_id) {
return Some(SessionIdResult {
session_id,
source: SessionIdSource::MetadataUserId,
client_provided: true,
});
}
}
// 2. 直接从 metadata.session_id 提取
if let Some(session_id) = metadata.get("session_id").and_then(|v| v.as_str()) {
if !session_id.is_empty() {
return Some(SessionIdResult {
session_id: session_id.to_string(),
source: SessionIdSource::MetadataSessionId,
client_provided: true,
});
}
}
None
}
/// 从 user_id 解析 session_id
///
/// 格式: `user_identifier_session_actual_session_id`
fn parse_session_from_user_id(user_id: &str) -> Option<String> {
// 查找 "_session_" 分隔符
if let Some(pos) = user_id.find("_session_") {
let session_id = &user_id[pos + 9..]; // "_session_" 长度为 9
if !session_id.is_empty() {
return Some(session_id.to_string());
}
}
None
}
/// 生成新的 Session ID
fn generate_new_session_id() -> SessionIdResult {
SessionIdResult {
session_id: Uuid::new_v4().to_string(),
source: SessionIdSource::Generated,
client_provided: false,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -476,92 +295,4 @@ mod tests {
assert_eq!(ClientFormat::GeminiCli.as_str(), "gemini_cli"); assert_eq!(ClientFormat::GeminiCli.as_str(), "gemini_cli");
assert_eq!(ClientFormat::Unknown.as_str(), "unknown"); assert_eq!(ClientFormat::Unknown.as_str(), "unknown");
} }
// ========== Session ID 提取测试 ==========
#[test]
fn test_extract_session_from_claude_metadata_user_id() {
let headers = HeaderMap::new();
let body = json!({
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": "Hello"}],
"metadata": {
"user_id": "user_john_doe_session_abc123def456"
}
});
let result = extract_session_id(&headers, &body, "claude");
assert_eq!(result.session_id, "abc123def456");
assert_eq!(result.source, SessionIdSource::MetadataUserId);
assert!(result.client_provided);
}
#[test]
fn test_extract_session_from_claude_metadata_session_id() {
let headers = HeaderMap::new();
let body = json!({
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": "Hello"}],
"metadata": {
"session_id": "my-session-123"
}
});
let result = extract_session_id(&headers, &body, "claude");
assert_eq!(result.session_id, "my-session-123");
assert_eq!(result.source, SessionIdSource::MetadataSessionId);
assert!(result.client_provided);
}
#[test]
fn test_extract_session_from_codex_previous_response_id() {
let headers = HeaderMap::new();
let body = json!({
"input": "Write a function",
"previous_response_id": "resp_abc123def456789"
});
let result = extract_session_id(&headers, &body, "codex");
assert_eq!(result.session_id, "codex_resp_abc123def456789");
assert_eq!(result.source, SessionIdSource::PreviousResponseId);
assert!(result.client_provided);
}
#[test]
fn test_extract_session_generates_new_when_not_found() {
let headers = HeaderMap::new();
let body = json!({
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": "Hello"}]
});
let result = extract_session_id(&headers, &body, "claude");
assert!(!result.session_id.is_empty());
assert_eq!(result.source, SessionIdSource::Generated);
assert!(!result.client_provided);
}
#[test]
fn test_parse_session_from_user_id() {
assert_eq!(
parse_session_from_user_id("user_john_session_abc123"),
Some("abc123".to_string())
);
assert_eq!(
parse_session_from_user_id("my_app_session_xyz789"),
Some("xyz789".to_string())
);
// 注意: "_session_" 是分隔符,所以下面的字符串会匹配
assert_eq!(
parse_session_from_user_id("no_session_marker"),
Some("marker".to_string())
);
// 没有 "_session_" 分隔符的情况
assert_eq!(parse_session_from_user_id("user_john_abc123"), None);
assert_eq!(parse_session_from_user_id("_session_"), None);
}
} }
+14 -88
View File
@@ -3,54 +3,29 @@ use serde::{Deserialize, Serialize};
/// 代理服务器配置 /// 代理服务器配置
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyConfig { pub struct ProxyConfig {
/// 是否启用代理服务
pub enabled: bool,
/// 监听地址 /// 监听地址
pub listen_address: String, pub listen_address: String,
/// 监听端口 /// 监听端口
pub listen_port: u16, pub listen_port: u16,
/// 最大重试次数 /// 最大重试次数
pub max_retries: u8, pub max_retries: u8,
/// 请求超时时间(秒)- 已废弃,保留兼容 /// 请求超时时间(秒)
pub request_timeout: u64, pub request_timeout: u64,
/// 是否启用日志 /// 是否启用日志
pub enable_logging: bool, pub enable_logging: bool,
/// 是否正在接管 Live 配置
#[serde(default)]
pub live_takeover_active: bool,
/// 流式首字超时(秒)- 等待首个数据块的最大时间
#[serde(default = "default_streaming_first_byte_timeout")]
pub streaming_first_byte_timeout: u64,
/// 流式静默超时(秒)- 两个数据块之间的最大间隔
#[serde(default = "default_streaming_idle_timeout")]
pub streaming_idle_timeout: u64,
/// 非流式总超时(秒)- 非流式请求的总超时时间
#[serde(default = "default_non_streaming_timeout")]
pub non_streaming_timeout: u64,
}
fn default_streaming_first_byte_timeout() -> u64 {
30
}
fn default_streaming_idle_timeout() -> u64 {
60
}
fn default_non_streaming_timeout() -> u64 {
600
} }
impl Default for ProxyConfig { impl Default for ProxyConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false,
listen_address: "127.0.0.1".to_string(), listen_address: "127.0.0.1".to_string(),
listen_port: 15721, // 使用较少占用的高位端口 listen_port: 15721, // 使用较少占用的高位端口
max_retries: 3, max_retries: 3,
request_timeout: 300, request_timeout: 300,
enable_logging: true, enable_logging: true,
live_takeover_active: false,
streaming_first_byte_timeout: 30,
streaming_idle_timeout: 60,
non_streaming_timeout: 600,
} }
} }
} }
@@ -107,14 +82,6 @@ pub struct ProxyServerInfo {
pub started_at: String, pub started_at: String,
} }
/// 各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProxyTakeoverStatus {
pub claude: bool,
pub codex: bool,
pub gemini: bool,
}
/// API 格式类型(预留,当前不需要格式转换) /// API 格式类型(预留,当前不需要格式转换)
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)] #[allow(dead_code)]
@@ -137,57 +104,16 @@ pub struct ProviderHealth {
pub updated_at: String, pub updated_at: String,
} }
/// Live 配置备份记录 /// 使用统计记录
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiveBackup { pub struct ProxyUsageRecord {
/// 应用类型 (claude/codex/gemini) pub provider_id: String,
pub app_type: String, pub app_type: String,
/// 原始配置 JSON pub endpoint: String,
pub original_config: String, pub request_tokens: Option<i32>,
/// 备份时间 pub response_tokens: Option<i32>,
pub backed_up_at: String, pub status_code: u16,
} pub latency_ms: u64,
pub error: Option<String>,
/// 全局代理配置(统一字段,三行镜像) pub timestamp: String,
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GlobalProxyConfig {
/// 代理总开关
pub proxy_enabled: bool,
/// 监听地址
pub listen_address: String,
/// 监听端口
pub listen_port: u16,
/// 是否启用日志
pub enable_logging: bool,
}
/// 应用级代理配置(每个 app 独立)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppProxyConfig {
/// 应用类型 (claude/codex/gemini)
pub app_type: String,
/// 该 app 代理启用开关
pub enabled: bool,
/// 该 app 自动故障转移开关
pub auto_failover_enabled: bool,
/// 最大重试次数
pub max_retries: u32,
/// 流式首字超时(秒)
pub streaming_first_byte_timeout: u32,
/// 流式静默超时(秒)
pub streaming_idle_timeout: u32,
/// 非流式总超时(秒)
pub non_streaming_timeout: u32,
/// 熔断失败阈值
pub circuit_failure_threshold: u32,
/// 熔断恢复阈值
pub circuit_success_threshold: u32,
/// 熔断恢复等待时间(秒)
pub circuit_timeout_seconds: u32,
/// 错误率阈值
pub circuit_error_rate_threshold: f64,
/// 计算错误率的最小请求数
pub circuit_min_requests: u32,
} }
+5 -13
View File
@@ -35,11 +35,6 @@ impl CostCalculator {
/// - `usage`: Token 使用量 /// - `usage`: Token 使用量
/// - `pricing`: 模型定价 /// - `pricing`: 模型定价
/// - `cost_multiplier`: 成本倍数 (provider 自定义) /// - `cost_multiplier`: 成本倍数 (provider 自定义)
///
/// # 计算逻辑
/// - input_cost: (input_tokens - cache_read_tokens) × 输入价格
/// - cache_read_cost: cache_read_tokens × 缓存读取价格
/// - 这样避免缓存部分被重复计费
pub fn calculate( pub fn calculate(
usage: &TokenUsage, usage: &TokenUsage,
pricing: &ModelPricing, pricing: &ModelPricing,
@@ -47,10 +42,7 @@ impl CostCalculator {
) -> CostBreakdown { ) -> CostBreakdown {
let million = Decimal::from(1_000_000); let million = Decimal::from(1_000_000);
// 计算实际需要按输入价格计费的 token 数(减去缓存命中部分) let input_cost = Decimal::from(usage.input_tokens) * pricing.input_cost_per_million
let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens);
let input_cost = Decimal::from(billable_input_tokens) * pricing.input_cost_per_million
/ million / million
* cost_multiplier; * cost_multiplier;
let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million
@@ -121,8 +113,8 @@ mod tests {
let cost = CostCalculator::calculate(&usage, &pricing, multiplier); let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
// input: (1000 - 200) * 3.0 / 1M = 0.0024 (只计算非缓存部分) // input: 1000 * 3.0 / 1M = 0.003
assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap()); assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap());
// output: 500 * 15.0 / 1M = 0.0075 // output: 500 * 15.0 / 1M = 0.0075
assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap()); assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap());
// cache_read: 200 * 0.3 / 1M = 0.00006 // cache_read: 200 * 0.3 / 1M = 0.00006
@@ -132,8 +124,8 @@ mod tests {
cost.cache_creation_cost, cost.cache_creation_cost,
Decimal::from_str("0.000375").unwrap() Decimal::from_str("0.000375").unwrap()
); );
// total: 0.0024 + 0.0075 + 0.00006 + 0.000375 = 0.010335 // total: 0.003 + 0.0075 + 0.00006 + 0.000375 = 0.010935
assert_eq!(cost.total_cost, Decimal::from_str("0.010335").unwrap()); assert_eq!(cost.total_cost, Decimal::from_str("0.010935").unwrap());
} }
#[test] #[test]
-39
View File
@@ -107,8 +107,6 @@ impl<'a> UsageLogger<'a> {
} }
/// 记录失败的请求 /// 记录失败的请求
///
/// 用于记录无法从上游获取 usage 信息的失败请求
#[allow(dead_code, clippy::too_many_arguments)] #[allow(dead_code, clippy::too_many_arguments)]
pub fn log_error( pub fn log_error(
&self, &self,
@@ -140,43 +138,6 @@ impl<'a> UsageLogger<'a> {
self.log_request(&log) self.log_request(&log)
} }
/// 记录失败的请求(带更多上下文信息)
///
/// 相比 log_error,这个方法接受更多参数以提供完整的请求上下文
#[allow(clippy::too_many_arguments)]
pub fn log_error_with_context(
&self,
request_id: String,
provider_id: String,
app_type: String,
model: String,
status_code: u16,
error_message: String,
latency_ms: u64,
is_streaming: bool,
session_id: Option<String>,
provider_type: Option<String>,
) -> Result<(), AppError> {
let log = RequestLog {
request_id,
provider_id,
app_type,
model,
usage: TokenUsage::default(),
cost: None,
latency_ms,
first_token_ms: None,
status_code,
error_message: Some(error_message),
session_id,
provider_type,
is_streaming,
cost_multiplier: "1.0".to_string(),
};
self.log_request(&log)
}
/// 获取模型定价 /// 获取模型定价
pub fn get_model_pricing(&self, model_id: &str) -> Result<Option<ModelPricing>, AppError> { pub fn get_model_pricing(&self, model_id: &str) -> Result<Option<ModelPricing>, AppError> {
let conn = crate::database::lock_conn!(self.db.conn); let conn = crate::database::lock_conn!(self.db.conn);
+21 -368
View File
@@ -34,12 +34,6 @@ impl TokenUsage {
/// 从 Claude API 非流式响应解析 /// 从 Claude API 非流式响应解析
pub fn from_claude_response(body: &Value) -> Option<Self> { pub fn from_claude_response(body: &Value) -> Option<Self> {
let usage = body.get("usage")?; let usage = body.get("usage")?;
// 提取响应中的模型名称
let model = body
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self { Some(Self {
input_tokens: usage.get("input_tokens")?.as_u64()? as u32, input_tokens: usage.get("input_tokens")?.as_u64()? as u32,
output_tokens: usage.get("output_tokens")?.as_u64()? as u32, output_tokens: usage.get("output_tokens")?.as_u64()? as u32,
@@ -51,7 +45,7 @@ impl TokenUsage {
.get("cache_creation_input_tokens") .get("cache_creation_input_tokens")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32, .unwrap_or(0) as u32,
model, model: None,
}) })
} }
@@ -59,20 +53,11 @@ impl TokenUsage {
#[allow(dead_code)] #[allow(dead_code)]
pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> { pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> {
let mut usage = Self::default(); let mut usage = Self::default();
let mut model: Option<String> = None;
for event in events { for event in events {
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
match event_type { match event_type {
"message_start" => { "message_start" => {
// 从 message_start 提取模型名称
if model.is_none() {
if let Some(message) = event.get("message") {
if let Some(m) = message.get("model").and_then(|v| v.as_str()) {
model = Some(m.to_string());
}
}
}
if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) { if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) {
// 从 message_start 获取 input_tokens(原生 Claude API // 从 message_start 获取 input_tokens(原生 Claude API
if let Some(input) = if let Some(input) =
@@ -117,7 +102,6 @@ impl TokenUsage {
} }
if usage.input_tokens > 0 || usage.output_tokens > 0 { if usage.input_tokens > 0 || usage.output_tokens > 0 {
usage.model = model;
Some(usage) Some(usage)
} else { } else {
None None
@@ -157,32 +141,18 @@ impl TokenUsage {
return None; return None;
} }
// 提取响应中的模型名称
let model = body
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let cached_tokens = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
usage
.get("input_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0) as u32;
Some(Self { Some(Self {
input_tokens: input_tokens? as u32, input_tokens: input_tokens? as u32,
output_tokens: output_tokens? as u32, output_tokens: output_tokens? as u32,
cache_read_tokens: cached_tokens, cache_read_tokens: usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: usage cache_creation_tokens: usage
.get("cache_creation_input_tokens") .get("cache_creation_input_tokens")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32, .unwrap_or(0) as u32,
model, model: None,
}) })
} }
@@ -196,27 +166,16 @@ impl TokenUsage {
let input_tokens = usage.get("input_tokens")?.as_u64()? as u32; let input_tokens = usage.get("input_tokens")?.as_u64()? as u32;
let output_tokens = usage.get("output_tokens")?.as_u64()? as u32; let output_tokens = usage.get("output_tokens")?.as_u64()? as u32;
// 获取 cached_tokens (可能在 cache_read_input_tokens 或 input_tokens_details 中) // 获取 cached_tokens (可能在 input_tokens_details 中)
let cached_tokens = usage let cached_tokens = usage
.get("cache_read_input_tokens") .get("input_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.or_else(|| {
usage
.get("input_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0) as u32; .unwrap_or(0) as u32;
// 调整 input_tokens: 减去 cached_tokens // 调整 input_tokens: 减去 cached_tokens
let adjusted_input = input_tokens.saturating_sub(cached_tokens); let adjusted_input = input_tokens.saturating_sub(cached_tokens);
// 提取响应中的模型名称
let model = body
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self { Some(Self {
input_tokens: adjusted_input, input_tokens: adjusted_input,
output_tokens, output_tokens,
@@ -225,7 +184,7 @@ impl TokenUsage {
.get("cache_creation_input_tokens") .get("cache_creation_input_tokens")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32, .unwrap_or(0) as u32,
model, model: None,
}) })
} }
@@ -239,7 +198,7 @@ impl TokenUsage {
if event_type == "response.completed" { if event_type == "response.completed" {
if let Some(response) = event.get("response") { if let Some(response) = event.get("response") {
log::debug!("[Codex] 找到 response.completed 事件,解析 usage"); log::debug!("[Codex] 找到 response.completed 事件,解析 usage");
return Self::from_codex_response_adjusted(response); return Self::from_codex_response(response);
} }
} }
} }
@@ -248,51 +207,6 @@ impl TokenUsage {
None None
} }
/// 智能 Codex 响应解析 - 自动检测 OpenAI 或 Codex 格式
///
/// Codex 支持两种 API 格式:
/// - `/v1/responses`: 使用 input_tokens/output_tokens
/// - `/v1/chat/completions`: 使用 prompt_tokens/completion_tokens (OpenAI 格式)
///
/// 注意:记录原始 input_tokens,费用计算时再减去 cached_tokens
pub fn from_codex_response_auto(body: &Value) -> Option<Self> {
let usage = body.get("usage")?;
// 检测格式:OpenAI 使用 prompt_tokensCodex 使用 input_tokens
if usage.get("prompt_tokens").is_some() {
log::debug!("[Codex] 检测到 OpenAI 格式 (prompt_tokens)");
Self::from_openai_response(body)
} else if usage.get("input_tokens").is_some() {
log::debug!("[Codex] 检测到 Codex 格式 (input_tokens)");
// 使用非调整版本,记录原始 input_tokens
Self::from_codex_response(body)
} else {
log::debug!("[Codex] 无法识别响应格式,usage: {usage:?}");
None
}
}
/// 智能 Codex 流式响应解析 - 自动检测 OpenAI 或 Codex 格式
pub fn from_codex_stream_events_auto(events: &[Value]) -> Option<Self> {
log::debug!("[Codex] 智能解析流式事件,共 {} 个事件", events.len());
// 先尝试 Codex Responses API 格式 (response.completed 事件)
for event in events {
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
if event_type == "response.completed" {
if let Some(response) = event.get("response") {
log::debug!("[Codex] 找到 response.completed 事件");
return Self::from_codex_response_auto(response);
}
}
}
}
// 回退到 OpenAI Chat Completions 格式 (最后一个 chunk 包含 usage)
log::debug!("[Codex] 尝试 OpenAI 流式格式");
Self::from_openai_stream_events(events)
}
/// 从 OpenAI Chat Completions API 响应解析 (prompt_tokens, completion_tokens) /// 从 OpenAI Chat Completions API 响应解析 (prompt_tokens, completion_tokens)
pub fn from_openai_response(body: &Value) -> Option<Self> { pub fn from_openai_response(body: &Value) -> Option<Self> {
let usage = body.get("usage")?; let usage = body.get("usage")?;
@@ -308,18 +222,12 @@ impl TokenUsage {
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32; .unwrap_or(0) as u32;
// 提取响应中的模型名称
let model = body
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self { Some(Self {
input_tokens: prompt_tokens as u32, input_tokens: prompt_tokens as u32,
output_tokens: completion_tokens as u32, output_tokens: completion_tokens as u32,
cache_read_tokens: cached_tokens, cache_read_tokens: cached_tokens,
cache_creation_tokens: 0, cache_creation_tokens: 0,
model, model: None,
}) })
} }
@@ -348,16 +256,9 @@ impl TokenUsage {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()); .map(|s| s.to_string());
let prompt_tokens = usage.get("promptTokenCount")?.as_u64()? as u32;
let total_tokens = usage.get("totalTokenCount")?.as_u64()? as u32;
// 输出 tokens = 总 tokens - 输入 tokens
// 这包含了 candidatesTokenCount + thoughtsTokenCount
let output_tokens = total_tokens.saturating_sub(prompt_tokens);
Some(Self { Some(Self {
input_tokens: prompt_tokens, input_tokens: usage.get("promptTokenCount")?.as_u64()? as u32,
output_tokens, output_tokens: usage.get("candidatesTokenCount")?.as_u64()? as u32,
cache_read_tokens: usage cache_read_tokens: usage
.get("cachedContentTokenCount") .get("cachedContentTokenCount")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
@@ -371,25 +272,20 @@ impl TokenUsage {
#[allow(dead_code)] #[allow(dead_code)]
pub fn from_gemini_stream_chunks(chunks: &[Value]) -> Option<Self> { pub fn from_gemini_stream_chunks(chunks: &[Value]) -> Option<Self> {
let mut total_input = 0u32; let mut total_input = 0u32;
let mut total_tokens = 0u32; let mut total_output = 0u32;
let mut total_cache_read = 0u32; let mut total_cache_read = 0u32;
let mut model: Option<String> = None; let mut model: Option<String> = None;
for chunk in chunks { for chunk in chunks {
if let Some(usage) = chunk.get("usageMetadata") { if let Some(usage) = chunk.get("usageMetadata") {
// 输入 tokens (通常在所有 chunk 中保持不变)
total_input = usage total_input = usage
.get("promptTokenCount") .get("promptTokenCount")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32; .unwrap_or(0) as u32;
total_output += usage
// 总 tokens (包含输入 + 输出 + 思考) .get("candidatesTokenCount")
total_tokens = usage
.get("totalTokenCount")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32; .unwrap_or(0) as u32;
// 缓存读取 tokens
total_cache_read = usage total_cache_read = usage
.get("cachedContentTokenCount") .get("cachedContentTokenCount")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
@@ -404,9 +300,6 @@ impl TokenUsage {
} }
} }
// 输出 tokens = 总 tokens - 输入 tokens
let total_output = total_tokens.saturating_sub(total_input);
if total_input > 0 || total_output > 0 { if total_input > 0 || total_output > 0 {
Some(Self { Some(Self {
input_tokens: total_input, input_tokens: total_input,
@@ -429,7 +322,6 @@ mod tests {
#[test] #[test]
fn test_claude_response_parsing() { fn test_claude_response_parsing() {
let response = json!({ let response = json!({
"model": "claude-sonnet-4-20250514",
"usage": { "usage": {
"input_tokens": 100, "input_tokens": 100,
"output_tokens": 50, "output_tokens": 50,
@@ -443,26 +335,6 @@ mod tests {
assert_eq!(usage.output_tokens, 50); assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20); assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 10); assert_eq!(usage.cache_creation_tokens, 10);
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
}
#[test]
fn test_claude_response_parsing_no_model() {
let response = json!({
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"cache_read_input_tokens": 20,
"cache_creation_input_tokens": 10
}
});
let usage = TokenUsage::from_claude_response(&response).unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 10);
assert_eq!(usage.model, None);
} }
#[test] #[test]
@@ -471,7 +343,6 @@ mod tests {
json!({ json!({
"type": "message_start", "type": "message_start",
"message": { "message": {
"model": "claude-sonnet-4-20250514",
"usage": { "usage": {
"input_tokens": 100, "input_tokens": 100,
"cache_read_input_tokens": 20, "cache_read_input_tokens": 20,
@@ -492,36 +363,6 @@ mod tests {
assert_eq!(usage.output_tokens, 50); assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20); assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 10); assert_eq!(usage.cache_creation_tokens, 10);
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
}
#[test]
fn test_claude_stream_parsing_no_model() {
let events = vec![
json!({
"type": "message_start",
"message": {
"usage": {
"input_tokens": 100,
"cache_read_input_tokens": 20,
"cache_creation_input_tokens": 10
}
}
}),
json!({
"type": "message_delta",
"usage": {
"output_tokens": 50
}
}),
];
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 10);
assert_eq!(usage.model, None);
} }
#[test] #[test]
@@ -545,18 +386,15 @@ mod tests {
let response = json!({ let response = json!({
"modelVersion": "gemini-3-pro-high", "modelVersion": "gemini-3-pro-high",
"usageMetadata": { "usageMetadata": {
"promptTokenCount": 8383, "promptTokenCount": 100,
"candidatesTokenCount": 50, "candidatesTokenCount": 50,
"thoughtsTokenCount": 114,
"totalTokenCount": 8547,
"cachedContentTokenCount": 20 "cachedContentTokenCount": 20
} }
}); });
let usage = TokenUsage::from_gemini_response(&response).unwrap(); let usage = TokenUsage::from_gemini_response(&response).unwrap();
assert_eq!(usage.input_tokens, 8383); assert_eq!(usage.input_tokens, 100);
// output_tokens = totalTokenCount - promptTokenCount = 8547 - 8383 = 164 assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.output_tokens, 164);
assert_eq!(usage.cache_read_tokens, 20); assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 0); assert_eq!(usage.cache_creation_tokens, 0);
assert_eq!(usage.model, Some("gemini-3-pro-high".to_string())); assert_eq!(usage.model, Some("gemini-3-pro-high".to_string()));
@@ -568,78 +406,19 @@ mod tests {
let response = json!({ let response = json!({
"usageMetadata": { "usageMetadata": {
"promptTokenCount": 100, "promptTokenCount": 100,
"totalTokenCount": 150, "candidatesTokenCount": 50,
"cachedContentTokenCount": 20 "cachedContentTokenCount": 20
} }
}); });
let usage = TokenUsage::from_gemini_response(&response).unwrap(); let usage = TokenUsage::from_gemini_response(&response).unwrap();
assert_eq!(usage.input_tokens, 100); assert_eq!(usage.input_tokens, 100);
// output_tokens = totalTokenCount - promptTokenCount = 150 - 100 = 50
assert_eq!(usage.output_tokens, 50); assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20); assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 0); assert_eq!(usage.cache_creation_tokens, 0);
assert_eq!(usage.model, None); assert_eq!(usage.model, None);
} }
#[test]
fn test_gemini_response_with_thoughts() {
// 测试包含 thoughtsTokenCount 的实际响应
// 这是用户报告的真实场景
let response = json!({
"candidates": [
{
"content": {
"parts": [
{
"text": "",
"thoughtSignature": "EvcECvQE..."
}
],
"role": "model"
},
"finishReason": "STOP"
}
],
"modelVersion": "gemini-3-pro-high",
"responseId": "yupTafqLDu-PjMcPhrOx4QQ",
"usageMetadata": {
"candidatesTokenCount": 50,
"promptTokenCount": 8383,
"thoughtsTokenCount": 114,
"totalTokenCount": 8547
}
});
let usage = TokenUsage::from_gemini_response(&response).unwrap();
assert_eq!(usage.input_tokens, 8383);
// output_tokens = totalTokenCount - promptTokenCount
// = 8547 - 8383 = 164 (包含 candidatesTokenCount 50 + thoughtsTokenCount 114)
assert_eq!(usage.output_tokens, 164);
assert_eq!(usage.cache_read_tokens, 0);
assert_eq!(usage.cache_creation_tokens, 0);
assert_eq!(usage.model, Some("gemini-3-pro-high".to_string()));
}
#[test]
fn test_codex_response_parsing_cached_tokens_in_details() {
let response = json!({
"usage": {
"input_tokens": 1000,
"output_tokens": 500,
"input_tokens_details": {
"cached_tokens": 300
}
}
});
let usage = TokenUsage::from_codex_response(&response).unwrap();
// 非调整模式:input_tokens 保持原值,但应记录缓存命中
assert_eq!(usage.input_tokens, 1000);
assert_eq!(usage.output_tokens, 500);
assert_eq!(usage.cache_read_tokens, 300);
}
#[test] #[test]
fn test_codex_response_adjusted() { fn test_codex_response_adjusted() {
let response = json!({ let response = json!({
@@ -675,22 +454,6 @@ mod tests {
assert_eq!(usage.cache_read_tokens, 0); assert_eq!(usage.cache_read_tokens, 0);
} }
#[test]
fn test_codex_response_adjusted_cache_read_input_tokens() {
let response = json!({
"usage": {
"input_tokens": 1000,
"output_tokens": 500,
"cache_read_input_tokens": 200
}
});
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
assert_eq!(usage.input_tokens, 800);
assert_eq!(usage.output_tokens, 500);
assert_eq!(usage.cache_read_tokens, 200);
}
#[test] #[test]
fn test_codex_response_adjusted_saturating_sub() { fn test_codex_response_adjusted_saturating_sub() {
// 测试 cached_tokens > input_tokens 的边界情况 // 测试 cached_tokens > input_tokens 的边界情况
@@ -718,7 +481,6 @@ mod tests {
json!({ json!({
"type": "message_start", "type": "message_start",
"message": { "message": {
"model": "claude-sonnet-4-20250514",
"usage": { "usage": {
"input_tokens": 0, "input_tokens": 0,
"output_tokens": 0 "output_tokens": 0
@@ -740,7 +502,6 @@ mod tests {
let usage = TokenUsage::from_claude_stream_events(&events).unwrap(); let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
assert_eq!(usage.input_tokens, 150); assert_eq!(usage.input_tokens, 150);
assert_eq!(usage.output_tokens, 75); assert_eq!(usage.output_tokens, 75);
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
} }
#[test] #[test]
@@ -751,7 +512,6 @@ mod tests {
json!({ json!({
"type": "message_start", "type": "message_start",
"message": { "message": {
"model": "claude-sonnet-4-20250514",
"usage": { "usage": {
"input_tokens": 200, "input_tokens": 200,
"cache_read_input_tokens": 50 "cache_read_input_tokens": 50
@@ -770,112 +530,5 @@ mod tests {
assert_eq!(usage.input_tokens, 200); assert_eq!(usage.input_tokens, 200);
assert_eq!(usage.output_tokens, 100); assert_eq!(usage.output_tokens, 100);
assert_eq!(usage.cache_read_tokens, 50); assert_eq!(usage.cache_read_tokens, 50);
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
}
// ============================================================================
// 智能 Codex 解析测试
// ============================================================================
#[test]
fn test_codex_response_auto_openai_format() {
// OpenAI 格式 (prompt_tokens/completion_tokens)
let response = json!({
"model": "gpt-4o",
"usage": {
"prompt_tokens": 1000,
"completion_tokens": 500,
"prompt_tokens_details": {
"cached_tokens": 200
}
}
});
let usage = TokenUsage::from_codex_response_auto(&response).unwrap();
assert_eq!(usage.input_tokens, 1000);
assert_eq!(usage.output_tokens, 500);
assert_eq!(usage.cache_read_tokens, 200);
assert_eq!(usage.model, Some("gpt-4o".to_string()));
}
#[test]
fn test_codex_response_auto_codex_format() {
// Codex 格式 (input_tokens/output_tokens)
let response = json!({
"model": "o3",
"usage": {
"input_tokens": 1000,
"output_tokens": 500,
"input_tokens_details": {
"cached_tokens": 300
}
}
});
let usage = TokenUsage::from_codex_response_auto(&response).unwrap();
// 记录原始 input_tokens,不调整
assert_eq!(usage.input_tokens, 1000);
assert_eq!(usage.output_tokens, 500);
assert_eq!(usage.cache_read_tokens, 300);
assert_eq!(usage.model, Some("o3".to_string()));
}
#[test]
fn test_codex_stream_events_auto_codex_format() {
// Codex Responses API 流式格式 (response.completed 事件)
let events = vec![
json!({
"type": "response.created",
"response": {
"id": "resp_123"
}
}),
json!({
"type": "response.completed",
"response": {
"model": "o3",
"usage": {
"input_tokens": 1000,
"output_tokens": 500,
"input_tokens_details": {
"cached_tokens": 200
}
}
}
}),
];
let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap();
// 记录原始 input_tokens,不调整
assert_eq!(usage.input_tokens, 1000);
assert_eq!(usage.output_tokens, 500);
assert_eq!(usage.cache_read_tokens, 200);
assert_eq!(usage.model, Some("o3".to_string()));
}
#[test]
fn test_codex_stream_events_auto_openai_format() {
// OpenAI Chat Completions 流式格式 (最后一个 chunk 包含 usage)
let events = vec![
json!({
"id": "chatcmpl-123",
"model": "gpt-4o",
"choices": [{"delta": {"content": "Hello"}}]
}),
json!({
"id": "chatcmpl-123",
"model": "gpt-4o",
"choices": [{"delta": {}}],
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50
}
}),
];
let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.model, Some("gpt-4o".to_string()));
} }
} }
+1 -3
View File
@@ -146,9 +146,7 @@ impl ConfigService {
let cfg_text = settings.get("config").and_then(Value::as_str); let cfg_text = settings.get("config").and_then(Value::as_str);
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?; crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用 crate::mcp::sync_enabled_to_codex(config)?;
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?; let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
if let Some(manager) = config.get_manager_mut(&AppType::Codex) { if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
+9 -64
View File
@@ -17,27 +17,8 @@ impl McpService {
/// 添加或更新 MCP 服务器 /// 添加或更新 MCP 服务器
pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> { pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> {
// 读取旧状态:用于处理“编辑时取消勾选某个应用”的场景(需要从对应 live 配置中移除)
let prev_apps = state
.db
.get_all_mcp_servers()?
.get(&server.id)
.map(|s| s.apps.clone())
.unwrap_or_default();
state.db.save_mcp_server(&server)?; state.db.save_mcp_server(&server)?;
// 处理禁用:若旧版本启用但新版本取消,则需要从该应用的 live 配置移除
if prev_apps.claude && !server.apps.claude {
Self::remove_server_from_app(state, &server.id, &AppType::Claude)?;
}
if prev_apps.codex && !server.apps.codex {
Self::remove_server_from_app(state, &server.id, &AppType::Codex)?;
}
if prev_apps.gemini && !server.apps.gemini {
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
}
// 同步到各个启用的应用 // 同步到各个启用的应用
Self::sync_server_to_apps(state, &server)?; Self::sync_server_to_apps(state, &server)?;
@@ -209,22 +190,10 @@ impl McpService {
// 如果有导入的服务器,保存到数据库 // 如果有导入的服务器,保存到数据库
if count > 0 { if count > 0 {
if let Some(servers) = &temp_config.mcp.servers { if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() { for server in servers.values() {
// 已存在:仅启用 Claude,不覆盖其他字段(与导入模块语义保持一致) state.db.save_mcp_server(server)?;
let to_save = if let Some(existing_server) = existing.get(&server.id) { // 同步到 Claude live 配置
let mut merged = existing_server.clone(); Self::sync_server_to_apps(state, server)?;
merged.apps.claude = true;
merged
} else {
server.clone()
};
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
} }
} }
} }
@@ -243,22 +212,10 @@ impl McpService {
// 如果有导入的服务器,保存到数据库 // 如果有导入的服务器,保存到数据库
if count > 0 { if count > 0 {
if let Some(servers) = &temp_config.mcp.servers { if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() { for server in servers.values() {
// 已存在:仅启用 Codex,不覆盖其他字段(与导入模块语义保持一致) state.db.save_mcp_server(server)?;
let to_save = if let Some(existing_server) = existing.get(&server.id) { // 同步到 Codex live 配置
let mut merged = existing_server.clone(); Self::sync_server_to_apps(state, server)?;
merged.apps.codex = true;
merged
} else {
server.clone()
};
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
} }
} }
} }
@@ -277,22 +234,10 @@ impl McpService {
// 如果有导入的服务器,保存到数据库 // 如果有导入的服务器,保存到数据库
if count > 0 { if count > 0 {
if let Some(servers) = &temp_config.mcp.servers { if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() { for server in servers.values() {
// 已存在:仅启用 Gemini,不覆盖其他字段(与导入模块语义保持一致) state.db.save_mcp_server(server)?;
let to_save = if let Some(existing_server) = existing.get(&server.id) { // 同步到 Gemini live 配置
let mut merged = existing_server.clone(); Self::sync_server_to_apps(state, server)?;
merged.apps.gemini = true;
merged
} else {
server.clone()
};
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
} }
} }
} }
+3 -1
View File
@@ -2,16 +2,18 @@ pub mod config;
pub mod env_checker; pub mod env_checker;
pub mod env_manager; pub mod env_manager;
pub mod mcp; pub mod mcp;
pub mod model_test;
pub mod prompt; pub mod prompt;
pub mod provider; pub mod provider;
pub mod proxy; pub mod proxy;
pub mod skill; pub mod skill;
pub mod speedtest; pub mod speedtest;
pub mod stream_check;
pub mod usage_stats; pub mod usage_stats;
pub use config::ConfigService; pub use config::ConfigService;
pub use mcp::McpService; pub use mcp::McpService;
#[allow(unused_imports)]
pub use model_test::{ModelTestConfig, ModelTestLog, ModelTestResult, ModelTestService};
pub use prompt::PromptService; pub use prompt::PromptService;
pub use provider::{ProviderService, ProviderSortUpdate}; pub use provider::{ProviderService, ProviderSortUpdate};
pub use proxy::ProxyService; pub use proxy::ProxyService;
+510
View File
@@ -0,0 +1,510 @@
//! 模型测试服务
//!
//! 提供独立的模型可用性测试功能,复用现有 Provider 适配器逻辑,
//! 但不影响正常代理数据流程。测试结果记录到独立的日志表。
use crate::app_config::AppType;
use crate::database::Database;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::providers::{get_adapter, AuthInfo, ProviderAdapter};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::time::{Duration, Instant};
/// 模型测试配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelTestConfig {
/// 默认测试模型(Claude
pub claude_model: String,
/// 默认测试模型(Codex/OpenAI
pub codex_model: String,
/// 默认测试模型(Gemini
pub gemini_model: String,
/// 测试提示词
pub test_prompt: String,
/// 超时时间(秒)
pub timeout_secs: u64,
}
impl Default for ModelTestConfig {
fn default() -> Self {
Self {
claude_model: "claude-haiku-4-5-20251001".to_string(),
codex_model: "gpt-5.1-low".to_string(),
gemini_model: "gemini-3-pro-low".to_string(),
test_prompt: "ping".to_string(),
timeout_secs: 15,
}
}
}
/// 模型测试结果
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelTestResult {
pub success: bool,
pub message: String,
pub response_time_ms: Option<u64>,
pub http_status: Option<u16>,
pub model_used: String,
pub tested_at: i64,
}
/// 模型测试日志记录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelTestLog {
pub id: i64,
pub provider_id: String,
pub provider_name: String,
pub app_type: String,
pub model: String,
pub prompt: String,
pub success: bool,
pub message: String,
pub response_time_ms: Option<i64>,
pub http_status: Option<i64>,
pub tested_at: i64,
}
/// 模型测试服务
pub struct ModelTestService;
impl ModelTestService {
/// 测试单个供应商的模型可用性
pub async fn test_provider(
app_type: &AppType,
provider: &Provider,
config: &ModelTestConfig,
) -> Result<ModelTestResult, AppError> {
let start = Instant::now();
let adapter = get_adapter(app_type);
// 构建 HTTP 客户端(独立于代理服务)
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.map_err(|e| AppError::Message(format!("创建 HTTP 客户端失败: {e}")))?;
// 根据 AppType 选择测试模型
let model = match app_type {
AppType::Claude => &config.claude_model,
AppType::Codex => &config.codex_model,
AppType::Gemini => &config.gemini_model,
};
let result = match app_type {
AppType::Claude => {
Self::test_claude(
&client,
provider,
adapter.as_ref(),
model,
&config.test_prompt,
)
.await
}
AppType::Codex => {
Self::test_codex(
&client,
provider,
adapter.as_ref(),
model,
&config.test_prompt,
)
.await
}
AppType::Gemini => {
Self::test_gemini(
&client,
provider,
adapter.as_ref(),
model,
&config.test_prompt,
)
.await
}
};
let response_time = start.elapsed().as_millis() as u64;
let tested_at = chrono::Utc::now().timestamp();
match result {
Ok((status, msg)) => Ok(ModelTestResult {
success: true,
message: msg,
response_time_ms: Some(response_time),
http_status: Some(status),
model_used: model.clone(),
tested_at,
}),
Err(e) => Ok(ModelTestResult {
success: false,
message: e.to_string(),
response_time_ms: Some(response_time),
http_status: None,
model_used: model.clone(),
tested_at,
}),
}
}
/// 测试 Claude (Anthropic Messages API)
async fn test_claude(
client: &Client,
provider: &Provider,
adapter: &dyn ProviderAdapter,
model: &str,
prompt: &str,
) -> Result<(u16, String), AppError> {
let base_url = adapter
.extract_base_url(provider)
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
let auth = adapter
.extract_auth(provider)
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
// 智能拼接 URL,避免重复 /v1
let base = base_url.trim_end_matches('/');
let url = if base.ends_with("/v1") {
format!("{base}/messages")
} else {
format!("{base}/v1/messages")
};
let body = json!({
"model": model,
"max_tokens": 1,
"messages": [{
"role": "user",
"content": prompt
}]
});
let mut request = client.post(&url).json(&body);
request = Self::add_claude_auth(request, &auth);
let response = request.send().await.map_err(|e| {
if e.is_timeout() {
AppError::Message("请求超时".to_string())
} else if e.is_connect() {
AppError::Message(format!("连接失败: {e}"))
} else {
AppError::Message(e.to_string())
}
})?;
let status = response.status().as_u16();
if response.status().is_success() {
// 先获取文本,再尝试解析 JSON(兼容流式响应)
let text = response.text().await.unwrap_or_default();
// 尝试解析 JSON
if let Ok(data) = serde_json::from_str::<Value>(&text) {
if data.get("type").is_some()
|| data.get("content").is_some()
|| data.get("id").is_some()
{
return Ok((status, "模型测试成功".to_string()));
}
}
// 即使无法解析 JSON,只要状态码是 200 就认为成功
Ok((status, "模型测试成功".to_string()))
} else {
let error_text = response.text().await.unwrap_or_default();
Err(AppError::Message(format!("HTTP {status}: {error_text}")))
}
}
/// 测试 Codex (OpenAI Chat Completions API)
async fn test_codex(
client: &Client,
provider: &Provider,
adapter: &dyn ProviderAdapter,
model: &str,
prompt: &str,
) -> Result<(u16, String), AppError> {
let base_url = adapter
.extract_base_url(provider)
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
let auth = adapter
.extract_auth(provider)
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
// 智能拼接 URL,避免重复 /v1
let base = base_url.trim_end_matches('/');
let url = if base.ends_with("/v1") {
format!("{base}/chat/completions")
} else {
format!("{base}/v1/chat/completions")
};
let body = json!({
"model": model,
"messages": [{
"role": "user",
"content": prompt
}],
"max_tokens": 1,
"stream": false
});
let request = client
.post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json")
.json(&body);
let response = request.send().await.map_err(|e| {
if e.is_timeout() {
AppError::Message("请求超时".to_string())
} else if e.is_connect() {
AppError::Message(format!("连接失败: {e}"))
} else {
AppError::Message(e.to_string())
}
})?;
let status = response.status().as_u16();
if response.status().is_success() {
// 先获取文本,再尝试解析 JSON
let text = response.text().await.unwrap_or_default();
if let Ok(data) = serde_json::from_str::<Value>(&text) {
if data.get("choices").is_some() || data.get("id").is_some() {
return Ok((status, "模型测试成功".to_string()));
}
}
// 即使无法解析 JSON,只要状态码是 200 就认为成功
Ok((status, "模型测试成功".to_string()))
} else {
let error_text = response.text().await.unwrap_or_default();
Err(AppError::Message(format!("HTTP {status}: {error_text}")))
}
}
/// 测试 Gemini (Google Generative AI API)
async fn test_gemini(
client: &Client,
provider: &Provider,
adapter: &dyn ProviderAdapter,
model: &str,
prompt: &str,
) -> Result<(u16, String), AppError> {
let base_url = adapter
.extract_base_url(provider)
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
let auth = adapter
.extract_auth(provider)
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
let url = format!(
"{}/v1beta/models/{}:generateContent?key={}",
base_url.trim_end_matches('/'),
model,
auth.api_key
);
let body = json!({
"contents": [{
"parts": [{
"text": prompt
}]
}],
"generationConfig": {
"maxOutputTokens": 1
}
});
let request = client
.post(&url)
.header("Content-Type", "application/json")
.json(&body);
let response = request.send().await.map_err(|e| {
if e.is_timeout() {
AppError::Message("请求超时".to_string())
} else if e.is_connect() {
AppError::Message(format!("连接失败: {e}"))
} else {
AppError::Message(e.to_string())
}
})?;
let status = response.status().as_u16();
if response.status().is_success() {
let data: Value = response
.json()
.await
.map_err(|e| AppError::Message(format!("解析响应失败: {e}")))?;
if data.get("candidates").is_some() {
Ok((status, "模型测试成功".to_string()))
} else {
Err(AppError::Message("响应格式异常".to_string()))
}
} else {
let error_text = response.text().await.unwrap_or_default();
Err(AppError::Message(format!("HTTP {status}: {error_text}")))
}
}
/// 添加 Claude 认证头
fn add_claude_auth(
request: reqwest::RequestBuilder,
auth: &AuthInfo,
) -> reqwest::RequestBuilder {
request
.header("x-api-key", &auth.api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
}
}
// ===== 数据库操作 =====
impl Database {
/// 保存模型测试日志
pub fn save_model_test_log(
&self,
provider_id: &str,
provider_name: &str,
app_type: &str,
model: &str,
prompt: &str,
result: &ModelTestResult,
) -> Result<i64, AppError> {
let conn = self
.conn
.lock()
.map_err(|e| AppError::Database(format!("获取数据库连接失败: {e}")))?;
conn.execute(
"INSERT INTO model_test_logs
(provider_id, provider_name, app_type, model, prompt, success, message, response_time_ms, http_status, tested_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
provider_id,
provider_name,
app_type,
model,
prompt,
result.success,
result.message,
result.response_time_ms.map(|t| t as i64),
result.http_status.map(|s| s as i64),
result.tested_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(conn.last_insert_rowid())
}
/// 获取模型测试日志
pub fn get_model_test_logs(
&self,
app_type: Option<&str>,
provider_id: Option<&str>,
limit: u32,
) -> Result<Vec<ModelTestLog>, AppError> {
let conn = self
.conn
.lock()
.map_err(|e| AppError::Database(format!("获取数据库连接失败: {e}")))?;
let mut sql = String::from(
"SELECT id, provider_id, provider_name, app_type, model, prompt, success, message, response_time_ms, http_status, tested_at
FROM model_test_logs WHERE 1=1"
);
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(at) = app_type {
sql.push_str(" AND app_type = ?");
params.push(Box::new(at.to_string()));
}
if let Some(pid) = provider_id {
sql.push_str(" AND provider_id = ?");
params.push(Box::new(pid.to_string()));
}
sql.push_str(" ORDER BY tested_at DESC LIMIT ?");
params.push(Box::new(limit as i64));
let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn
.prepare(&sql)
.map_err(|e| AppError::Database(e.to_string()))?;
let logs = stmt
.query_map(params_refs.as_slice(), |row| {
Ok(ModelTestLog {
id: row.get(0)?,
provider_id: row.get(1)?,
provider_name: row.get(2)?,
app_type: row.get(3)?,
model: row.get(4)?,
prompt: row.get(5)?,
success: row.get(6)?,
message: row.get(7)?,
response_time_ms: row.get(8)?,
http_status: row.get(9)?,
tested_at: row.get(10)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(logs)
}
/// 获取模型测试配置
pub fn get_model_test_config(&self) -> Result<ModelTestConfig, AppError> {
match self.get_setting("model_test_config")? {
Some(json) => serde_json::from_str(&json)
.map_err(|e| AppError::Message(format!("解析模型测试配置失败: {e}"))),
None => Ok(ModelTestConfig::default()),
}
}
/// 保存模型测试配置
pub fn save_model_test_config(&self, config: &ModelTestConfig) -> Result<(), AppError> {
let json = serde_json::to_string(config)
.map_err(|e| AppError::Message(format!("序列化模型测试配置失败: {e}")))?;
self.set_setting("model_test_config", &json)
}
/// 清理旧的测试日志(保留最近 N 条)
pub fn cleanup_model_test_logs(&self, keep_count: u32) -> Result<u64, AppError> {
let conn = self
.conn
.lock()
.map_err(|e| AppError::Database(format!("获取数据库连接失败: {e}")))?;
let deleted = conn
.execute(
"DELETE FROM model_test_logs WHERE id NOT IN (
SELECT id FROM model_test_logs ORDER BY tested_at DESC LIMIT ?
)",
rusqlite::params![keep_count as i64],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(deleted as u64)
}
}
+20 -231
View File
@@ -144,29 +144,9 @@ impl ProviderService {
state.db.save_provider(app_type.as_str(), &provider)?; state.db.save_provider(app_type.as_str(), &provider)?;
if is_current { if is_current {
// 如果代理接管模式处于激活状态,并且代理服务正在运行: write_live_snapshot(&app_type, &provider)?;
// - 不写 Live 配置(否则会破坏接管) // Sync MCP
// - 仅更新 Live 备份(保证关闭代理时能恢复到最新配置) McpService::sync_all_enabled(state)?;
let is_app_taken_over =
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
.flatten()
.is_some();
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
let should_skip_live_write = is_app_taken_over && is_proxy_running;
if should_skip_live_write {
futures::executor::block_on(
state
.proxy_service
.update_live_backup_from_provider(app_type.as_str(), &provider),
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
} else {
write_live_snapshot(&app_type, &provider)?;
// Sync MCP
McpService::sync_all_enabled(state)?;
}
} }
Ok(true) Ok(true)
@@ -193,80 +173,14 @@ impl ProviderService {
/// ///
/// Switch flow: /// Switch flow:
/// 1. Validate target provider exists /// 1. Validate target provider exists
/// 2. Check if proxy takeover mode is active AND proxy server is running /// 2. **Backfill mechanism**: Backfill current live config to current provider, protect user manual modifications
/// 3. If takeover mode active: hot-switch proxy target only (no Live config write) /// 3. Update local settings current_provider_xxx (device-level)
/// 4. If normal mode: /// 4. Update database is_current (as default for new devices)
/// a. **Backfill mechanism**: Backfill current live config to current provider /// 5. Write target provider config to live files
/// b. Update local settings current_provider_xxx (device-level) /// 6. Sync MCP configuration
/// c. Update database is_current (as default for new devices)
/// d. Write target provider config to live files
/// e. Sync MCP configuration
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Check if provider exists // Check if provider exists
let providers = state.db.get_all_providers(app_type.as_str())?; let providers = state.db.get_all_providers(app_type.as_str())?;
let _provider = providers
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// Check if proxy takeover mode is active AND proxy server is actually running
// Both conditions must be true to use hot-switch mode
// Use blocking wait since this is a sync function
let is_app_taken_over =
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
.flatten()
.is_some();
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
let live_taken_over = state
.proxy_service
.detect_takeover_in_live_config_for_app(&app_type);
// 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;
if should_hot_switch {
// Proxy takeover mode: hot-switch only, don't write Live config
log::info!(
"代理接管模式:热切换 {} 的目标供应商为 {}",
app_type.as_str(),
id
);
// 获取新供应商的完整配置(用于更新备份)
let provider = providers
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// Update database is_current
state.db.set_current_provider(app_type.as_str(), id)?;
// Update local settings for consistency
crate::settings::set_current_provider(&app_type, Some(id))?;
// 更新 Live 备份(确保代理关闭时恢复正确的供应商配置)
futures::executor::block_on(
state
.proxy_service
.update_live_backup_from_provider(app_type.as_str(), provider),
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
// Note: No Live config write, no MCP sync
// The proxy server will route requests to the new provider via is_current
return Ok(());
}
// Normal mode: full switch with Live config write
Self::switch_normal(state, app_type, id, &providers)
}
/// Normal switch flow (non-proxy mode)
fn switch_normal(
state: &AppState,
app_type: AppType,
id: &str,
providers: &indexmap::IndexMap<String, Provider>,
) -> Result<(), AppError> {
let provider = providers let provider = providers
.get(id) .get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
@@ -303,6 +217,18 @@ impl ProviderService {
Ok(()) Ok(())
} }
/// Set proxy target provider
pub fn set_proxy_target(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Check if provider exists
let providers = state.db.get_all_providers(app_type.as_str())?;
if !providers.contains_key(id) {
return Err(AppError::Message(format!("供应商 {id} 不存在")));
}
state.db.set_proxy_target_provider(app_type.as_str(), id)?;
Ok(())
}
/// Sync current provider to live configuration (re-export) /// Sync current provider to live configuration (re-export)
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
sync_current_to_live(state) sync_current_to_live(state)
@@ -693,140 +619,3 @@ pub struct ProviderSortUpdate {
#[serde(rename = "sortIndex")] #[serde(rename = "sortIndex")]
pub sort_index: usize, pub sort_index: usize,
} }
// ============================================================================
// 统一供应商(Universal Provider)服务方法
// ============================================================================
use crate::provider::UniversalProvider;
use std::collections::HashMap;
impl ProviderService {
/// 获取所有统一供应商
pub fn list_universal(
state: &AppState,
) -> Result<HashMap<String, UniversalProvider>, AppError> {
state.db.get_all_universal_providers()
}
/// 获取单个统一供应商
pub fn get_universal(
state: &AppState,
id: &str,
) -> Result<Option<UniversalProvider>, AppError> {
state.db.get_universal_provider(id)
}
/// 添加或更新统一供应商(不自动同步,需手动调用 sync_universal_to_apps
pub fn upsert_universal(
state: &AppState,
provider: UniversalProvider,
) -> Result<bool, AppError> {
// 保存统一供应商
state.db.save_universal_provider(&provider)?;
Ok(true)
}
/// 删除统一供应商
pub fn delete_universal(state: &AppState, id: &str) -> Result<bool, AppError> {
// 获取统一供应商(用于删除生成的子供应商)
let provider = state.db.get_universal_provider(id)?;
// 删除统一供应商
state.db.delete_universal_provider(id)?;
// 删除生成的子供应商
if let Some(p) = provider {
if p.apps.claude {
let claude_id = format!("universal-claude-{id}");
let _ = state.db.delete_provider("claude", &claude_id);
}
if p.apps.codex {
let codex_id = format!("universal-codex-{id}");
let _ = state.db.delete_provider("codex", &codex_id);
}
if p.apps.gemini {
let gemini_id = format!("universal-gemini-{id}");
let _ = state.db.delete_provider("gemini", &gemini_id);
}
}
Ok(true)
}
/// 同步统一供应商到各应用
pub fn sync_universal_to_apps(state: &AppState, id: &str) -> Result<bool, AppError> {
let provider = state
.db
.get_universal_provider(id)?
.ok_or_else(|| AppError::Message(format!("统一供应商 {id} 不存在")))?;
// 同步到 Claude
if let Some(mut claude_provider) = provider.to_claude_provider() {
// 合并已有配置
if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, "claude")? {
let mut merged = existing.settings_config.clone();
Self::merge_json(&mut merged, &claude_provider.settings_config);
claude_provider.settings_config = merged;
}
state.db.save_provider("claude", &claude_provider)?;
} else {
// 如果禁用了 Claude,删除对应的子供应商
let claude_id = format!("universal-claude-{id}");
let _ = state.db.delete_provider("claude", &claude_id);
}
// 同步到 Codex
if let Some(mut codex_provider) = provider.to_codex_provider() {
// 合并已有配置
if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, "codex")? {
let mut merged = existing.settings_config.clone();
Self::merge_json(&mut merged, &codex_provider.settings_config);
codex_provider.settings_config = merged;
}
state.db.save_provider("codex", &codex_provider)?;
} else {
let codex_id = format!("universal-codex-{id}");
let _ = state.db.delete_provider("codex", &codex_id);
}
// 同步到 Gemini
if let Some(mut gemini_provider) = provider.to_gemini_provider() {
// 合并已有配置
if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, "gemini")? {
let mut merged = existing.settings_config.clone();
Self::merge_json(&mut merged, &gemini_provider.settings_config);
gemini_provider.settings_config = merged;
}
state.db.save_provider("gemini", &gemini_provider)?;
} else {
let gemini_id = format!("universal-gemini-{id}");
let _ = state.db.delete_provider("gemini", &gemini_id);
}
Ok(true)
}
/// 递归合并 JSONbase 为底,patch 覆盖同名字段
fn merge_json(base: &mut serde_json::Value, patch: &serde_json::Value) {
use serde_json::Value;
match (base, patch) {
(Value::Object(base_map), Value::Object(patch_map)) => {
for (k, v_patch) in patch_map {
match base_map.get_mut(k) {
Some(v_base) => Self::merge_json(v_base, v_patch),
None => {
base_map.insert(k.clone(), v_patch.clone());
}
}
}
}
// 其它类型:直接覆盖
(base_val, patch_val) => {
*base_val = patch_val.clone();
}
}
}
}
+3 -45
View File
@@ -79,34 +79,6 @@ pub(crate) async fn execute_and_format_usage_result(
} }
} }
/// Extract API key from provider configuration
fn extract_api_key_from_provider(provider: &crate::provider::Provider) -> Option<String> {
if let Some(env) = provider.settings_config.get("env") {
// Try multiple possible API key fields
env.get("ANTHROPIC_AUTH_TOKEN")
.or_else(|| env.get("ANTHROPIC_API_KEY"))
.or_else(|| env.get("OPENROUTER_API_KEY"))
.or_else(|| env.get("GOOGLE_API_KEY"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else {
None
}
}
/// Extract base URL from provider configuration
fn extract_base_url_from_provider(provider: &crate::provider::Provider) -> Option<String> {
if let Some(env) = provider.settings_config.get("env") {
// Try multiple possible base URL fields
env.get("ANTHROPIC_BASE_URL")
.or_else(|| env.get("GOOGLE_GEMINI_BASE_URL"))
.and_then(|v| v.as_str())
.map(|s| s.trim_end_matches('/').to_string())
} else {
None
}
}
/// Query provider usage (using saved script configuration) /// Query provider usage (using saved script configuration)
pub async fn query_usage( pub async fn query_usage(
state: &AppState, state: &AppState,
@@ -142,26 +114,12 @@ pub async fn query_usage(
)); ));
} }
// Get credentials: prioritize UsageScript values, fallback to provider config // Get credentials directly from UsageScript, no longer extract from provider config
let api_key = usage_script
.api_key
.clone()
.filter(|k| !k.is_empty())
.or_else(|| extract_api_key_from_provider(provider))
.unwrap_or_default();
let base_url = usage_script
.base_url
.clone()
.filter(|u| !u.is_empty())
.or_else(|| extract_base_url_from_provider(provider))
.unwrap_or_default();
( (
usage_script.code.clone(), usage_script.code.clone(),
usage_script.timeout.unwrap_or(10), usage_script.timeout.unwrap_or(10),
api_key, usage_script.api_key.clone().unwrap_or_default(),
base_url, usage_script.base_url.clone().unwrap_or_default(),
usage_script.access_token.clone(), usage_script.access_token.clone(),
usage_script.user_id.clone(), usage_script.user_id.clone(),
) )
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -77,7 +77,7 @@ impl Default for SkillStore {
SkillRepo { SkillRepo {
owner: "ComposioHQ".to_string(), owner: "ComposioHQ".to_string(),
name: "awesome-claude-skills".to_string(), name: "awesome-claude-skills".to_string(),
branch: "master".to_string(), branch: "main".to_string(),
enabled: true, enabled: true,
}, },
SkillRepo { SkillRepo {
+26 -43
View File
@@ -31,51 +31,40 @@ impl SpeedtestService {
return Ok(vec![]); return Ok(vec![]);
} }
let mut results: Vec<Option<EndpointLatency>> = vec![None; urls.len()];
let mut valid_targets = Vec::new();
for (idx, raw_url) in urls.into_iter().enumerate() {
let trimmed = raw_url.trim().to_string();
if trimmed.is_empty() {
results[idx] = Some(EndpointLatency {
url: raw_url,
latency: None,
status: None,
error: Some("URL 不能为空".to_string()),
});
continue;
}
match Url::parse(&trimmed) {
Ok(parsed_url) => valid_targets.push((idx, trimmed, parsed_url)),
Err(err) => {
results[idx] = Some(EndpointLatency {
url: trimmed,
latency: None,
status: None,
error: Some(format!("URL 无效: {err}")),
});
}
}
}
if valid_targets.is_empty() {
return Ok(results.into_iter().flatten().collect::<Vec<_>>());
}
let timeout = Self::sanitize_timeout(timeout_secs); let timeout = Self::sanitize_timeout(timeout_secs);
let client = Self::build_client(timeout)?; let client = Self::build_client(timeout)?;
let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| { let tasks = urls.into_iter().map(|raw_url| {
let client = client.clone(); let client = client.clone();
async move { async move {
let trimmed = raw_url.trim().to_string();
if trimmed.is_empty() {
return EndpointLatency {
url: raw_url,
latency: None,
status: None,
error: Some("URL 不能为空".to_string()),
};
}
let parsed_url = match Url::parse(&trimmed) {
Ok(url) => url,
Err(err) => {
return EndpointLatency {
url: trimmed,
latency: None,
status: None,
error: Some(format!("URL 无效: {err}")),
};
}
};
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。 // 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
let _ = client.get(parsed_url.clone()).send().await; let _ = client.get(parsed_url.clone()).send().await;
// 第二次请求开始计时,并将其作为结果返回。 // 第二次请求开始计时,并将其作为结果返回。
let start = Instant::now(); let start = Instant::now();
let latency = match client.get(parsed_url).send().await { match client.get(parsed_url).send().await {
Ok(resp) => EndpointLatency { Ok(resp) => EndpointLatency {
url: trimmed, url: trimmed,
latency: Some(start.elapsed().as_millis()), latency: Some(start.elapsed().as_millis()),
@@ -99,17 +88,11 @@ impl SpeedtestService {
error: Some(error_message), error: Some(error_message),
} }
} }
}; }
(idx, latency)
} }
}); });
for (idx, latency) in join_all(tasks).await { Ok(join_all(tasks).await)
results[idx] = Some(latency);
}
Ok(results.into_iter().flatten().collect::<Vec<_>>())
} }
fn build_client(timeout_secs: u64) -> Result<Client, AppError> { fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
-481
View File
@@ -1,481 +0,0 @@
//! 流式健康检查服务
//!
//! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。
use futures::StreamExt;
use regex::Regex;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::time::{Duration, Instant};
use crate::app_config::AppType;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::providers::{get_adapter, AuthInfo};
/// 健康状态枚举
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum HealthStatus {
Operational,
Degraded,
Failed,
}
/// 流式检查配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamCheckConfig {
pub timeout_secs: u64,
pub max_retries: u32,
pub degraded_threshold_ms: u64,
/// Claude 测试模型
pub claude_model: String,
/// Codex 测试模型
pub codex_model: String,
/// Gemini 测试模型
pub gemini_model: String,
}
impl Default for StreamCheckConfig {
fn default() -> Self {
Self {
timeout_secs: 45,
max_retries: 2,
degraded_threshold_ms: 6000,
claude_model: "claude-haiku-4-5-20251001".to_string(),
codex_model: "gpt-5.1-codex@low".to_string(),
gemini_model: "gemini-3-pro-preview".to_string(),
}
}
}
/// 流式检查结果
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamCheckResult {
pub status: HealthStatus,
pub success: bool,
pub message: String,
pub response_time_ms: Option<u64>,
pub http_status: Option<u16>,
pub model_used: String,
pub tested_at: i64,
pub retry_count: u32,
}
/// 流式健康检查服务
pub struct StreamCheckService;
impl StreamCheckService {
/// 执行流式健康检查(带重试)
pub async fn check_with_retry(
app_type: &AppType,
provider: &Provider,
config: &StreamCheckConfig,
) -> Result<StreamCheckResult, AppError> {
let mut last_result = None;
for attempt in 0..=config.max_retries {
let result = Self::check_once(app_type, provider, config).await;
match &result {
Ok(r) if r.success => {
return Ok(StreamCheckResult {
retry_count: attempt,
..r.clone()
});
}
Ok(r) => {
// 失败但非异常,判断是否重试
if Self::should_retry(&r.message) && attempt < config.max_retries {
last_result = Some(r.clone());
continue;
}
return Ok(StreamCheckResult {
retry_count: attempt,
..r.clone()
});
}
Err(e) => {
if Self::should_retry(&e.to_string()) && attempt < config.max_retries {
continue;
}
return Err(AppError::Message(e.to_string()));
}
}
}
Ok(last_result.unwrap_or_else(|| StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: "检查失败".to_string(),
response_time_ms: None,
http_status: None,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
retry_count: config.max_retries,
}))
}
/// 单次流式检查
async fn check_once(
app_type: &AppType,
provider: &Provider,
config: &StreamCheckConfig,
) -> Result<StreamCheckResult, AppError> {
let start = Instant::now();
let adapter = get_adapter(app_type);
let base_url = adapter
.extract_base_url(provider)
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
let auth = adapter
.extract_auth(provider)
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.user_agent("cc-switch/1.0")
.build()
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
let model_to_test = Self::resolve_test_model(app_type, provider, config);
let result = match app_type {
AppType::Claude => {
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
}
AppType::Codex => {
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
}
AppType::Gemini => {
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
}
};
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: "检查成功".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,
}),
}
}
/// Claude 流式检查
async fn check_claude_stream(
client: &Client,
base_url: &str,
auth: &AuthInfo,
model: &str,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
let url = if base.ends_with("/v1") {
format!("{base}/messages")
} else {
format!("{base}/v1/messages")
};
let body = json!({
"model": model,
"max_tokens": 1,
"messages": [{ "role": "user", "content": "hi" }],
"stream": true
});
let response = client
.post(&url)
.header("x-api-key", &auth.api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(Self::map_request_error)?;
let status = response.status().as_u16();
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
}
// 流式读取:只需首个 chunk
let mut stream = response.bytes_stream();
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
}
} else {
Err(AppError::Message("未收到响应数据".to_string()))
}
}
/// Codex 流式检查
async fn check_codex_stream(
client: &Client,
base_url: &str,
auth: &AuthInfo,
model: &str,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
let url = if base.ends_with("/v1") {
format!("{base}/chat/completions")
} else {
format!("{base}/v1/chat/completions")
};
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
let mut body = json!({
"model": actual_model,
"messages": [
{ "role": "system", "content": "" },
{ "role": "assistant", "content": "" },
{ "role": "user", "content": "hi" }
],
"max_tokens": 1,
"temperature": 0,
"stream": true
});
// 如果是推理模型,添加 reasoning_effort
if let Some(effort) = reasoning_effort {
body["reasoning_effort"] = json!(effort);
}
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(Self::map_request_error)?;
let status = response.status().as_u16();
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
}
let mut stream = response.bytes_stream();
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
}
} else {
Err(AppError::Message("未收到响应数据".to_string()))
}
}
/// Gemini 流式检查
async fn check_gemini_stream(
client: &Client,
base_url: &str,
auth: &AuthInfo,
model: &str,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
let url = format!("{base}/v1/chat/completions");
let body = json!({
"model": model,
"messages": [{ "role": "user", "content": "hi" }],
"max_tokens": 1,
"temperature": 0,
"stream": true
});
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(Self::map_request_error)?;
let status = response.status().as_u16();
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
}
let mut stream = response.bytes_stream();
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
}
} else {
Err(AppError::Message("未收到响应数据".to_string()))
}
}
fn determine_status(latency_ms: u64, threshold: u64) -> HealthStatus {
if latency_ms <= threshold {
HealthStatus::Operational
} else {
HealthStatus::Degraded
}
}
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
/// 返回 (实际模型名, Option<推理等级>)
fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
// 查找 @ 或 # 分隔符
if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
let actual_model = model[..pos].to_string();
let effort = model[pos + 1..].to_string();
if !effort.is_empty() {
return (actual_model, Some(effort));
}
}
(model.to_string(), None)
}
fn should_retry(msg: &str) -> bool {
let lower = msg.to_lowercase();
lower.contains("timeout")
|| lower.contains("abort")
|| lower.contains("中断")
|| lower.contains("超时")
}
fn map_request_error(e: reqwest::Error) -> AppError {
if e.is_timeout() {
AppError::Message("请求超时".to_string())
} else if e.is_connect() {
AppError::Message(format!("连接失败: {e}"))
} else {
AppError::Message(e.to_string())
}
}
fn resolve_test_model(
app_type: &AppType,
provider: &Provider,
config: &StreamCheckConfig,
) -> String {
match app_type {
AppType::Claude => Self::extract_env_model(provider, "ANTHROPIC_MODEL")
.unwrap_or_else(|| config.claude_model.clone()),
AppType::Codex => {
Self::extract_codex_model(provider).unwrap_or_else(|| config.codex_model.clone())
}
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
.unwrap_or_else(|| config.gemini_model.clone()),
}
}
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
provider
.settings_config
.get("env")
.and_then(|env| env.get(key))
.and_then(|value| value.as_str())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn extract_codex_model(provider: &Provider) -> Option<String> {
let config_text = provider
.settings_config
.get("config")
.and_then(|value| value.as_str())?;
if config_text.trim().is_empty() {
return None;
}
let re = Regex::new(r#"^model\s*=\s*["']([^"']+)["']"#).ok()?;
re.captures(config_text)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().trim().to_string())
.filter(|value| !value.is_empty())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_determine_status() {
assert_eq!(
StreamCheckService::determine_status(3000, 6000),
HealthStatus::Operational
);
assert_eq!(
StreamCheckService::determine_status(6000, 6000),
HealthStatus::Operational
);
assert_eq!(
StreamCheckService::determine_status(6001, 6000),
HealthStatus::Degraded
);
}
#[test]
fn test_should_retry() {
assert!(StreamCheckService::should_retry("请求超时"));
assert!(StreamCheckService::should_retry("request timeout"));
assert!(!StreamCheckService::should_retry("API Key 无效"));
}
#[test]
fn test_default_config() {
let config = StreamCheckConfig::default();
assert_eq!(config.timeout_secs, 45);
assert_eq!(config.max_retries, 2);
assert_eq!(config.degraded_threshold_ms, 6000);
}
#[test]
fn test_parse_model_with_effort() {
// 带 @ 分隔符
let (model, effort) = StreamCheckService::parse_model_with_effort("gpt-5.1-codex@low");
assert_eq!(model, "gpt-5.1-codex");
assert_eq!(effort, Some("low".to_string()));
// 带 # 分隔符
let (model, effort) = StreamCheckService::parse_model_with_effort("o1-preview#high");
assert_eq!(model, "o1-preview");
assert_eq!(effort, Some("high".to_string()));
// 无分隔符
let (model, effort) = StreamCheckService::parse_model_with_effort("gpt-4o-mini");
assert_eq!(model, "gpt-4o-mini");
assert_eq!(effort, None);
}
}
+278 -155
View File
@@ -4,7 +4,7 @@
use crate::database::{lock_conn, Database}; use crate::database::{lock_conn, Database};
use crate::error::AppError; use crate::error::AppError;
use chrono::{Local, TimeZone}; use chrono::{Duration, Utc};
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -181,63 +181,29 @@ impl Database {
Ok(result) Ok(result)
} }
/// 获取每日趋势(滑动窗口,<=24h 按小时,>24h 按天,窗口与汇总一致) /// 获取每日趋势
pub fn get_daily_trends( pub fn get_daily_trends(&self, days: u32) -> Result<Vec<DailyStats>, AppError> {
&self,
start_date: Option<i64>,
end_date: Option<i64>,
) -> Result<Vec<DailyStats>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let end_ts = end_date.unwrap_or_else(|| Local::now().timestamp()); if days <= 1 {
let mut start_ts = start_date.unwrap_or_else(|| end_ts - 24 * 60 * 60); let sql = "SELECT
strftime('%Y-%m-%dT%H:00:00Z', datetime(created_at, 'unixepoch')) as bucket,
COUNT(*) as request_count,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
FROM proxy_request_logs
WHERE created_at >= strftime('%s', 'now', '-1 day')
GROUP BY bucket
ORDER BY bucket ASC";
if start_ts >= end_ts { let mut stmt = conn.prepare(sql)?;
start_ts = end_ts - 24 * 60 * 60; let rows = stmt.query_map([], |row| {
} Ok(DailyStats {
date: row.get(0)?,
let duration = end_ts - start_ts;
let bucket_seconds: i64 = if duration <= 24 * 60 * 60 {
60 * 60
} else {
24 * 60 * 60
};
let mut bucket_count: i64 = if duration <= 0 {
1
} else {
((duration as f64) / bucket_seconds as f64).ceil() as i64
};
// 固定 24 小时窗口为 24 个小时桶,避免浮点误差
if bucket_seconds == 60 * 60 {
bucket_count = 24;
}
if bucket_count < 1 {
bucket_count = 1;
}
let sql = "
SELECT
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
COUNT(*) as request_count,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
FROM proxy_request_logs
WHERE created_at >= ?1 AND created_at <= ?2
GROUP BY bucket_idx
ORDER BY bucket_idx ASC";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| {
Ok((
row.get::<_, i64>(0)?,
DailyStats {
date: String::new(),
request_count: row.get::<_, i64>(1)? as u64, request_count: row.get::<_, i64>(1)? as u64,
total_cost: format!("{:.6}", row.get::<_, f64>(2)?), total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
total_tokens: row.get::<_, i64>(3)? as u64, total_tokens: row.get::<_, i64>(3)? as u64,
@@ -245,50 +211,99 @@ impl Database {
total_output_tokens: row.get::<_, i64>(5)? as u64, total_output_tokens: row.get::<_, i64>(5)? as u64,
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64, total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
total_cache_read_tokens: row.get::<_, i64>(7)? as u64, total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
}, })
)) })?;
})?;
let mut map: HashMap<i64, DailyStats> = HashMap::new(); let mut buckets: HashMap<String, DailyStats> = HashMap::new();
for row in rows { for row in rows {
let (mut bucket_idx, stat) = row?; let stat = row?;
if bucket_idx < 0 { buckets.insert(stat.date.clone(), stat);
continue;
} }
if bucket_idx >= bucket_count {
bucket_idx = bucket_count - 1; let mut stats = Vec::new();
let today = Utc::now().date_naive();
for hour in 0..24 {
let bucket = today
.and_hms_opt(hour, 0, 0)
.unwrap()
.format("%Y-%m-%dT%H:00:00Z")
.to_string();
if let Some(stat) = buckets.remove(&bucket) {
stats.push(stat);
} else {
stats.push(DailyStats {
date: bucket,
request_count: 0,
total_cost: "0.000000".to_string(),
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
});
}
} }
map.insert(bucket_idx, stat); Ok(stats)
} else {
let sql = "SELECT
date(created_at, 'unixepoch') as bucket,
COUNT(*) as request_count,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
FROM proxy_request_logs
WHERE created_at >= strftime('%s', 'now', ?)
GROUP BY bucket
ORDER BY bucket ASC";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([format!("-{days} days")], |row| {
Ok(DailyStats {
date: row.get(0)?,
request_count: row.get::<_, i64>(1)? as u64,
total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
total_tokens: row.get::<_, i64>(3)? as u64,
total_input_tokens: row.get::<_, i64>(4)? as u64,
total_output_tokens: row.get::<_, i64>(5)? as u64,
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
})
})?;
let mut map = HashMap::new();
for row in rows {
let stat = row?;
map.insert(stat.date.clone(), stat);
}
let mut stats = Vec::new();
let start_day =
Utc::now().date_naive() - Duration::days((days.saturating_sub(1)) as i64);
for i in 0..days {
let day = start_day + Duration::days(i as i64);
let key = day.format("%Y-%m-%d").to_string();
if let Some(stat) = map.remove(&key) {
stats.push(stat);
} else {
stats.push(DailyStats {
date: key,
request_count: 0,
total_cost: "0.000000".to_string(),
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
});
}
}
Ok(stats)
} }
let mut stats = Vec::with_capacity(bucket_count as usize);
for i in 0..bucket_count {
let bucket_start_ts = start_ts + i * bucket_seconds;
let bucket_start = Local
.timestamp_opt(bucket_start_ts, 0)
.single()
.unwrap_or_else(Local::now);
let date = bucket_start.format("%Y-%m-%dT%H:%M:%S").to_string();
if let Some(mut stat) = map.remove(&i) {
stat.date = date;
stats.push(stat);
} else {
stats.push(DailyStats {
date,
request_count: 0,
total_cost: "0.000000".to_string(),
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
});
}
}
Ok(stats)
} }
/// 获取 Provider 统计 /// 获取 Provider 统计
@@ -602,7 +617,7 @@ impl Database {
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) "SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
FROM proxy_request_logs FROM proxy_request_logs
WHERE provider_id = ? AND app_type = ? WHERE provider_id = ? AND app_type = ?
AND date(datetime(created_at, 'unixepoch', 'localtime')) = date('now', 'localtime')", AND date(created_at, 'unixepoch') = date('now')",
params![provider_id, app_type], params![provider_id, app_type],
|row| row.get(0), |row| row.get(0),
) )
@@ -614,7 +629,7 @@ impl Database {
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) "SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
FROM proxy_request_logs FROM proxy_request_logs
WHERE provider_id = ? AND app_type = ? WHERE provider_id = ? AND app_type = ?
AND strftime('%Y-%m', datetime(created_at, 'unixepoch', 'localtime')) = strftime('%Y-%m', 'now', 'localtime')", AND strftime('%Y-%m', created_at, 'unixepoch') = strftime('%Y-%m', 'now')",
params![provider_id, app_type], params![provider_id, app_type],
|row| row.get(0), |row| row.get(0),
) )
@@ -637,6 +652,56 @@ impl Database {
monthly_exceeded, monthly_exceeded,
}) })
} }
/// 更新每日统计聚合
///
/// 在请求完成后调用,更新 usage_daily_stats 表
#[allow(clippy::too_many_arguments)]
pub fn update_daily_stats(
&self,
provider_id: &str,
app_type: &str,
model: &str,
input_tokens: u32,
output_tokens: u32,
total_cost: &str,
is_success: bool,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
let date = Utc::now().format("%Y-%m-%d").to_string();
// 使用 UPSERT 更新或插入统计
conn.execute(
"INSERT INTO usage_daily_stats (
date, provider_id, app_type, model,
request_count, total_input_tokens, total_output_tokens,
total_cost_usd, success_count, error_count
) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(date, provider_id, app_type, model) DO UPDATE SET
request_count = request_count + 1,
total_input_tokens = total_input_tokens + ?5,
total_output_tokens = total_output_tokens + ?6,
total_cost_usd = CAST(
CAST(total_cost_usd AS REAL) + CAST(?7 AS REAL) AS TEXT
),
success_count = success_count + ?8,
error_count = error_count + ?9",
params![
date,
provider_id,
app_type,
model,
input_tokens,
output_tokens,
total_cost,
if is_success { 1 } else { 0 },
if is_success { 0 } else { 1 },
],
)
.map_err(|e| AppError::Database(format!("更新每日统计失败: {e}")))?;
Ok(())
}
} }
/// Provider 限额状态 /// Provider 限额状态
@@ -798,46 +863,89 @@ impl Database {
} }
} }
/// 标准化模型名称:去除供应商前缀并将点号替换为短横线
/// 例如:anthropic/claude-haiku-4.5 → claude-haiku-4-5
fn normalize_model_id(model_id: &str) -> String {
// 1. 去除供应商前缀(如 anthropic/、openai/
let stripped = if let Some(pos) = model_id.find('/') {
&model_id[pos + 1..]
} else {
model_id
};
// 2. 将点号替换为短横线(如 claude-haiku-4.5 → claude-haiku-4-5
stripped.replace('.', "-")
}
pub(crate) fn find_model_pricing_row( pub(crate) fn find_model_pricing_row(
conn: &Connection, conn: &Connection,
model_id: &str, model_id: &str,
) -> Result<Option<(String, String, String, String)>, AppError> { ) -> Result<Option<(String, String, String, String)>, AppError> {
// 1) 去除供应商前缀(/ 之前)与冒号后缀(: 之后),例如 moonshotai/kimi-k2-0905:exa → kimi-k2-0905 // 0. 标准化模型名称(去除前缀 + 点号转短横线)
let without_prefix = model_id // 例如:anthropic/claude-haiku-4.5 → claude-haiku-4-5
.rsplit_once('/') let normalized = normalize_model_id(model_id);
.map(|(_, rest)| rest)
.unwrap_or(model_id);
let cleaned = without_prefix
.split(':')
.next()
.map(str::trim)
.unwrap_or(without_prefix);
// 2) 精确匹配清洗后的名称 // 1. 精确匹配(先尝试原始名称,再尝试标准化后的名称
let exact = conn for id in [model_id, normalized.as_str()] {
.query_row( let exact = conn
"SELECT input_cost_per_million, output_cost_per_million, .query_row(
cache_read_cost_per_million, cache_creation_cost_per_million "SELECT input_cost_per_million, output_cost_per_million,
FROM model_pricing cache_read_cost_per_million, cache_creation_cost_per_million
WHERE model_id = ?1", FROM model_pricing
[cleaned], WHERE model_id = ?1",
|row| { [id],
Ok(( |row| {
row.get::<_, String>(0)?, Ok((
row.get::<_, String>(1)?, row.get::<_, String>(0)?,
row.get::<_, String>(2)?, row.get::<_, String>(1)?,
row.get::<_, String>(3)?, row.get::<_, String>(2)?,
)) row.get::<_, String>(3)?,
}, ))
) },
.optional() )
.map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?; .optional()
.map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?;
if exact.is_none() { if exact.is_some() {
log::warn!("模型 {model_id}(清洗后: {cleaned})未找到定价信息,成本将记录为 0"); if id != model_id {
log::info!("模型 {model_id} 标准化后精确匹配到: {id}");
}
return Ok(exact);
}
} }
Ok(exact) // 2. 逐步删除后缀匹配(claude-haiku-4-5-20250929 → claude-haiku-4-5 → claude-haiku-4 → claude-haiku
// 使用标准化后的名称进行后缀匹配
let mut current = normalized;
while let Some(pos) = current.rfind('-') {
current = current[..pos].to_string();
let result = conn
.query_row(
"SELECT input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing
WHERE model_id = ?1",
[&current],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
)
.optional()
.map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?;
if result.is_some() {
log::info!("模型 {model_id} 通过删除后缀匹配到: {current}");
return Ok(result);
}
}
log::warn!("模型 {model_id} 未找到定价信息,成本将记录为 0");
Ok(None)
} }
#[cfg(test)] #[cfg(test)]
@@ -917,39 +1025,54 @@ mod tests {
let db = Database::memory()?; let db = Database::memory()?;
let conn = lock_conn!(db.conn); let conn = lock_conn!(db.conn);
// 准备额外定价数据,覆盖前缀/后缀清洗场景 // 测试精确匹配
conn.execute( let result = find_model_pricing_row(&conn, "claude-sonnet-4-5")?;
"INSERT OR REPLACE INTO model_pricing ( assert!(result.is_some(), "应该能精确匹配 claude-sonnet-4-5");
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?, ?, ?, ?, ?, ?)",
params![
"claude-haiku-4.5",
"Claude Haiku 4.5",
"1.0",
"2.0",
"0.0",
"0.0"
],
)?;
// 测试精确匹配(seed_model_pricing 已预置 claude-sonnet-4-5-20250929 // 测试带供应商前缀的模型名称(anthropic/claude-haiku-4.5 → claude-haiku-4-5
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20250929")?;
assert!(
result.is_some(),
"应该能精确匹配 claude-sonnet-4-5-20250929"
);
// 清洗:去除前缀和冒号后缀
let result = find_model_pricing_row(&conn, "anthropic/claude-haiku-4.5")?; let result = find_model_pricing_row(&conn, "anthropic/claude-haiku-4.5")?;
assert!( assert!(
result.is_some(), result.is_some(),
"带前缀的模型 anthropic/claude-haiku-4.5 应能匹配到 claude-haiku-4.5" "应该能匹配带前缀的模型 anthropic/claude-haiku-4.5"
); );
let result = find_model_pricing_row(&conn, "moonshotai/kimi-k2-0905:exa")?;
// 测试带供应商前缀 + 点号的模型名称
let result = find_model_pricing_row(&conn, "anthropic/claude-sonnet-4.5")?;
assert!( assert!(
result.is_some(), result.is_some(),
"带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905" "应该能匹配带前缀的模型 anthropic/claude-sonnet-4.5"
);
// 测试逐步删除后缀匹配 - 日期后缀
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20241022")?;
assert!(
result.is_some(),
"应该能通过删除后缀匹配 claude-sonnet-4-5-20241022"
);
// 测试逐步删除后缀匹配 - 多个后缀
let result = find_model_pricing_row(&conn, "claude-haiku-4-5-20240229-preview")?;
assert!(
result.is_some(),
"应该能通过删除后缀匹配 claude-haiku-4-5-20240229-preview"
);
// 测试 GPT 模型
let result = find_model_pricing_row(&conn, "gpt-5-2024-11-20")?;
assert!(result.is_some(), "应该能通过删除后缀匹配 gpt-5-2024-11-20");
// 测试 Gemini 模型
let result = find_model_pricing_row(&conn, "gemini-2.5-flash-exp")?;
assert!(
result.is_some(),
"应该能通过删除后缀匹配 gemini-2.5-flash-exp"
);
// 测试 claude-sonnet-4-5 命名格式
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20250929")?;
assert!(
result.is_some(),
"应该能通过删除后缀匹配 claude-sonnet-4-5-20250929"
); );
// 测试不存在的模型 // 测试不存在的模型
-8
View File
@@ -31,9 +31,6 @@ pub struct AppSettings {
/// 是否启用 Claude 插件联动 /// 是否启用 Claude 插件联动
#[serde(default)] #[serde(default)]
pub enable_claude_plugin_integration: bool, pub enable_claude_plugin_integration: bool,
/// 是否跳过 Claude Code 初次安装确认
#[serde(default = "default_true")]
pub skip_claude_onboarding: bool,
/// 是否开机自启 /// 是否开机自启
#[serde(default)] #[serde(default)]
pub launch_on_startup: bool, pub launch_on_startup: bool,
@@ -68,17 +65,12 @@ fn default_minimize_to_tray_on_close() -> bool {
true true
} }
fn default_true() -> bool {
true
}
impl Default for AppSettings { impl Default for AppSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
show_in_tray: true, show_in_tray: true,
minimize_to_tray_on_close: true, minimize_to_tray_on_close: true,
enable_claude_plugin_integration: false, enable_claude_plugin_integration: false,
skip_claude_onboarding: true,
launch_on_startup: false, launch_on_startup: false,
language: None, language: None,
claude_config_dir: None, claude_config_dir: None,
+18 -492
View File
@@ -3,7 +3,6 @@ use rquickjs::{Context, Function, Runtime};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use url::{Host, Url};
use crate::error::AppError; use crate::error::AppError;
@@ -16,14 +15,20 @@ pub async fn execute_usage_script(
access_token: Option<&str>, access_token: Option<&str>,
user_id: Option<&str>, user_id: Option<&str>,
) -> Result<Value, AppError> { ) -> Result<Value, AppError> {
// 1. 替换模板变量,避免泄露敏感信息 // 1. 替换变量
let script_with_vars = let mut replaced = script_code
build_script_with_vars(script_code, api_key, base_url, access_token, user_id); .replace("{{apiKey}}", api_key)
.replace("{{baseUrl}}", base_url);
// 2. 验证 base_url 的安全性 // 替换 accessToken 和 userId
validate_base_url(base_url)?; if let Some(token) = access_token {
replaced = replaced.replace("{{accessToken}}", token);
}
if let Some(uid) = user_id {
replaced = replaced.replace("{{userId}}", uid);
}
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放) // 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
let request_config = { let request_config = {
let runtime = Runtime::new().map_err(|e| { let runtime = Runtime::new().map_err(|e| {
AppError::localized( AppError::localized(
@@ -42,7 +47,7 @@ pub async fn execute_usage_script(
context.with(|ctx| { context.with(|ctx| {
// 执行用户代码,获取配置对象 // 执行用户代码,获取配置对象
let config: rquickjs::Object = ctx.eval(script_with_vars.clone()).map_err(|e| { let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
AppError::localized( AppError::localized(
"usage_script.config_parse_failed", "usage_script.config_parse_failed",
format!("解析配置失败: {e}"), format!("解析配置失败: {e}"),
@@ -89,7 +94,7 @@ pub async fn execute_usage_script(
})? })?
}; // Runtime 和 Context 在这里被 drop }; // Runtime 和 Context 在这里被 drop
// 4. 解析 request 配置 // 3. 解析 request 配置
let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| { let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {
AppError::localized( AppError::localized(
"usage_script.request_format_invalid", "usage_script.request_format_invalid",
@@ -98,13 +103,10 @@ pub async fn execute_usage_script(
) )
})?; })?;
// 5. 验证请求 URL 是否安全(防止 SSRF) // 4. 发送 HTTP 请求
validate_request_url(&request.url, base_url)?;
// 6. 发送 HTTP 请求
let response_data = send_http_request(&request, timeout_secs).await?; let response_data = send_http_request(&request, timeout_secs).await?;
// 7. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放) // 5. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放)
let result: Value = { let result: Value = {
let runtime = Runtime::new().map_err(|e| { let runtime = Runtime::new().map_err(|e| {
AppError::localized( AppError::localized(
@@ -123,7 +125,7 @@ pub async fn execute_usage_script(
context.with(|ctx| { context.with(|ctx| {
// 重新 eval 获取配置对象 // 重新 eval 获取配置对象
let config: rquickjs::Object = ctx.eval(script_with_vars.clone()).map_err(|e| { let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
AppError::localized( AppError::localized(
"usage_script.config_reparse_failed", "usage_script.config_reparse_failed",
format!("重新解析配置失败: {e}"), format!("重新解析配置失败: {e}"),
@@ -196,7 +198,7 @@ pub async fn execute_usage_script(
})? })?
}; // Runtime 和 Context 在这里被 drop }; // Runtime 和 Context 在这里被 drop
// 8. 验证返回值格式 // 6. 验证返回值格式
validate_result(&result)?; validate_result(&result)?;
Ok(result) Ok(result)
@@ -392,479 +394,3 @@ fn validate_single_usage(result: &Value) -> Result<(), AppError> {
Ok(()) Ok(())
} }
/// 构建替换变量后的脚本,保持与旧版脚本的兼容性
fn build_script_with_vars(
script_code: &str,
api_key: &str,
base_url: &str,
access_token: Option<&str>,
user_id: Option<&str>,
) -> String {
let mut replaced = script_code
.replace("{{apiKey}}", api_key)
.replace("{{baseUrl}}", base_url);
if let Some(token) = access_token {
replaced = replaced.replace("{{accessToken}}", token);
}
if let Some(uid) = user_id {
replaced = replaced.replace("{{userId}}", uid);
}
replaced
}
/// 验证 base_url 的基本安全性
fn validate_base_url(base_url: &str) -> Result<(), AppError> {
if base_url.is_empty() {
return Err(AppError::localized(
"usage_script.base_url_empty",
"base_url 不能为空",
"base_url cannot be empty",
));
}
// 解析 URL
let parsed_url = Url::parse(base_url).map_err(|e| {
AppError::localized(
"usage_script.base_url_invalid",
format!("无效的 base_url: {e}"),
format!("Invalid base_url: {e}"),
)
})?;
let is_loopback = is_loopback_host(&parsed_url);
// 必须是 HTTPS(允许 localhost 用于开发)
if parsed_url.scheme() != "https" && !is_loopback {
return Err(AppError::localized(
"usage_script.base_url_https_required",
"base_url 必须使用 HTTPS 协议(localhost 除外)",
"base_url must use HTTPS (localhost allowed)",
));
}
// 检查主机名格式有效性
let hostname = parsed_url.host_str().ok_or_else(|| {
AppError::localized(
"usage_script.base_url_hostname_missing",
"base_url 必须包含有效的主机名",
"base_url must include a valid hostname",
)
})?;
// 基本的主机名格式检查
if hostname.is_empty() {
return Err(AppError::localized(
"usage_script.base_url_hostname_empty",
"base_url 主机名不能为空",
"base_url hostname cannot be empty",
));
}
// 检查是否为明显的私有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)
fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppError> {
// 解析请求 URL
let parsed_request = Url::parse(request_url).map_err(|e| {
AppError::localized(
"usage_script.request_url_invalid",
format!("无效的请求 URL: {e}"),
format!("Invalid request URL: {e}"),
)
})?;
// 解析 base URL
let parsed_base = Url::parse(base_url).map_err(|e| {
AppError::localized(
"usage_script.base_url_invalid",
format!("无效的 base_url: {e}"),
format!("Invalid base_url: {e}"),
)
})?;
let is_request_loopback = is_loopback_host(&parsed_request);
// 必须使用 HTTPS(允许 localhost 用于开发)
if parsed_request.scheme() != "https" && !is_request_loopback {
return Err(AppError::localized(
"usage_script.request_https_required",
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
"Request URL must use HTTPS (localhost allowed)",
));
}
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
if parsed_request.host_str() != parsed_base.host_str() {
return Err(AppError::localized(
"usage_script.request_host_mismatch",
format!(
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
format!(
"Request host {} must match base_url host {} (same-origin required)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
));
}
// 检查端口是否匹配(考虑默认端口)
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443
match (
parsed_request.port_or_known_default(),
parsed_base.port_or_known_default(),
) {
(Some(request_port), Some(base_port)) if request_port == base_port => {
// 端口匹配,继续执行
}
(Some(request_port), Some(base_port)) => {
return Err(AppError::localized(
"usage_script.request_port_mismatch",
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
format!("Request port {request_port} must match base_url port {base_port}"),
));
}
_ => {
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
return Err(AppError::localized(
"usage_script.request_port_unknown",
"无法确定端口号",
"Unable to determine port number",
));
}
}
// 禁止私有 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",
));
}
}
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::<std::net::IpAddr>() {
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::<std::net::IpAddr>().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() {
Some(Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
Some(Host::Ipv4(ip)) => ip.is_loopback(),
Some(Host::Ipv6(ip)) => ip.is_loopback(),
_ => false,
}
}
#[cfg(test)]
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 应该被拒绝
let result = validate_base_url("http://127.0.0.1.evil.com/api");
assert!(
result.is_err(),
"Should reject HTTP for non-localhost domains"
);
}
#[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::<std::net::IpAddr>().unwrap();
assert!(is_private_ip_addr(ipv4_private));
let ipv4_public = "172.67.0.1".parse::<std::net::IpAddr>().unwrap();
assert!(!is_private_ip_addr(ipv4_public));
let ipv6_private = "fc00::1".parse::<std::net::IpAddr>().unwrap();
assert!(is_private_ip_addr(ipv6_private));
let ipv6_public = "2001:4860:4860::8888".parse::<std::net::IpAddr>().unwrap();
assert!(!is_private_ip_addr(ipv6_public));
}
#[test]
fn test_port_comparison() {
// 测试端口比较逻辑是否正确处理默认端口和显式端口
// 测试用例:(base_url, request_url, should_match)
let test_cases = vec![
// HTTPS默认端口测试
(
"https://api.example.com",
"https://api.example.com/v1/test",
true,
),
(
"https://api.example.com",
"https://api.example.com:443/v1/test",
true,
),
(
"https://api.example.com:443",
"https://api.example.com/v1/test",
true,
),
(
"https://api.example.com:443",
"https://api.example.com:443/v1/test",
true,
),
// 端口不匹配测试
(
"https://api.example.com",
"https://api.example.com:8443/v1/test",
false,
),
(
"https://api.example.com:443",
"https://api.example.com:8443/v1/test",
false,
),
];
for (base_url, request_url, should_match) in test_cases {
let result = validate_request_url(request_url, base_url);
if should_match {
assert!(
result.is_ok(),
"应该匹配的URL被拒绝: base_url={}, request_url={}, error={}",
base_url,
request_url,
result.unwrap_err()
);
} else {
assert!(
result.is_err(),
"应该不匹配的URL被允许: base_url={}, request_url={}",
base_url,
request_url
);
}
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch", "productName": "CC Switch",
"version": "3.9.0-3", "version": "3.8.2",
"identifier": "com.ccswitch.desktop", "identifier": "com.ccswitch.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+1 -3
View File
@@ -4,9 +4,7 @@
"windows": [ "windows": [
{ {
"label": "main", "label": "main",
"titleBarStyle": "Visible", "titleBarStyle": "Visible"
"minWidth": 900,
"minHeight": 600
} }
] ]
} }
+5 -13
View File
@@ -1,8 +1,6 @@
use std::sync::Arc; use std::sync::Arc;
use cc_switch_lib::{ use cc_switch_lib::{import_provider_from_deeplink, parse_deeplink_url, AppState, Database};
import_provider_from_deeplink, parse_deeplink_url, AppState, Database, ProxyService,
};
#[path = "support.rs"] #[path = "support.rs"]
mod support; mod support;
@@ -18,11 +16,8 @@ fn deeplink_import_claude_provider_persists_to_db() {
let request = parse_deeplink_url(url).expect("parse deeplink url"); let request = parse_deeplink_url(url).expect("parse deeplink url");
let db = Arc::new(Database::memory().expect("create memory db")); let db = Arc::new(Database::memory().expect("create memory db"));
let proxy_service = ProxyService::new(db.clone());
let state = AppState { let state = AppState { db: db.clone() };
db: db.clone(),
proxy_service,
};
let provider_id = import_provider_from_deeplink(&state, request.clone()) let provider_id = import_provider_from_deeplink(&state, request.clone())
.expect("import provider from deeplink"); .expect("import provider from deeplink");
@@ -58,11 +53,8 @@ fn deeplink_import_codex_provider_builds_auth_and_config() {
let request = parse_deeplink_url(url).expect("parse deeplink url"); let request = parse_deeplink_url(url).expect("parse deeplink url");
let db = Arc::new(Database::memory().expect("create memory db")); let db = Arc::new(Database::memory().expect("create memory db"));
let proxy_service = ProxyService::new(db.clone());
let state = AppState { let state = AppState { db: db.clone() };
db: db.clone(),
proxy_service,
};
let provider_id = import_provider_from_deeplink(&state, request.clone()) let provider_id = import_provider_from_deeplink(&state, request.clone())
.expect("import provider from deeplink"); .expect("import provider from deeplink");
+16 -89
View File
@@ -76,8 +76,19 @@ fn sync_codex_provider_writes_auth_and_config() {
let mut config = MultiAppConfig::default(); let mut config = MultiAppConfig::default();
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发 // 添加入测 MCP 启用项,确保 sync_enabled_to_codex 会写入 TOML
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入 config.mcp.codex.servers.insert(
"echo-server".into(),
json!({
"id": "echo-server",
"enabled": true,
"server": {
"type": "stdio",
"command": "echo",
"args": ["hello"]
}
}),
);
let provider_config = json!({ let provider_config = json!({
"auth": { "auth": {
@@ -122,10 +133,9 @@ fn sync_codex_provider_writes_auth_and_config() {
); );
let toml_text = fs::read_to_string(&config_path).expect("read config.toml"); let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
// 验证基础配置正确写入
assert!( assert!(
toml_text.contains("base_url"), toml_text.contains("command = \"echo\""),
"config.toml should contain base_url from provider config" "config.toml should contain serialized enabled MCP server"
); );
// 当前供应商应同步最新 config 文本 // 当前供应商应同步最新 config 文本
@@ -144,12 +154,6 @@ fn sync_enabled_to_codex_writes_enabled_servers() {
let _guard = test_mutex().lock().expect("acquire test mutex"); let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs(); reset_test_fs();
// 模拟 Codex 已安装/已初始化:存在 ~/.codex 目录
let path = cc_switch_lib::get_codex_config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create codex dir");
}
let mut config = MultiAppConfig::default(); let mut config = MultiAppConfig::default();
config.mcp.codex.servers.insert( config.mcp.codex.servers.insert(
"stdio-enabled".into(), "stdio-enabled".into(),
@@ -166,6 +170,7 @@ fn sync_enabled_to_codex_writes_enabled_servers() {
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex"); cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
let path = cc_switch_lib::get_codex_config_path();
assert!(path.exists(), "config.toml should be created"); assert!(path.exists(), "config.toml should be created");
let text = fs::read_to_string(&path).expect("read config.toml"); let text = fs::read_to_string(&path).expect("read config.toml");
assert!( assert!(
@@ -589,11 +594,6 @@ command = "echo"
fn sync_claude_enabled_mcp_projects_to_user_config() { fn sync_claude_enabled_mcp_projects_to_user_config() {
let _guard = test_mutex().lock().expect("acquire test mutex"); let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs(); reset_test_fs();
let home = ensure_test_home();
// 模拟 Claude 已安装/已初始化:存在 ~/.claude 目录
fs::create_dir_all(home.join(".claude")).expect("create claude dir");
let mut config = MultiAppConfig::default(); let mut config = MultiAppConfig::default();
config.mcp.claude.servers.insert( config.mcp.claude.servers.insert(
@@ -993,76 +993,3 @@ fn export_sql_returns_error_for_invalid_path() {
other => panic!("expected IoContext or Io error, got {other:?}"), other => panic!("expected IoContext or Io error, got {other:?}"),
} }
} }
#[test]
fn import_sql_rejects_non_cc_switch_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let state = create_test_state().expect("create test state");
let import_path = home.join("not-cc-switch.sql");
fs::write(&import_path, "CREATE TABLE x (id INTEGER);").expect("write import sql");
let err = state
.db
.import_sql(&import_path)
.expect_err("non-cc-switch sql should be rejected");
match err {
AppError::Localized { key, .. } => {
assert_eq!(key, "backup.sql.invalid_format");
}
other => panic!("expected Localized error, got {other:?}"),
}
}
#[test]
fn import_sql_accepts_cc_switch_exported_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// Create a database with some data and export it.
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "test-provider".to_string();
manager.providers.insert(
"test-provider".to_string(),
Provider::with_id(
"test-provider".to_string(),
"Test Provider".to_string(),
json!({"env": {"ANTHROPIC_API_KEY": "test-key"}}),
None,
),
);
}
let state = create_test_state_with_config(&config).expect("create test state");
let export_path = home.join("cc-switch-export.sql");
state
.db
.export_sql(&export_path)
.expect("export should succeed");
// Reset database, then import into a fresh one.
reset_test_fs();
let state = create_test_state().expect("create test state");
state
.db
.import_sql(&export_path)
.expect("import should succeed");
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("load providers");
assert!(
providers.contains_key("test-provider"),
"imported providers should contain test-provider"
);
}
-308
View File
@@ -246,311 +246,3 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
"codex config should include the enabled server definition" "codex config should include the enabled server definition"
); );
} }
#[test]
fn enabling_codex_mcp_skips_when_codex_dir_missing() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 确认 Codex 配置目录不存在(模拟“未安装/未运行过 Codex CLI”)
assert!(
!home.join(".codex").exists(),
"~/.codex should not exist in fresh test environment"
);
let state = create_test_state().expect("create test state");
// 先插入一个未启用 Codex 的 MCP 服务器(避免 upsert 触发同步)
McpService::upsert_server(
&state,
McpServer {
id: "codex-server".to_string(),
name: "Codex Server".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("insert server without syncing");
// 启用 Codex:目录缺失时应跳过写入(不创建 ~/.codex/config.toml
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
.expect("toggle codex should succeed even when ~/.codex is missing");
assert!(
!home.join(".codex").exists(),
"~/.codex should still not exist after skipped sync"
);
}
#[test]
fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 模拟 Claude 已安装/已初始化:存在 ~/.claude 目录
fs::create_dir_all(home.join(".claude")).expect("create ~/.claude dir");
// 先创建一个启用 Claude 的 MCP 服务器
let state = support::create_test_state().expect("create test state");
McpService::upsert_server(
&state,
McpServer {
id: "echo".to_string(),
name: "echo".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: true,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("upsert should sync to Claude live config");
// 确认已写入 ~/.claude.json
let mcp_path = get_claude_mcp_path();
let text = fs::read_to_string(&mcp_path).expect("read ~/.claude.json");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse ~/.claude.json");
assert!(
v.pointer("/mcpServers/echo").is_some(),
"echo should exist in Claude live config after enabling"
);
// 再次 upsert:取消勾选 Claudeapps.claude=false),应从 Claude live 配置中移除
McpService::upsert_server(
&state,
McpServer {
id: "echo".to_string(),
name: "echo".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("upsert disabling app should remove from Claude live config");
let text = fs::read_to_string(&mcp_path).expect("read ~/.claude.json after disable");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse ~/.claude.json");
assert!(
v.pointer("/mcpServers/echo").is_none(),
"echo should be removed from Claude live config after disabling"
);
}
#[test]
fn import_mcp_from_multiple_apps_merges_enabled_flags() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 1) Claude: ~/.claude.json
let mcp_path = get_claude_mcp_path();
let claude_json = json!({
"mcpServers": {
"shared": {
"type": "stdio",
"command": "echo"
}
}
});
fs::write(
&mcp_path,
serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"),
)
.expect("seed ~/.claude.json");
// 2) Codex: ~/.codex/config.toml
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
fs::write(
codex_dir.join("config.toml"),
r#"[mcp_servers.shared]
type = "stdio"
command = "echo"
"#,
)
.expect("seed ~/.codex/config.toml");
let state = support::create_test_state().expect("create test state");
McpService::import_from_claude(&state).expect("import from claude");
McpService::import_from_codex(&state).expect("import from codex");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("shared").expect("shared server exists");
assert!(entry.apps.claude, "shared should enable Claude");
assert!(entry.apps.codex, "shared should enable Codex");
}
#[test]
fn import_mcp_from_gemini_sse_url_only_is_valid() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// Gemini MCP 位于 ~/.gemini/settings.json
let gemini_dir = home.join(".gemini");
fs::create_dir_all(&gemini_dir).expect("create gemini dir");
let settings_path = gemini_dir.join("settings.json");
// Gemini SSE:只包含 urlGemini 不使用 type 字段)
let gemini_settings = json!({
"mcpServers": {
"sse-server": {
"url": "https://example.com/sse"
}
}
});
fs::write(
&settings_path,
serde_json::to_string_pretty(&gemini_settings).expect("serialize gemini settings"),
)
.expect("seed ~/.gemini/settings.json");
let state = support::create_test_state().expect("create test state");
let changed = McpService::import_from_gemini(&state).expect("import from gemini");
assert!(changed > 0, "should import at least 1 server");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("sse-server").expect("sse-server exists");
assert!(entry.apps.gemini, "imported server should enable Gemini");
assert_eq!(
entry.server.get("type").and_then(|v| v.as_str()),
Some("sse"),
"Gemini url-only server should be normalized to type=sse in unified structure"
);
}
#[test]
fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 确认 Gemini 配置目录不存在(模拟“未安装/未运行过 Gemini CLI”)
assert!(
!home.join(".gemini").exists(),
"~/.gemini should not exist in fresh test environment"
);
let state = create_test_state().expect("create test state");
// 先插入一个未启用 Gemini 的 MCP 服务器(避免 upsert 触发同步)
McpService::upsert_server(
&state,
McpServer {
id: "gemini-server".to_string(),
name: "Gemini Server".to_string(),
server: json!({
"type": "sse",
"url": "https://example.com/sse"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("insert server without syncing");
// 启用 Gemini:目录缺失时应跳过写入(不创建 ~/.gemini/settings.json
McpService::toggle_app(&state, "gemini-server", AppType::Gemini, true)
.expect("toggle gemini should succeed even when ~/.gemini is missing");
assert!(
!home.join(".gemini").exists(),
"~/.gemini should still not exist after skipped sync"
);
}
#[test]
fn enabling_claude_mcp_skips_when_claude_config_absent() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 确认 Claude 相关目录/文件都不存在(模拟“未安装/未运行过 Claude”)
assert!(
!home.join(".claude").exists(),
"~/.claude should not exist in fresh test environment"
);
assert!(
!home.join(".claude.json").exists(),
"~/.claude.json should not exist in fresh test environment"
);
let state = create_test_state().expect("create test state");
// 先插入一个未启用 Claude 的 MCP 服务器(避免 upsert 触发同步)
McpService::upsert_server(
&state,
McpServer {
id: "claude-server".to_string(),
name: "Claude Server".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("insert server without syncing");
// 启用 Claude:配置缺失时应跳过写入(不创建 ~/.claude.json
McpService::toggle_app(&state, "claude-server", AppType::Claude, true)
.expect("toggle claude should succeed even when ~/.claude is missing");
assert!(
!home.join(".claude.json").exists(),
"~/.claude.json should still not exist after skipped sync"
);
}
+5 -11
View File
@@ -1,9 +1,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use cc_switch_lib::{ use cc_switch_lib::{update_settings, AppSettings, AppState, Database, MultiAppConfig};
update_settings, AppSettings, AppState, Database, MultiAppConfig, ProxyService,
};
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。 /// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
pub fn ensure_test_home() -> &'static Path { pub fn ensure_test_home() -> &'static Path {
@@ -49,20 +47,16 @@ pub fn test_mutex() -> &'static Mutex<()> {
} }
/// 创建测试用的 AppState,包含一个空的数据库 /// 创建测试用的 AppState,包含一个空的数据库
#[allow(dead_code)]
pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> { pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
let db = Arc::new(Database::init()?); let db = Database::init()?;
let proxy_service = ProxyService::new(db.clone()); Ok(AppState { db: Arc::new(db) })
Ok(AppState { db, proxy_service })
} }
/// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据 /// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据
#[allow(dead_code)]
pub fn create_test_state_with_config( pub fn create_test_state_with_config(
config: &MultiAppConfig, config: &MultiAppConfig,
) -> Result<AppState, Box<dyn std::error::Error>> { ) -> Result<AppState, Box<dyn std::error::Error>> {
let db = Arc::new(Database::init()?); let db = Database::init()?;
db.migrate_from_json(config)?; db.migrate_from_json(config)?;
let proxy_service = ProxyService::new(db.clone()); Ok(AppState { db: Arc::new(db) })
Ok(AppState { db, proxy_service })
} }
+117 -274
View File
@@ -1,9 +1,7 @@
import { useEffect, useMemo, useState, useRef } from "react"; import { useEffect, useMemo, useState, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { motion, AnimatePresence } from "framer-motion";
import { toast } from "sonner"; import { toast } from "sonner";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useQueryClient } from "@tanstack/react-query";
import { import {
Plus, Plus,
Settings, Settings,
@@ -25,10 +23,7 @@ import {
} from "@/lib/api"; } from "@/lib/api";
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions"; import { useProviderActions } from "@/hooks/useProviderActions";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useLastValidValue } from "@/hooks/useLastValidValue";
import { extractErrorMessage } from "@/utils/errorUtils"; import { extractErrorMessage } from "@/utils/errorUtils";
import { cn } from "@/lib/utils";
import { AppSwitcher } from "@/components/AppSwitcher"; import { AppSwitcher } from "@/components/AppSwitcher";
import { ProviderList } from "@/components/providers/ProviderList"; import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
@@ -37,32 +32,18 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
import { SettingsPage } from "@/components/settings/SettingsPage"; import { SettingsPage } from "@/components/settings/SettingsPage";
import { UpdateBadge } from "@/components/UpdateBadge"; import { UpdateBadge } from "@/components/UpdateBadge";
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner"; import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
import { ProxyToggle } from "@/components/proxy/ProxyToggle";
import UsageScriptModal from "@/components/UsageScriptModal"; import UsageScriptModal from "@/components/UsageScriptModal";
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
import PromptPanel from "@/components/prompts/PromptPanel"; import PromptPanel from "@/components/prompts/PromptPanel";
import { SkillsPage } from "@/components/skills/SkillsPage"; import { SkillsPage } from "@/components/skills/SkillsPage";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { AgentsPanel } from "@/components/agents/AgentsPanel"; import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { UniversalProviderPanel } from "@/components/universal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
type View = type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
| "providers"
| "settings"
| "prompts"
| "skills"
| "mcp"
| "agents"
| "universal";
const DRAG_BAR_HEIGHT = 28; // px
const HEADER_HEIGHT = 64; // px
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
function App() { function App() {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeApp, setActiveApp] = useState<AppId>("claude"); const [activeApp, setActiveApp] = useState<AppId>("claude");
const [currentView, setCurrentView] = useState<View>("providers"); const [currentView, setCurrentView] = useState<View>("providers");
@@ -74,36 +55,13 @@ function App() {
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]); const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false); const [showEnvBanner, setShowEnvBanner] = useState(false);
// 使用 Hook 保存最后有效值,用于动画退出期间保持内容显示
const effectiveEditingProvider = useLastValidValue(editingProvider);
const effectiveUsageProvider = useLastValidValue(usageProvider);
const promptPanelRef = useRef<any>(null); const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null); const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null); const skillsPageRef = useRef<any>(null);
const addActionButtonClass = const addActionButtonClass =
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8"; "bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
// 获取代理服务状态 const { data, isLoading, refetch } = useProvidersQuery(activeApp);
const {
isRunning: isProxyRunning,
takeoverStatus,
status: proxyStatus,
} = useProxyStatus();
// 当前应用的代理是否开启
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取)
const activeProviderId = useMemo(() => {
const target = proxyStatus?.active_targets?.find(
(t) => t.app_type === activeApp,
);
return target?.provider_id;
}, [proxyStatus?.active_targets, activeApp]);
// 获取供应商列表,当代理服务运行时自动刷新
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
isProxyRunning,
});
const providers = useMemo(() => data?.providers ?? {}, [data]); const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? ""; const currentProviderId = data?.currentProviderId ?? "";
// Skills 功能仅支持 Claude 和 Codex // Skills 功能仅支持 Claude 和 Codex
@@ -116,6 +74,7 @@ function App() {
switchProvider, switchProvider,
deleteProvider, deleteProvider,
saveUsageScript, saveUsageScript,
setProxyTarget,
} = useProviderActions(activeApp); } = useProviderActions(activeApp);
// 监听来自托盘菜单的切换事件 // 监听来自托盘菜单的切换事件
@@ -142,38 +101,6 @@ function App() {
}; };
}, [activeApp, refetch]); }, [activeApp, refetch]);
// 监听统一供应商同步事件,刷新所有应用的供应商列表
useEffect(() => {
let unsubscribe: (() => void) | undefined;
const setupListener = async () => {
try {
const { listen } = await import("@tauri-apps/api/event");
unsubscribe = await listen("universal-provider-synced", async () => {
// 统一供应商同步后刷新所有应用的供应商列表
// 使用 invalidateQueries 使所有 providers 查询失效
await queryClient.invalidateQueries({ queryKey: ["providers"] });
// 同时更新托盘菜单
try {
await providersApi.updateTrayMenu();
} catch (error) {
console.error("[App] Failed to update tray menu", error);
}
});
} catch (error) {
console.error(
"[App] Failed to subscribe universal-provider-synced event",
error,
);
}
};
setupListener();
return () => {
unsubscribe?.();
};
}, [queryClient]);
// 应用启动时检测所有应用的环境变量冲突 // 应用启动时检测所有应用的环境变量冲突
useEffect(() => { useEffect(() => {
const checkEnvOnStartup = async () => { const checkEnvOnStartup = async () => {
@@ -207,7 +134,6 @@ function App() {
if (migrated) { if (migrated) {
toast.success( toast.success(
t("migration.success", { defaultValue: "配置迁移成功" }), t("migration.success", { defaultValue: "配置迁移成功" }),
{ closeButton: true },
); );
} }
} catch (error) { } catch (error) {
@@ -251,21 +177,6 @@ function App() {
checkEnvOnSwitch(); checkEnvOnSwitch();
}, [activeApp]); }, [activeApp]);
useEffect(() => {
const handleGlobalShortcut = (event: KeyboardEvent) => {
if (event.key !== "," || !(event.metaKey || event.ctrlKey)) {
return;
}
event.preventDefault();
setCurrentView("settings");
};
window.addEventListener("keydown", handleGlobalShortcut);
return () => {
window.removeEventListener("keydown", handleGlobalShortcut);
};
}, []);
// 打开网站链接 // 打开网站链接
const handleOpenWebsite = async (url: string) => { const handleOpenWebsite = async (url: string) => {
try { try {
@@ -348,20 +259,7 @@ function App() {
// 导入配置成功后刷新 // 导入配置成功后刷新
const handleImportSuccess = async () => { const handleImportSuccess = async () => {
try { await refetch();
// 导入会影响所有应用的供应商数据:刷新所有 providers 缓存
await queryClient.invalidateQueries({
queryKey: ["providers"],
refetchType: "all",
});
await queryClient.refetchQueries({
queryKey: ["providers"],
type: "all",
});
} catch (error) {
console.error("[App] Failed to refresh providers after import", error);
await refetch();
}
try { try {
await providersApi.updateTrayMenu(); await providersApi.updateTrayMenu();
} catch (error) { } catch (error) {
@@ -370,115 +268,78 @@ function App() {
}; };
const renderContent = () => { const renderContent = () => {
const content = (() => { switch (currentView) {
switch (currentView) { case "settings":
case "settings": return (
return ( <SettingsPage
<SettingsPage open={true}
open={true} onOpenChange={() => setCurrentView("providers")}
onOpenChange={() => setCurrentView("providers")} onImportSuccess={handleImportSuccess}
onImportSuccess={handleImportSuccess} />
/> );
); case "prompts":
case "prompts": return (
return ( <PromptPanel
<PromptPanel ref={promptPanelRef}
ref={promptPanelRef} open={true}
open={true} onOpenChange={() => setCurrentView("providers")}
onOpenChange={() => setCurrentView("providers")} appId={activeApp}
appId={activeApp} />
/> );
); case "skills":
case "skills": return (
return ( <SkillsPage
<SkillsPage ref={skillsPageRef}
ref={skillsPageRef} onClose={() => setCurrentView("providers")}
onClose={() => setCurrentView("providers")} initialApp={activeApp}
initialApp={activeApp} />
/> );
); case "mcp":
case "mcp": return (
return ( <UnifiedMcpPanel
<UnifiedMcpPanel ref={mcpPanelRef}
ref={mcpPanelRef} onOpenChange={() => setCurrentView("providers")}
onOpenChange={() => setCurrentView("providers")} />
/> );
); case "agents":
case "agents": return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
return ( default:
<AgentsPanel onOpenChange={() => setCurrentView("providers")} /> return (
); <div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
case "universal": {/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
return ( <div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
<div className="mx-auto max-w-[56rem] px-5 pt-4"> <div className="space-y-4">
<UniversalProviderPanel /> <ProviderList
</div> providers={providers}
); currentProviderId={currentProviderId}
default: appId={activeApp}
return ( isLoading={isLoading}
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden"> onSwitch={switchProvider}
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */} onSetProxyTarget={setProxyTarget}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1"> onEdit={setEditingProvider}
<AnimatePresence mode="wait"> onDelete={setConfirmDelete}
<motion.div onDuplicate={handleDuplicateProvider}
key={activeApp} onConfigureUsage={setUsageProvider}
initial={{ opacity: 0 }} onOpenWebsite={handleOpenWebsite}
animate={{ opacity: 1 }} onCreate={() => setIsAddOpen(true)}
exit={{ opacity: 0 }} />
transition={{ duration: 0.15 }}
className="space-y-4"
>
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
isProxyRunning={isProxyRunning}
isProxyTakeover={
isProxyRunning && isCurrentAppTakeoverActive
}
activeProviderId={activeProviderId}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)}
/>
</motion.div>
</AnimatePresence>
</div> </div>
</div> </div>
); </div>
} );
})(); }
return (
<AnimatePresence mode="wait">
<motion.div
key={currentView}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
{content}
</motion.div>
</AnimatePresence>
);
}; };
return ( return (
<div <div
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30" className="flex min-h-screen flex-col bg-background text-foreground selection:bg-primary/30"
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }} style={{ overflowX: "hidden" }}
> >
{/* 全局拖拽区域(顶部 28px),避免上边框无法拖动 */} {/* 全局拖拽区域(顶部 4px),避免上边框无法拖动 */}
<div <div
className="fixed top-0 left-0 right-0 z-[60]" className="fixed top-0 left-0 right-0 h-4 z-[60]"
data-tauri-drag-region data-tauri-drag-region
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any} style={{ WebkitAppRegion: "drag" } as any}
/> />
{/* 环境变量警告横幅 */} {/* 环境变量警告横幅 */}
{showEnvBanner && envConflicts.length > 0 && ( {showEnvBanner && envConflicts.length > 0 && (
@@ -508,18 +369,13 @@ function App() {
)} )}
<header <header
className="fixed z-50 w-full transition-all duration-300 bg-background/80 backdrop-blur-md" className="glass-header fixed top-0 z-50 w-full py-3 transition-all duration-300"
data-tauri-drag-region data-tauri-drag-region
style={ style={{ WebkitAppRegion: "drag" } as any}
{
WebkitAppRegion: "drag",
top: DRAG_BAR_HEIGHT,
height: HEADER_HEIGHT,
} as any
}
> >
<div className="h-4 w-full" aria-hidden data-tauri-drag-region />
<div <div
className="mx-auto flex h-full max-w-[56rem] flex-wrap items-center justify-between gap-2 px-6" className="mx-auto max-w-[56rem] px-6 flex flex-wrap items-center justify-between gap-2"
data-tauri-drag-region data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any} style={{ WebkitAppRegion: "drag" } as any}
> >
@@ -535,7 +391,7 @@ function App() {
onClick={() => setCurrentView("providers")} onClick={() => setCurrentView("providers")}
className="mr-2 rounded-lg" className="mr-2 rounded-lg"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<h1 className="text-lg font-semibold"> <h1 className="text-lg font-semibold">
{currentView === "settings" && t("settings.title")} {currentView === "settings" && t("settings.title")}
@@ -544,10 +400,6 @@ function App() {
{currentView === "skills" && t("skills.title")} {currentView === "skills" && t("skills.title")}
{currentView === "mcp" && t("mcp.unifiedPanel.title")} {currentView === "mcp" && t("mcp.unifiedPanel.title")}
{currentView === "agents" && t("agents.title")} {currentView === "agents" && t("agents.title")}
{currentView === "universal" &&
t("universalProvider.title", {
defaultValue: "统一供应商",
})}
</h1> </h1>
</div> </div>
) : ( ) : (
@@ -557,12 +409,7 @@ function App() {
href="https://github.com/farion1231/cc-switch" href="https://github.com/farion1231/cc-switch"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className={cn( className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
"text-xl font-semibold transition-colors",
isProxyRunning && isCurrentAppTakeoverActive
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
)}
> >
CC Switch CC Switch
</a> </a>
@@ -573,7 +420,7 @@ function App() {
title={t("common.settings")} title={t("common.settings")}
className="hover:bg-black/5 dark:hover:bg-white/5" className="hover:bg-black/5 dark:hover:bg-white/5"
> >
<Settings className="w-4 h-4" /> <Settings className="h-4 w-4" />
</Button> </Button>
</div> </div>
<UpdateBadge onClick={() => setCurrentView("settings")} /> <UpdateBadge onClick={() => setCurrentView("settings")} />
@@ -582,27 +429,27 @@ function App() {
</div> </div>
<div <div
className="flex items-center gap-2 h-[32px]" className="flex items-center gap-2"
style={{ WebkitAppRegion: "no-drag" } as any} style={{ WebkitAppRegion: "no-drag" } as any}
> >
{currentView === "prompts" && ( {currentView === "prompts" && (
<Button <Button
size="icon" size="icon"
onClick={() => promptPanelRef.current?.openAdd()} onClick={() => promptPanelRef.current?.openAdd()}
className={`ml-auto ${addActionButtonClass}`} className={addActionButtonClass}
title={t("prompts.add")} title={t("prompts.add")}
> >
<Plus className="w-5 h-5" /> <Plus className="h-5 w-5" />
</Button> </Button>
)} )}
{currentView === "mcp" && ( {currentView === "mcp" && (
<Button <Button
size="icon" size="icon"
onClick={() => mcpPanelRef.current?.openAdd()} onClick={() => mcpPanelRef.current?.openAdd()}
className={`ml-auto ${addActionButtonClass}`} className={addActionButtonClass}
title={t("mcp.unifiedPanel.addServer")} title={t("mcp.unifiedPanel.addServer")}
> >
<Plus className="w-5 h-5" /> <Plus className="h-5 w-5" />
</Button> </Button>
)} )}
{currentView === "skills" && ( {currentView === "skills" && (
@@ -613,7 +460,7 @@ function App() {
onClick={() => skillsPageRef.current?.refresh()} onClick={() => skillsPageRef.current?.refresh()}
className="hover:bg-black/5 dark:hover:bg-white/5" className="hover:bg-black/5 dark:hover:bg-white/5"
> >
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
{t("skills.refresh")} {t("skills.refresh")}
</Button> </Button>
<Button <Button
@@ -622,45 +469,39 @@ function App() {
onClick={() => skillsPageRef.current?.openRepoManager()} onClick={() => skillsPageRef.current?.openRepoManager()}
className="hover:bg-black/5 dark:hover:bg-white/5" className="hover:bg-black/5 dark:hover:bg-white/5"
> >
<Settings className="w-4 h-4 mr-2" /> <Settings className="h-4 w-4 mr-2" />
{t("skills.repoManager")} {t("skills.repoManager")}
</Button> </Button>
</> </>
)} )}
{currentView === "providers" && ( {currentView === "providers" && (
<> <>
<ProxyToggle activeApp={activeApp} />
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} /> <AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl"> <div className="glass p-1 rounded-xl flex items-center gap-1">
<Button {hasSkillsSupport && (
variant="ghost" <Button
size="sm" variant="ghost"
onClick={() => setCurrentView("skills")} size="sm"
className={cn( onClick={() => setCurrentView("skills")}
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5", className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
"transition-all duration-200 ease-in-out overflow-hidden", title={t("skills.manage")}
hasSkillsSupport >
? "opacity-100 w-8 scale-100 px-2" <Wrench className="h-4 w-4" />
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1", </Button>
)} )}
title={t("skills.manage")}
>
<Wrench className="flex-shrink-0 w-4 h-4" />
</Button>
{/* TODO: Agents 功能开发中,暂时隐藏入口 */} {/* TODO: Agents 功能开发中,暂时隐藏入口 */}
{/* {isClaudeApp && ( {/* {isClaudeApp && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setCurrentView("agents")} onClick={() => setCurrentView("agents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5" className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents" title="Agents"
> >
<Bot className="w-4 h-4" /> <Bot className="h-4 w-4" />
</Button> </Button>
)} */} )} */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -668,7 +509,7 @@ function App() {
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5" className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")} title={t("prompts.manage")}
> >
<Book className="w-4 h-4" /> <Book className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -677,7 +518,7 @@ function App() {
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5" className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("mcp.title")} title={t("mcp.title")}
> >
<Server className="w-4 h-4" /> <Server className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -686,7 +527,7 @@ function App() {
size="icon" size="icon"
className={`ml-2 ${addActionButtonClass}`} className={`ml-2 ${addActionButtonClass}`}
> >
<Plus className="w-5 h-5" /> <Plus className="h-5 w-5" />
</Button> </Button>
</> </>
)} )}
@@ -694,8 +535,13 @@ function App() {
</div> </div>
</header> </header>
<main className="flex-1 pb-12 animate-fade-in "> <main
<div className="pb-12">{renderContent()}</div> className={`flex-1 overflow-y-auto pb-12 animate-fade-in scroll-overlay ${
currentView === "providers" ? "pt-24" : "pt-20"
}`}
style={{ overflowX: "hidden" }}
>
{renderContent()}
</main> </main>
<AddProviderDialog <AddProviderDialog
@@ -707,7 +553,7 @@ function App() {
<EditProviderDialog <EditProviderDialog
open={Boolean(editingProvider)} open={Boolean(editingProvider)}
provider={effectiveEditingProvider} provider={editingProvider}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setEditingProvider(null); setEditingProvider(null);
@@ -715,19 +561,16 @@ function App() {
}} }}
onSubmit={handleEditProvider} onSubmit={handleEditProvider}
appId={activeApp} appId={activeApp}
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
/> />
{effectiveUsageProvider && ( {usageProvider && (
<UsageScriptModal <UsageScriptModal
provider={effectiveUsageProvider} provider={usageProvider}
appId={activeApp} appId={activeApp}
isOpen={Boolean(usageProvider)} isOpen={Boolean(usageProvider)}
onClose={() => setUsageProvider(null)} onClose={() => setUsageProvider(null)}
onSave={(script) => { onSave={(script) => {
if (usageProvider) { void saveUsageScript(usageProvider, script);
void saveUsageScript(usageProvider, script);
}
}} }}
/> />
)} )}
+13 -13
View File
@@ -24,14 +24,14 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
}; };
return ( return (
<div className="inline-flex bg-muted rounded-xl p-1 gap-1"> <div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1">
<button <button
type="button" type="button"
onClick={() => handleSwitch("claude")} onClick={() => handleSwitch("claude")}
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${ className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "claude" activeApp === "claude"
? "bg-background text-foreground shadow-sm" ? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
: "text-muted-foreground hover:text-foreground hover:bg-background/50" : "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
}`} }`}
> >
<ProviderIcon <ProviderIcon
@@ -41,7 +41,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
className={ className={
activeApp === "claude" activeApp === "claude"
? "text-foreground" ? "text-foreground"
: "text-muted-foreground group-hover:text-foreground transition-colors" : "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
} }
/> />
<span>{appDisplayName.claude}</span> <span>{appDisplayName.claude}</span>
@@ -50,10 +50,10 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button <button
type="button" type="button"
onClick={() => handleSwitch("codex")} onClick={() => handleSwitch("codex")}
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${ className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "codex" activeApp === "codex"
? "bg-background text-foreground shadow-sm" ? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
: "text-muted-foreground hover:text-foreground hover:bg-background/50" : "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
}`} }`}
> >
<ProviderIcon <ProviderIcon
@@ -63,7 +63,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
className={ className={
activeApp === "codex" activeApp === "codex"
? "text-foreground" ? "text-foreground"
: "text-muted-foreground group-hover:text-foreground transition-colors" : "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
} }
/> />
<span>{appDisplayName.codex}</span> <span>{appDisplayName.codex}</span>
@@ -72,10 +72,10 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button <button
type="button" type="button"
onClick={() => handleSwitch("gemini")} onClick={() => handleSwitch("gemini")}
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${ className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "gemini" activeApp === "gemini"
? "bg-background text-foreground shadow-sm" ? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
: "text-muted-foreground hover:text-foreground hover:bg-background/50" : "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
}`} }`}
> >
<ProviderIcon <ProviderIcon
@@ -85,7 +85,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
className={ className={
activeApp === "gemini" activeApp === "gemini"
? "text-foreground" ? "text-foreground"
: "text-muted-foreground group-hover:text-foreground transition-colors" : "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
} }
/> />
<span>{appDisplayName.gemini}</span> <span>{appDisplayName.gemini}</span>
-85
View File
@@ -128,7 +128,6 @@ export function DeepLinkImportDialog() {
description: t("deeplink.mcpImportSuccessDescription", { description: t("deeplink.mcpImportSuccessDescription", {
count: summary.importedCount, count: summary.importedCount,
}), }),
closeButton: true,
}); });
} }
}; };
@@ -143,7 +142,6 @@ export function DeepLinkImportDialog() {
description: t("deeplink.importSuccessDescription", { description: t("deeplink.importSuccessDescription", {
name: request.name, name: request.name,
}), }),
closeButton: true,
}); });
} else if (result.type === "prompt") { } else if (result.type === "prompt") {
// Prompts don't use React Query, trigger a custom event for refresh // Prompts don't use React Query, trigger a custom event for refresh
@@ -156,7 +154,6 @@ export function DeepLinkImportDialog() {
description: t("deeplink.promptImportSuccessDescription", { description: t("deeplink.promptImportSuccessDescription", {
name: request.name, name: request.name,
}), }),
closeButton: true,
}); });
} else if (result.type === "mcp") { } else if (result.type === "mcp") {
await refreshMcp(result); await refreshMcp(result);
@@ -174,7 +171,6 @@ export function DeepLinkImportDialog() {
description: t("deeplink.skillImportSuccessDescription", { description: t("deeplink.skillImportSuccessDescription", {
repo: request.repo, repo: request.repo,
}), }),
closeButton: true,
}); });
} }
} else if (isMcpImportResult(result)) { } else if (isMcpImportResult(result)) {
@@ -189,7 +185,6 @@ export function DeepLinkImportDialog() {
description: t("deeplink.importSuccessDescription", { description: t("deeplink.importSuccessDescription", {
name: request.name, name: request.name,
}), }),
closeButton: true,
}); });
} }
@@ -610,86 +605,6 @@ export function DeepLinkImportDialog() {
</div> </div>
)} )}
{/* Usage Script Configuration (v3.9+) */}
{request.usageScript && (
<div className="space-y-3 pt-2 border-t border-border-default">
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageScript", {
defaultValue: "用量查询",
})}
</div>
<div className="col-span-2 text-sm">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium ${
request.usageEnabled !== false
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
{request.usageEnabled !== false
? t("deeplink.usageScriptEnabled", {
defaultValue: "已启用",
})
: t("deeplink.usageScriptDisabled", {
defaultValue: "未启用",
})}
</span>
</div>
</div>
{/* Usage API Key (if different from provider) */}
{request.usageApiKey &&
request.usageApiKey !== request.apiKey && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageApiKey", {
defaultValue: "用量 API Key",
})}
</div>
<div className="col-span-2 text-sm font-mono text-muted-foreground">
{request.usageApiKey.length > 4
? `${request.usageApiKey.substring(0, 4)}${"*".repeat(12)}`
: "****"}
</div>
</div>
)}
{/* Usage Base URL (if different from provider) */}
{request.usageBaseUrl &&
request.usageBaseUrl !== request.endpoint && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageBaseUrl", {
defaultValue: "用量查询地址",
})}
</div>
<div className="col-span-2 text-sm break-all">
{request.usageBaseUrl}
</div>
</div>
)}
{/* Auto Query Interval */}
{request.usageAutoInterval &&
request.usageAutoInterval > 0 && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageAutoInterval", {
defaultValue: "自动查询",
})}
</div>
<div className="col-span-2 text-sm">
{t("deeplink.usageAutoIntervalValue", {
defaultValue: "每 {{minutes}} 分钟",
minutes: request.usageAutoInterval,
})}
</div>
</div>
)}
</div>
)}
{/* Warning */} {/* Warning */}
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200"> <div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
{t("deeplink.warning")} {t("deeplink.warning")}
+1 -3
View File
@@ -234,9 +234,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
try { try {
const formatted = formatJSON(currentValue); const formatted = formatJSON(currentValue);
onChange(formatted); onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }), { toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
closeButton: true,
});
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);

Some files were not shown because too many files have changed in this diff Show More