Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fadd8bcdf | |||
| 70e7feb73b | |||
| 60fdc38e6b | |||
| 24a36df140 | |||
| 2c4c2ce83d | |||
| 0020889a8f | |||
| 671cda60d9 | |||
| efa653809b | |||
| 5aa35906d8 | |||
| 4777c99b38 | |||
| 8912216cb2 | |||
| 32149b1eeb | |||
| 8979d964d6 | |||
| 37396b9c70 | |||
| 2c35372ca0 | |||
| 63bb673bf2 | |||
| 058f86aff3 | |||
| 188c94f2e3 | |||
| c049c5f2bb | |||
| c71b030662 | |||
| 2105f2d05b | |||
| 0a9de282a3 | |||
| 0e085aa01a | |||
| 2c95f697cd | |||
| ba97a5f373 | |||
| 86e802bd4b | |||
| 6460c1d5dd | |||
| 6b73e55bfe | |||
| 47aa4c6bee | |||
| 4dc59dff21 | |||
| c8750f5550 | |||
| 22460de976 | |||
| e69c1bd8aa | |||
| a17fa8098b | |||
| ff03ca1e63 | |||
| cce6ae86a5 | |||
| 5376ea042b | |||
| d0431b66ae | |||
| eaddcbedd7 | |||
| 83a5597756 | |||
| bcfc22514c | |||
| 443e23c77e | |||
| f26a01137d | |||
| 1be9c56ec5 | |||
| 2651b65b10 | |||
| 7fdaeacb5b | |||
| 9716eb797f | |||
| 91deaf094e | |||
| 3a548152a9 | |||
| a8f7cda167 | |||
| 8fe5c1041a | |||
| a24753f074 | |||
| 079ee687a8 | |||
| e08c67b88f | |||
| bb2756d0fb | |||
| c87bb43aaa | |||
| 8f58c08d0d | |||
| e6f18ba801 | |||
| 7d495aa772 | |||
| db8180aa31 | |||
| 1586451862 | |||
| bf570b6d2a | |||
| 26c3f05daf | |||
| d303706d51 | |||
| 97495d1550 | |||
| a1537807eb | |||
| f047960a33 | |||
| ace9b38cee | |||
| b67cdbb18c | |||
| c4f1e90893 | |||
| ddbff070d5 | |||
| ca7cb398c2 | |||
| 2fb3b5405a | |||
| ec649e7718 | |||
| 3da5525c79 | |||
| 44ca688253 | |||
| 5fe5ed98be | |||
| b2a9e91d70 | |||
| 4a1a997935 | |||
| c4535c894a | |||
| 64e0cabaa7 | |||
| 8ecb41d25e | |||
| 3e8f84481d | |||
| ba59483b33 | |||
| b6ff721d67 | |||
| 1706c9a26f | |||
| 5bce6d6020 | |||
| 6bdbb4df23 | |||
| 256903ee70 | |||
| f42f73ebb0 | |||
| ec6e113cf2 | |||
| ae837ade02 | |||
| a8fd1f0dd2 | |||
| fa33330b3b | |||
| 18207771ad | |||
| 0cd7d0756c | |||
| bca0997afa | |||
| 0ef8a4153f | |||
| 1b73b26c0e | |||
| 3d514c8250 | |||
| 18e973b920 | |||
| ec20ff4d8c | |||
| e6654bd7f9 | |||
| d4f33224c6 | |||
| 510a013449 | |||
| 1e5bab1cb6 | |||
| 6dcf268317 | |||
| 5efc0cdd5e | |||
| e081c7560c | |||
| 007813e09e | |||
| 9196d07925 | |||
| 1172209f49 | |||
| c49cfa5ac5 | |||
| bfe9bb6a0c | |||
| 5a5ca2a989 | |||
| 5d424b1383 | |||
| c42a0dccaf | |||
| ebe2a665ae | |||
| 1926af4988 | |||
| 1e3a978ecb | |||
| 395783e22a | |||
| 038b74b844 | |||
| f1e5afdae2 | |||
| c9ea13a7ce | |||
| 404ab5a1ae | |||
| 9a8f12a490 | |||
| 6a7c2df2d2 | |||
| 735b3b7d39 | |||
| cbc23764c0 | |||
| 2a541cfda4 | |||
| 5cc864c6aa | |||
| 3cdce2eced | |||
| 8876d67807 | |||
| 493b154a9d | |||
| 56b40bdad2 | |||
| 41267135f5 | |||
| 1fb2d5ed44 | |||
| 622a24ded4 | |||
| 6713368657 | |||
| b1103c8a59 | |||
| bf9228093b | |||
| 420b5d4389 | |||
| b1e4c37f9c | |||
| 74a2e9c08b | |||
| 169db5b6d8 | |||
| 3230b0e094 | |||
| 2347ac0ee0 | |||
| 58c5468bf6 | |||
| 98084d61aa | |||
| a627e1bb50 | |||
| 04a588694b | |||
| f5f7dfed8c | |||
| 5888c56f2a | |||
| c5cadb73bc | |||
| 17948ee031 | |||
| 2643595012 | |||
| 6ac4d1652c | |||
| 3bf37cf0ff | |||
| 526c7406fa | |||
| 9db85dd4dc | |||
| 0f333d9e5b | |||
| ba875552a6 | |||
| 75e7f9d731 | |||
| 7e6074a9a9 |
@@ -53,7 +53,10 @@ jobs:
|
||||
wget \
|
||||
file \
|
||||
patchelf \
|
||||
libssl-dev
|
||||
libssl-dev \
|
||||
rpm \
|
||||
flatpak \
|
||||
flatpak-builder
|
||||
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libgtk-3-dev \
|
||||
@@ -153,7 +156,7 @@ jobs:
|
||||
|
||||
- name: Build Tauri App (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: pnpm tauri build
|
||||
run: pnpm tauri build -- --bundles appimage,deb,rpm
|
||||
|
||||
- name: Prepare macOS Assets
|
||||
if: runner.os == 'macOS'
|
||||
@@ -271,6 +274,28 @@ jobs:
|
||||
else
|
||||
echo "No .deb found (optional)"
|
||||
fi
|
||||
# 额外上传 .rpm(用于 Fedora/RHEL/openSUSE 等,不参与 Updater)
|
||||
RPM=$(find src-tauri/target/release/bundle -name "*.rpm" | head -1 || true)
|
||||
if [ -n "$RPM" ]; then
|
||||
NEW_RPM="CC-Switch-${VERSION}-Linux.rpm"
|
||||
cp "$RPM" "release-assets/$NEW_RPM"
|
||||
echo "RPM package copied: $NEW_RPM"
|
||||
else
|
||||
echo "No .rpm found (optional)"
|
||||
fi
|
||||
# 额外上传 .flatpak(跨发行版;不参与 Updater)
|
||||
if [ -n "$DEB" ]; then
|
||||
echo "Building Flatpak bundle from .deb..."
|
||||
cp "$DEB" flatpak/cc-switch.deb
|
||||
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.gnome.Platform//46 org.gnome.Sdk//46
|
||||
flatpak-builder --force-clean --user --disable-cache --repo flatpak-repo flatpak-build flatpak/com.ccswitch.desktop.yml
|
||||
NEW_FLATPAK="CC-Switch-${VERSION}-Linux.flatpak"
|
||||
flatpak build-bundle --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo flatpak-repo "release-assets/$NEW_FLATPAK" com.ccswitch.desktop
|
||||
echo "Flatpak bundle created: $NEW_FLATPAK"
|
||||
else
|
||||
echo "Skip Flatpak build: no .deb found"
|
||||
fi
|
||||
|
||||
- name: List prepared assets
|
||||
shell: bash
|
||||
@@ -299,7 +324,7 @@ jobs:
|
||||
|
||||
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`(Homebrew)
|
||||
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
|
||||
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`(AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`(Debian/Ubuntu)
|
||||
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`(AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`(Debian/Ubuntu)或 `CC-Switch-${{ github.ref_name }}-Linux.rpm`(Fedora/RHEL/openSUSE)或 `CC-Switch-${{ github.ref_name }}-Linux.flatpak`(Flatpak)
|
||||
|
||||
---
|
||||
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
||||
|
||||
@@ -17,3 +17,9 @@ GEMINI.md
|
||||
/.idea
|
||||
/.vscode
|
||||
vitest-report.json
|
||||
nul
|
||||
|
||||
# Flatpak build artifacts
|
||||
flatpak/cc-switch.deb
|
||||
flatpak-build/
|
||||
flatpak-repo/
|
||||
|
||||
@@ -1 +1 @@
|
||||
v22.4.1
|
||||
22.12.0
|
||||
@@ -5,6 +5,158 @@ All notable changes to CC Switch will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.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
|
||||
|
||||
### Major Updates
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/trending/typescript)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -12,33 +11,30 @@
|
||||
|
||||
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>
|
||||
|
||||
## ❤️Sponsor
|
||||
|
||||

|
||||
[](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)!
|
||||
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>
|
||||
<tr>
|
||||
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
||||
<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>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>
|
||||
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></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>
|
||||
<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>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>
|
||||
</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>
|
||||
|
||||
</table>
|
||||
@@ -51,7 +47,7 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
|
||||
|
||||
## Features
|
||||
|
||||
### Current Version: v3.8.0 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
|
||||
### Current Version: v3.8.3 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
|
||||
|
||||
**v3.8.0 Major Update (2025-11-28)**
|
||||
|
||||
@@ -190,7 +186,19 @@ paru -S cc-switch-bin
|
||||
|
||||
### Linux Users
|
||||
|
||||
Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{version}-Linux.AppImage` from the [Releases](../../releases) page.
|
||||
Download the latest Linux build from the [Releases](../../releases) page:
|
||||
|
||||
- `CC-Switch-v{version}-Linux.deb` (Debian/Ubuntu)
|
||||
- `CC-Switch-v{version}-Linux.rpm` (Fedora/RHEL/openSUSE)
|
||||
- `CC-Switch-v{version}-Linux.AppImage` (Universal)
|
||||
- `CC-Switch-v{version}-Linux.flatpak` (Flatpak)
|
||||
|
||||
Flatpak install & run:
|
||||
|
||||
```bash
|
||||
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
|
||||
flatpak run com.ccswitch.desktop
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/trending/typescript)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -12,33 +11,30 @@
|
||||
|
||||
[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>
|
||||
|
||||
## ❤️スポンサー
|
||||
|
||||

|
||||
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||
|
||||
本プロジェクトは 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>
|
||||
<tr>
|
||||
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
||||
<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>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
|
||||
<td>ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。<a href="https://www.shandianshuo.cn">Mac/Win 版を無料ダウンロード</a></td>
|
||||
<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>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</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>
|
||||
|
||||
</table>
|
||||
@@ -46,12 +42,12 @@ GLM CODING PLAN は AI コーディング向けのサブスクリプションで
|
||||
## スクリーンショット
|
||||
|
||||
| メイン画面 | プロバイダ追加 |
|
||||
| :-------------------------------------------: | :---------------------------------------------: |
|
||||
|  |  |
|
||||
| :-------------------------------------------: | :----------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
## 特長
|
||||
|
||||
### 現在のバージョン:v3.8.0 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
|
||||
### 現在のバージョン:v3.8.3 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
|
||||
|
||||
**v3.8.0 メジャーアップデート (2025-11-28)**
|
||||
|
||||
@@ -190,7 +186,19 @@ paru -S cc-switch-bin
|
||||
|
||||
### Linux ユーザー
|
||||
|
||||
[Releases](../../releases) から最新版の `CC-Switch-v{version}-Linux.deb` または `CC-Switch-v{version}-Linux.AppImage` をダウンロード。
|
||||
[Releases](../../releases) から最新版の Linux ビルドをダウンロード:
|
||||
|
||||
- `CC-Switch-v{version}-Linux.deb`(Debian/Ubuntu)
|
||||
- `CC-Switch-v{version}-Linux.rpm`(Fedora/RHEL/openSUSE)
|
||||
- `CC-Switch-v{version}-Linux.AppImage`(汎用)
|
||||
- `CC-Switch-v{version}-Linux.flatpak`(Flatpak)
|
||||
|
||||
Flatpak のインストールと起動:
|
||||
|
||||
```bash
|
||||
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
|
||||
flatpak run com.ccswitch.desktop
|
||||
```
|
||||
|
||||
## クイックスタート
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/trending/typescript)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -12,35 +11,32 @@
|
||||
|
||||
[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>
|
||||
|
||||
## ❤️赞助商
|
||||
|
||||

|
||||
[](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)购买可以享受九折优惠。
|
||||
感谢智谱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>
|
||||
<tr>
|
||||
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
||||
<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>感谢 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>
|
||||
<td width="180"><img src="assets/partners/logos/sds-zh.png" alt="ShanDianShuo" width="150"></td>
|
||||
<td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍,AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td>
|
||||
<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>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
|
||||
</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>
|
||||
|
||||
## 界面预览
|
||||
@@ -51,7 +47,7 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 当前版本:v3.8.0 | [完整更新日志](CHANGELOG.md)
|
||||
### 当前版本:v3.8.3 | [完整更新日志](CHANGELOG.md)
|
||||
|
||||
**v3.8.0 重大更新(2025-11-28)**
|
||||
|
||||
@@ -190,7 +186,19 @@ paru -S cc-switch-bin
|
||||
|
||||
### Linux 用户
|
||||
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包。
|
||||
从 [Releases](../../releases) 页面下载最新版本的 Linux 安装包:
|
||||
|
||||
- `CC-Switch-v{版本号}-Linux.deb`(Debian/Ubuntu)
|
||||
- `CC-Switch-v{版本号}-Linux.rpm`(Fedora/RHEL/openSUSE)
|
||||
- `CC-Switch-v{版本号}-Linux.AppImage`(通用)
|
||||
- `CC-Switch-v{版本号}-Linux.flatpak`(Flatpak)
|
||||
|
||||
Flatpak 安装与运行:
|
||||
|
||||
```bash
|
||||
flatpak install --user ./CC-Switch-v{版本号}-Linux.flatpak
|
||||
flatpak run com.ccswitch.desktop
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# CC Switch 国际化功能说明
|
||||
|
||||
## 已完成的工作
|
||||
|
||||
1. **安装依赖**:添加了 `react-i18next` 和 `i18next` 包
|
||||
2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件
|
||||
3. **翻译文件**:创建了英文、中文、日文翻译文件
|
||||
4. **组件更新**:替换了主要组件中的硬编码文案
|
||||
5. **语言切换器**:添加了语言切换按钮
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── i18n/
|
||||
│ ├── index.ts # 国际化配置文件
|
||||
│ └── locales/
|
||||
│ ├── en.json # 英文翻译
|
||||
│ ├── ja.json # 日文翻译
|
||||
│ └── zh.json # 中文翻译
|
||||
├── components/
|
||||
│ └── LanguageSwitcher.tsx # 语言切换组件
|
||||
└── main.tsx # 导入国际化配置
|
||||
```
|
||||
|
||||
## 默认语言设置
|
||||
|
||||
- **默认语言**:中文 (zh)(无首选时根据浏览器/系统语言选择 zh/en/ja)
|
||||
- **回退语言**:英文 (en)
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 在组件中导入 `useTranslation`:
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function MyComponent() {
|
||||
const { t } = useTranslation();
|
||||
return <div>{t('common.save')}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
2. 切换语言:
|
||||
```tsx
|
||||
const { i18n } = useTranslation();
|
||||
i18n.changeLanguage('zh'); // 切换到中文
|
||||
```
|
||||
|
||||
## 翻译键结构
|
||||
|
||||
- `common.*` - 通用文案(保存、取消、设置等)
|
||||
- `header.*` - 头部相关文案
|
||||
- `provider.*` - 供应商相关文案
|
||||
- `notifications.*` - 通知消息
|
||||
- `settings.*` - 设置页面文案
|
||||
- `apps.*` - 应用名称
|
||||
- `console.*` - 控制台日志信息
|
||||
|
||||
## 测试功能
|
||||
|
||||
应用已添加了语言切换按钮,支持中文、英文、日文三种语言切换,验证国际化功能是否正常工作。
|
||||
|
||||
## 已更新的组件
|
||||
|
||||
- ✅ App.tsx - 主应用组件
|
||||
- ✅ ConfirmDialog.tsx - 确认对话框
|
||||
- ✅ AddProviderModal.tsx - 添加供应商弹窗
|
||||
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
|
||||
- ✅ ProviderList.tsx - 供应商列表
|
||||
- ✅ LanguageSwitcher.tsx - 语言切换器
|
||||
- ✅ settings/SettingsDialog.tsx - 设置对话框
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 所有新的文案都应该添加到翻译文件中,而不是硬编码
|
||||
2. 翻译键名应该有意义且结构化
|
||||
3. 可以通过修改 `src/i18n/index.ts` 中的 `lng` 配置来更改默认语言
|
||||
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 225 KiB |
@@ -4,7 +4,7 @@
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"config": "tailwind.config.cjs",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# 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 配置备份
|
||||
@@ -0,0 +1,63 @@
|
||||
# Flatpak Build Guide
|
||||
|
||||
This directory contains the Flatpak manifest (`com.ccswitch.desktop`) for CC Switch, used to convert the generated `.deb` artifact into an installable `.flatpak` package via CI or local builds.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `flatpak`
|
||||
- `flatpak-builder`
|
||||
- Flathub remote (for installing `org.gnome.Platform//46` runtime)
|
||||
|
||||
For Ubuntu/Debian:
|
||||
|
||||
```bash
|
||||
sudo apt install flatpak flatpak-builder
|
||||
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.gnome.Platform//46 org.gnome.Sdk//46
|
||||
```
|
||||
|
||||
## Local Build (Generate .flatpak from .deb)
|
||||
|
||||
1) Build the deb on Linux first:
|
||||
|
||||
```bash
|
||||
pnpm tauri build -- --bundles deb
|
||||
```
|
||||
|
||||
2) Copy the generated deb to this directory:
|
||||
|
||||
```bash
|
||||
cp "$(find src-tauri/target/release/bundle -name '*.deb' | head -n 1)" flatpak/cc-switch.deb
|
||||
```
|
||||
|
||||
3) Build the local Flatpak repository and export the `.flatpak`:
|
||||
|
||||
```bash
|
||||
flatpak-builder --force-clean --user --disable-cache --repo flatpak-repo flatpak-build flatpak/com.ccswitch.desktop.yml
|
||||
flatpak build-bundle --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo flatpak-repo CC-Switch-Linux.flatpak com.ccswitch.desktop
|
||||
```
|
||||
|
||||
4) Install and run:
|
||||
|
||||
```bash
|
||||
flatpak install --user ./CC-Switch-Linux.flatpak
|
||||
flatpak run com.ccswitch.desktop
|
||||
```
|
||||
|
||||
## Permissions Note
|
||||
|
||||
The current manifest uses `--filesystem=home` by default for "download and run" convenience, allowing the app to directly read/write CLI configuration files and app data on the host (and supporting the "directory override" feature).
|
||||
|
||||
If you prefer minimal permissions (e.g., for Flathub submission or security concerns), you can replace `--filesystem=home` in `flatpak/com.ccswitch.desktop.yml` with more precise grants:
|
||||
|
||||
```yaml
|
||||
- --filesystem=~/.cc-switch:create
|
||||
- --filesystem=~/.claude:create
|
||||
- --filesystem=~/.claude.json
|
||||
- --filesystem=~/.codex:create
|
||||
- --filesystem=~/.gemini:create
|
||||
```
|
||||
|
||||
Note: Flatpak's `:create` modifier only works with directories, not files. Therefore, `~/.claude.json` cannot use `:create`. If this file doesn't exist on the user's machine, the app may not be able to create it with restricted permissions. Users should either run Claude Code once to generate it, or manually create an empty JSON file (content: `{}`).
|
||||
|
||||
If you plan to publish on Flathub or want stricter permission control, adjust the `finish-args` in `flatpak/com.ccswitch.desktop.yml` accordingly.
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=CC Switch
|
||||
Comment=All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||
Exec=cc-switch
|
||||
Icon=com.ccswitch.desktop
|
||||
Terminal=false
|
||||
Categories=Utility;Development;
|
||||
StartupNotify=true
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.ccswitch.desktop</id>
|
||||
<name>CC Switch</name>
|
||||
<summary>All-in-One Assistant for Claude Code, Codex & Gemini CLI</summary>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
|
||||
<description>
|
||||
<p>CC Switch is a cross-platform desktop app for managing and switching provider configurations for Claude Code, Codex, and Gemini CLI.</p>
|
||||
<ul>
|
||||
<li>Manage multiple provider configurations and endpoints</li>
|
||||
<li>One-click switch and sync to client live configurations</li>
|
||||
<li>MCP servers and Prompt/Skills management</li>
|
||||
</ul>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">com.ccswitch.desktop.desktop</launchable>
|
||||
<provides>
|
||||
<binary>cc-switch</binary>
|
||||
</provides>
|
||||
|
||||
<url type="homepage">https://github.com/farion1231/cc-switch</url>
|
||||
<url type="bugtracker">https://github.com/farion1231/cc-switch/issues</url>
|
||||
</component>
|
||||
@@ -0,0 +1,45 @@
|
||||
id: com.ccswitch.desktop
|
||||
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '46'
|
||||
sdk: org.gnome.Sdk
|
||||
|
||||
command: cc-switch
|
||||
|
||||
finish-args:
|
||||
- --share=ipc
|
||||
- --share=network
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
- --device=dri
|
||||
# Tray icon permissions (required by Tauri tray-icon)
|
||||
- --talk-name=org.kde.StatusNotifierWatcher
|
||||
- --filesystem=xdg-run/tray-icon:create
|
||||
# GitHub Releases scenario: Users download and install manually.
|
||||
# For "download and run" convenience (needs read/write access to ~/.cc-switch, ~/.claude, ~/.claude.json, ~/.codex, ~/.gemini,
|
||||
# and supports custom directory overrides), we grant full Home access by default.
|
||||
# If you plan to publish on Flathub or prefer minimal permissions, replace this with more precise directory grants (see flatpak/README.md).
|
||||
- --filesystem=home
|
||||
|
||||
modules:
|
||||
- name: cc-switch
|
||||
buildsystem: simple
|
||||
sources:
|
||||
# Placed in flatpak/ directory by CI or local build script
|
||||
- type: file
|
||||
path: cc-switch.deb
|
||||
- type: file
|
||||
path: com.ccswitch.desktop.desktop
|
||||
- type: file
|
||||
path: com.ccswitch.desktop.metainfo.xml
|
||||
- type: file
|
||||
path: ../src-tauri/icons/128x128.png
|
||||
build-commands:
|
||||
- ar -x *.deb
|
||||
- tar -xf data.tar.*
|
||||
- cp -a usr/* /app/
|
||||
# Use our own desktop/metainfo/icon to align with Flatpak app id
|
||||
- rm -f /app/share/applications/*.desktop
|
||||
- install -Dm644 com.ccswitch.desktop.desktop /app/share/applications/com.ccswitch.desktop.desktop
|
||||
- install -Dm644 com.ccswitch.desktop.metainfo.xml /app/share/metainfo/com.ccswitch.desktop.metainfo.xml
|
||||
- install -Dm644 128x128.png /app/share/icons/hicolor/128x128/apps/com.ccswitch.desktop.png
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.8.0",
|
||||
"version": "3.9.0-3",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm tauri dev",
|
||||
"build": "pnpm tauri build",
|
||||
@@ -26,16 +27,17 @@
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"code-inspector-plugin": "^1.3.3",
|
||||
"cross-fetch": "^4.1.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"msw": "^2.11.6",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^2.0.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17"
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^7.3.0",
|
||||
"vitest": "^2.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
@@ -50,6 +52,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lobehub/icons-static-svg": "^1.73.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -67,7 +70,9 @@
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"framer-motion": "^12.23.25",
|
||||
"i18next": "^25.5.2",
|
||||
"jsonc-parser": "^3.2.1",
|
||||
"lucide-react": "^0.542.0",
|
||||
@@ -75,10 +80,11 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"react-i18next": "^16.0.0",
|
||||
"recharts": "^3.5.1",
|
||||
"smol-toml": "^1.4.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
}
|
||||
@@ -257,6 +257,28 @@ dependencies = [
|
||||
"windows-sys 0.61.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
||||
dependencies = [
|
||||
"async-stream-impl",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream-impl"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
@@ -320,6 +342,61 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backtrace"
|
||||
version = "0.3.76"
|
||||
@@ -509,6 +586,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.10.1"
|
||||
@@ -618,14 +701,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.8.0"
|
||||
version = "3.9.0-3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"auto-launch",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"futures",
|
||||
"hyper",
|
||||
"indexmap 2.11.4",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
@@ -635,6 +722,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"rquickjs",
|
||||
"rusqlite",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -650,11 +738,14 @@ dependencies = [
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"toml 0.8.2",
|
||||
"toml_edit 0.22.27",
|
||||
"tower 0.4.13",
|
||||
"tower-http 0.5.2",
|
||||
"url",
|
||||
"uuid",
|
||||
"winreg 0.52.0",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
@@ -783,6 +874,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -806,9 +907,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -819,7 +920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1204,6 +1305,15 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.0"
|
||||
@@ -1369,6 +1479,15 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -1376,7 +1495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1390,6 +1509,12 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -1833,6 +1958,25 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.11.4",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1957,6 +2101,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.7.0"
|
||||
@@ -1967,9 +2117,11 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
@@ -1995,6 +2147,22 @@ dependencies = [
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.17"
|
||||
@@ -2014,9 +2182,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2050,7 +2220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2166,6 +2336,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -2541,6 +2724,12 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.6"
|
||||
@@ -2589,6 +2778,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "muda"
|
||||
version = "0.17.1"
|
||||
@@ -2604,12 +2803,29 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.1",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.17",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -3027,6 +3243,50 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -3332,6 +3592,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@@ -3463,6 +3736,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
@@ -3770,16 +4052,21 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -3790,10 +4077,11 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower 0.5.2",
|
||||
"tower-http 0.6.6",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
@@ -4037,6 +4325,15 @@ dependencies = [
|
||||
"sdd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -4112,6 +4409,29 @@ version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.24.0"
|
||||
@@ -4206,6 +4526,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -4440,7 +4771,7 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
"core-graphics",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
@@ -4581,6 +4912,27 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -4602,7 +4954,7 @@ checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"block2 0.6.1",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dispatch",
|
||||
@@ -4686,6 +5038,7 @@ dependencies = [
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http-range",
|
||||
"image",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -4754,7 +5107,7 @@ dependencies = [
|
||||
"ico",
|
||||
"json-patch",
|
||||
"plist",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
@@ -5241,6 +5594,16 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -5378,6 +5741,17 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||
dependencies = [
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.2"
|
||||
@@ -5391,6 +5765,23 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5406,7 +5797,7 @@ dependencies = [
|
||||
"http-body",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
@@ -5429,6 +5820,7 @@ version = "0.1.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -5470,7 +5862,7 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation 0.3.1",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.17",
|
||||
"windows-sys 0.59.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.8.0"
|
||||
version = "3.9.0-3"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -26,7 +26,7 @@ serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] }
|
||||
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset", "image-png"] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-process = "2"
|
||||
@@ -37,12 +37,18 @@ tauri-plugin-deep-link = "2"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||
futures = "0.3"
|
||||
async-stream = "0.3"
|
||||
bytes = "1.5"
|
||||
axum = "0.7"
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
hyper = { version = "1.0", features = ["full"] }
|
||||
regex = "1.10"
|
||||
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
zip = "2.2"
|
||||
serde_yaml = "0.9"
|
||||
@@ -53,6 +59,8 @@ once_cell = "1.21.3"
|
||||
base64 = "0.22"
|
||||
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
rust_decimal = "1.33"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -55,6 +55,110 @@ impl McpApps {
|
||||
}
|
||||
}
|
||||
|
||||
/// Skill 应用启用状态(标记 Skill 应用到哪些客户端)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct SkillApps {
|
||||
#[serde(default)]
|
||||
pub claude: bool,
|
||||
#[serde(default)]
|
||||
pub codex: bool,
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
}
|
||||
|
||||
impl SkillApps {
|
||||
/// 检查指定应用是否启用
|
||||
pub fn is_enabled_for(&self, app: &AppType) -> bool {
|
||||
match app {
|
||||
AppType::Claude => self.claude,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置指定应用的启用状态
|
||||
pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
|
||||
match app {
|
||||
AppType::Claude => self.claude = enabled,
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有启用的应用列表
|
||||
pub fn enabled_apps(&self) -> Vec<AppType> {
|
||||
let mut apps = Vec::new();
|
||||
if self.claude {
|
||||
apps.push(AppType::Claude);
|
||||
}
|
||||
if self.codex {
|
||||
apps.push(AppType::Codex);
|
||||
}
|
||||
if self.gemini {
|
||||
apps.push(AppType::Gemini);
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini
|
||||
}
|
||||
|
||||
/// 仅启用指定应用(其他应用设为禁用)
|
||||
pub fn only(app: &AppType) -> Self {
|
||||
let mut apps = Self::default();
|
||||
apps.set_enabled_for(app, true);
|
||||
apps
|
||||
}
|
||||
}
|
||||
|
||||
/// 已安装的 Skill(v3.10.0+ 统一结构)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstalledSkill {
|
||||
/// 唯一标识符(格式:"owner/repo:directory" 或 "local:directory")
|
||||
pub id: String,
|
||||
/// 显示名称
|
||||
pub name: String,
|
||||
/// 描述
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// 安装目录名(在 SSOT 目录中的子目录名)
|
||||
pub directory: String,
|
||||
/// 仓库所有者(GitHub 用户/组织)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repo_owner: Option<String>,
|
||||
/// 仓库名称
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repo_name: Option<String>,
|
||||
/// 仓库分支
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repo_branch: Option<String>,
|
||||
/// README URL
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub readme_url: Option<String>,
|
||||
/// 应用启用状态
|
||||
pub apps: SkillApps,
|
||||
/// 安装时间(Unix 时间戳)
|
||||
pub installed_at: i64,
|
||||
}
|
||||
|
||||
/// 未管理的 Skill(在应用目录中发现但未被 CC Switch 管理)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnmanagedSkill {
|
||||
/// 目录名
|
||||
pub directory: String,
|
||||
/// 显示名称(从 SKILL.md 解析)
|
||||
pub name: String,
|
||||
/// 描述
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// 在哪些应用目录中发现(如 ["claude", "codex"])
|
||||
pub found_in: Vec<String>,
|
||||
}
|
||||
|
||||
/// MCP 服务器定义(v3.7.0 统一结构)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServer {
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
use crate::error::AppError;
|
||||
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 实例
|
||||
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
|
||||
let app_name = "CC Switch";
|
||||
let app_path =
|
||||
let exe_path =
|
||||
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 消除平台差异
|
||||
// Windows/Linux: new() 接受 3 参数
|
||||
// macOS: new() 接受 4 参数(含 hidden 参数)
|
||||
// Builder 模式自动处理这些差异
|
||||
// macOS: 使用 AppleScript 方式(默认),需要 .app bundle 路径
|
||||
// Windows/Linux: 使用注册表/XDG autostart
|
||||
let auto_launch = AutoLaunchBuilder::new()
|
||||
.set_app_name(app_name)
|
||||
.set_app_path(&app_path.to_string_lossy())
|
||||
@@ -47,3 +67,50 @@ pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
|
||||
.is_enabled()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,64 @@ use std::path::{Path, PathBuf};
|
||||
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpStatus {
|
||||
@@ -105,6 +163,55 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
||||
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> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||
@@ -290,6 +397,9 @@ pub fn set_mcp_servers_map(
|
||||
obj.remove("homepage");
|
||||
obj.remove("docs");
|
||||
|
||||
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
|
||||
wrap_command_for_windows(&mut obj);
|
||||
|
||||
out.insert(id.clone(), Value::Object(obj));
|
||||
}
|
||||
|
||||
@@ -303,3 +413,136 @@ pub fn set_mcp_servers_map(
|
||||
write_json_value(&path, &root)?;
|
||||
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"]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use tauri_plugin_opener::OpenerExt;
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config;
|
||||
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||
use crate::settings;
|
||||
|
||||
/// 获取 Claude Code 配置状态
|
||||
#[tauri::command]
|
||||
@@ -16,6 +17,18 @@ pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
fn invalid_json_format_error(error: serde_json::Error) -> String {
|
||||
let lang = settings::get_settings()
|
||||
.language
|
||||
.unwrap_or_else(|| "zh".to_string());
|
||||
|
||||
match lang.as_str() {
|
||||
"en" => format!("Invalid JSON format: {error}"),
|
||||
"ja" => format!("JSON形式が無効です: {error}"),
|
||||
_ => format!("无效的 JSON 格式: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||
@@ -155,8 +168,7 @@ pub async fn set_claude_common_config_snippet(
|
||||
) -> Result<(), String> {
|
||||
// 验证是否为有效的 JSON(如果不为空)
|
||||
if !snippet.trim().is_empty() {
|
||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
|
||||
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
|
||||
}
|
||||
|
||||
let value = if snippet.trim().is_empty() {
|
||||
@@ -197,7 +209,7 @@ pub async fn set_common_config_snippet(
|
||||
"claude" | "gemini" => {
|
||||
// 验证 JSON 格式
|
||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
|
||||
.map_err(invalid_json_format_error)?;
|
||||
}
|
||||
"codex" => {
|
||||
// TOML 格式暂不验证(或可使用 toml crate)
|
||||
@@ -219,3 +231,31 @@ pub async fn set_common_config_snippet(
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 提取通用配置片段
|
||||
///
|
||||
/// 优先从 `settingsConfig`(编辑器当前内容)提取;若未提供,则从当前激活供应商提取。
|
||||
///
|
||||
/// 提取时会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。
|
||||
#[tauri::command]
|
||||
pub async fn extract_common_config_snippet(
|
||||
appType: String,
|
||||
settingsConfig: Option<String>,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<String, String> {
|
||||
let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {
|
||||
let settings: serde_json::Value =
|
||||
serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;
|
||||
|
||||
return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
|
||||
app,
|
||||
&settings,
|
||||
)
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
|
||||
crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! 故障转移队列命令
|
||||
//!
|
||||
//! 管理代理模式下的故障转移队列(基于 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())
|
||||
}
|
||||
@@ -192,3 +192,13 @@ pub async fn toggle_mcp_app(
|
||||
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 从所有应用导入 MCP 服务器(复用已有的导入逻辑)
|
||||
#[tauri::command]
|
||||
pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, String> {
|
||||
let mut total = 0;
|
||||
total += McpService::import_from_claude(&state).unwrap_or(0);
|
||||
total += McpService::import_from_codex(&state).unwrap_or(0);
|
||||
total += McpService::import_from_gemini(&state).unwrap_or(0);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use crate::init_status::InitErrorPayload;
|
||||
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
||||
use tauri::AppHandle;
|
||||
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]
|
||||
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
|
||||
@@ -58,3 +64,234 @@ pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
|
||||
pub async fn get_migration_result() -> Result<bool, String> {
|
||||
Ok(crate::init_status::take_migration_success())
|
||||
}
|
||||
|
||||
/// 获取 Skills 自动导入(SSOT)迁移结果(若有)。
|
||||
/// 只返回一次 Some({count}),之后返回 None,用于前端显示一次性 Toast 通知。
|
||||
#[tauri::command]
|
||||
pub async fn get_skills_migration_result() -> Result<Option<SkillsMigrationPayload>, String> {
|
||||
Ok(crate::init_status::take_skills_migration_result())
|
||||
}
|
||||
|
||||
#[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()))
|
||||
}
|
||||
|
||||
@@ -3,23 +3,31 @@
|
||||
mod config;
|
||||
mod deeplink;
|
||||
mod env;
|
||||
mod failover;
|
||||
mod import_export;
|
||||
mod mcp;
|
||||
mod misc;
|
||||
mod plugin;
|
||||
mod prompt;
|
||||
mod provider;
|
||||
mod proxy;
|
||||
mod settings;
|
||||
pub mod skill;
|
||||
mod stream_check;
|
||||
mod usage;
|
||||
|
||||
pub use config::*;
|
||||
pub use deeplink::*;
|
||||
pub use env::*;
|
||||
pub use failover::*;
|
||||
pub use import_export::*;
|
||||
pub use mcp::*;
|
||||
pub use misc::*;
|
||||
pub use plugin::*;
|
||||
pub use prompt::*;
|
||||
pub use provider::*;
|
||||
pub use proxy::*;
|
||||
pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
pub use usage::*;
|
||||
|
||||
@@ -34,3 +34,15 @@ pub async fn apply_claude_plugin_config(official: bool) -> 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())
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
|
||||
@@ -229,3 +229,97 @@ pub fn update_providers_sort_order(
|
||||
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())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 统一供应商(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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
//! 代理服务相关的 Tauri 命令
|
||||
//!
|
||||
//! 提供前端调用的 API 接口
|
||||
|
||||
use crate::proxy::types::*;
|
||||
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
|
||||
use crate::store::AppState;
|
||||
|
||||
/// 启动代理服务器(仅启动服务,不接管 Live 配置)
|
||||
#[tauri::command]
|
||||
pub async fn start_proxy_server(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<ProxyServerInfo, String> {
|
||||
state.proxy_service.start().await
|
||||
}
|
||||
|
||||
/// 停止代理服务器(恢复 Live 配置)
|
||||
#[tauri::command]
|
||||
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||
state.proxy_service.stop_with_restore().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
|
||||
}
|
||||
|
||||
/// 获取代理服务器状态
|
||||
#[tauri::command]
|
||||
pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result<ProxyStatus, String> {
|
||||
state.proxy_service.get_status().await
|
||||
}
|
||||
|
||||
/// 获取代理配置
|
||||
#[tauri::command]
|
||||
pub async fn get_proxy_config(state: tauri::State<'_, AppState>) -> Result<ProxyConfig, String> {
|
||||
state.proxy_service.get_config().await
|
||||
}
|
||||
|
||||
/// 更新代理配置
|
||||
#[tauri::command]
|
||||
pub async fn update_proxy_config(
|
||||
state: tauri::State<'_, AppState>,
|
||||
config: ProxyConfig,
|
||||
) -> Result<(), String> {
|
||||
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]
|
||||
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||
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)
|
||||
}
|
||||
@@ -1,66 +1,177 @@
|
||||
//! Skills 命令层
|
||||
//!
|
||||
//! v3.10.0+ 统一管理架构:
|
||||
//! - 支持三应用开关(Claude/Codex/Gemini)
|
||||
//! - SSOT 存储在 ~/.cc-switch/skills/
|
||||
|
||||
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
|
||||
use crate::error::format_skill_error;
|
||||
use crate::services::skill::SkillState;
|
||||
use crate::services::{Skill, SkillRepo, SkillService};
|
||||
use crate::services::skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||
use crate::store::AppState;
|
||||
use chrono::Utc;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
/// SkillService 状态包装
|
||||
pub struct SkillServiceState(pub Arc<SkillService>);
|
||||
|
||||
/// 解析 app 参数为 AppType
|
||||
fn parse_app_type(app: &str) -> Result<AppType, String> {
|
||||
match app.to_lowercase().as_str() {
|
||||
"claude" => Ok(AppType::Claude),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
_ => Err(format!("不支持的 app 类型: {app}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 统一管理命令 ==========
|
||||
|
||||
/// 获取所有已安装的 Skills
|
||||
#[tauri::command]
|
||||
pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<InstalledSkill>, String> {
|
||||
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 安装 Skill(新版统一安装)
|
||||
///
|
||||
/// 参数:
|
||||
/// - skill: 从发现列表获取的技能信息
|
||||
/// - current_app: 当前选中的应用,安装后默认启用该应用
|
||||
#[tauri::command]
|
||||
pub async fn install_skill_unified(
|
||||
skill: DiscoverableSkill,
|
||||
current_app: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<InstalledSkill, String> {
|
||||
let app_type = parse_app_type(¤t_app)?;
|
||||
|
||||
service
|
||||
.0
|
||||
.install(&app_state.db, &skill, &app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 卸载 Skill(新版统一卸载)
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill_unified(id: String, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 切换 Skill 的应用启用状态
|
||||
#[tauri::command]
|
||||
pub fn toggle_skill_app(
|
||||
id: String,
|
||||
app: String,
|
||||
enabled: bool,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = parse_app_type(&app)?;
|
||||
SkillService::toggle_app(&app_state.db, &id, &app_type, enabled).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 扫描未管理的 Skills
|
||||
#[tauri::command]
|
||||
pub fn scan_unmanaged_skills(
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<UnmanagedSkill>, String> {
|
||||
SkillService::scan_unmanaged(&app_state.db).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 从应用目录导入 Skills
|
||||
#[tauri::command]
|
||||
pub fn import_skills_from_apps(
|
||||
directories: Vec<String>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<InstalledSkill>, String> {
|
||||
SkillService::import_from_apps(&app_state.db, directories).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ========== 发现功能命令 ==========
|
||||
|
||||
/// 发现可安装的 Skills(从仓库获取)
|
||||
#[tauri::command]
|
||||
pub async fn discover_available_skills(
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<DiscoverableSkill>, String> {
|
||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||
service
|
||||
.0
|
||||
.discover_available(repos)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ========== 兼容旧 API 的命令 ==========
|
||||
|
||||
/// 获取技能列表(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub async fn get_skills(
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<Skill>, String> {
|
||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||
|
||||
let skills = service
|
||||
service
|
||||
.0
|
||||
.list_skills(repos)
|
||||
.list_skills(repos, &app_state.db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 自动同步本地已安装的 skills 到数据库
|
||||
// 这样用户在首次运行时,已有的 skills 会被自动记录
|
||||
let existing_states = app_state.db.get_skills().unwrap_or_default();
|
||||
|
||||
for skill in &skills {
|
||||
if skill.installed && !existing_states.contains_key(&skill.directory) {
|
||||
// 本地有该 skill,但数据库中没有记录,自动添加
|
||||
if let Err(e) = app_state.db.update_skill_state(
|
||||
&skill.directory,
|
||||
&SkillState {
|
||||
installed: true,
|
||||
installed_at: Utc::now(),
|
||||
},
|
||||
) {
|
||||
log::warn!("同步本地 skill {} 状态到数据库失败: {}", skill.directory, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(skills)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取指定应用的技能列表(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub async fn get_skills_for_app(
|
||||
app: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<Skill>, String> {
|
||||
// 新版本不再区分应用,统一返回所有技能
|
||||
let _ = parse_app_type(&app)?; // 验证 app 参数有效
|
||||
get_skills(service, app_state).await
|
||||
}
|
||||
|
||||
/// 安装技能(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub async fn install_skill(
|
||||
directory: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
// 先在不持有写锁的情况下收集仓库与技能信息
|
||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||
install_skill_for_app("claude".to_string(), directory, service, app_state).await
|
||||
}
|
||||
|
||||
/// 安装指定应用的技能(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub async fn install_skill_for_app(
|
||||
app: String,
|
||||
directory: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = parse_app_type(&app)?;
|
||||
|
||||
// 先获取技能信息
|
||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||
let skills = service
|
||||
.0
|
||||
.list_skills(repos)
|
||||
.discover_available(repos)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let skill = skills
|
||||
.iter()
|
||||
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
|
||||
.into_iter()
|
||||
.find(|s| {
|
||||
let install_name = std::path::Path::new(&s.directory)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| s.directory.clone());
|
||||
install_name.eq_ignore_ascii_case(&directory)
|
||||
|| s.directory.eq_ignore_ascii_case(&directory)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
format_skill_error(
|
||||
"SKILL_NOT_FOUND",
|
||||
@@ -69,90 +180,54 @@ pub async fn install_skill(
|
||||
)
|
||||
})?;
|
||||
|
||||
if !skill.installed {
|
||||
let repo = SkillRepo {
|
||||
owner: skill.repo_owner.clone().ok_or_else(|| {
|
||||
format_skill_error(
|
||||
"MISSING_REPO_INFO",
|
||||
&[("directory", &directory), ("field", "owner")],
|
||||
None,
|
||||
)
|
||||
})?,
|
||||
name: skill.repo_name.clone().ok_or_else(|| {
|
||||
format_skill_error(
|
||||
"MISSING_REPO_INFO",
|
||||
&[("directory", &directory), ("field", "name")],
|
||||
None,
|
||||
)
|
||||
})?,
|
||||
branch: skill
|
||||
.repo_branch
|
||||
.clone()
|
||||
.unwrap_or_else(|| "main".to_string()),
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
service
|
||||
.0
|
||||
.install_skill(directory.clone(), repo)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
app_state
|
||||
.db
|
||||
.update_skill_state(
|
||||
&directory,
|
||||
&SkillState {
|
||||
installed: true,
|
||||
installed_at: Utc::now(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill(
|
||||
directory: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
service
|
||||
.0
|
||||
.uninstall_skill(directory.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Remove from database by setting installed = false
|
||||
app_state
|
||||
.db
|
||||
.update_skill_state(
|
||||
&directory,
|
||||
&SkillState {
|
||||
installed: false,
|
||||
installed_at: Utc::now(),
|
||||
},
|
||||
)
|
||||
.install(&app_state.db, &skill, &app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 卸载技能(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub fn get_skill_repos(
|
||||
_service: State<'_, SkillServiceState>,
|
||||
pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||
uninstall_skill_for_app("claude".to_string(), directory, app_state)
|
||||
}
|
||||
|
||||
/// 卸载指定应用的技能(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill_for_app(
|
||||
app: String,
|
||||
directory: String,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<SkillRepo>, String> {
|
||||
) -> Result<bool, String> {
|
||||
let _ = parse_app_type(&app)?; // 验证参数
|
||||
|
||||
// 通过 directory 找到对应的 skill id
|
||||
let skills = SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())?;
|
||||
|
||||
let skill = skills
|
||||
.into_iter()
|
||||
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
|
||||
.ok_or_else(|| format!("未找到已安装的 Skill: {directory}"))?;
|
||||
|
||||
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// ========== 仓库管理命令 ==========
|
||||
|
||||
/// 获取技能仓库列表
|
||||
#[tauri::command]
|
||||
pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result<Vec<SkillRepo>, String> {
|
||||
app_state.db.get_skill_repos().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 添加技能仓库
|
||||
#[tauri::command]
|
||||
pub fn add_skill_repo(
|
||||
repo: SkillRepo,
|
||||
_service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||
app_state
|
||||
.db
|
||||
.save_skill_repo(&repo)
|
||||
@@ -160,11 +235,11 @@ pub fn add_skill_repo(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 删除技能仓库
|
||||
#[tauri::command]
|
||||
pub fn remove_skill_repo(
|
||||
owner: String,
|
||||
name: String,
|
||||
_service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
app_state
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! 流式健康检查命令
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! 使用统计相关命令
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::services::usage_stats::*;
|
||||
use crate::store::AppState;
|
||||
use tauri::State;
|
||||
|
||||
/// 获取使用量汇总
|
||||
#[tauri::command]
|
||||
pub fn get_usage_summary(
|
||||
state: State<'_, AppState>,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
) -> Result<UsageSummary, AppError> {
|
||||
state.db.get_usage_summary(start_date, end_date)
|
||||
}
|
||||
|
||||
/// 获取每日趋势
|
||||
#[tauri::command]
|
||||
pub fn get_usage_trends(
|
||||
state: State<'_, AppState>,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
) -> Result<Vec<DailyStats>, AppError> {
|
||||
state.db.get_daily_trends(start_date, end_date)
|
||||
}
|
||||
|
||||
/// 获取 Provider 统计
|
||||
#[tauri::command]
|
||||
pub fn get_provider_stats(state: State<'_, AppState>) -> Result<Vec<ProviderStats>, AppError> {
|
||||
state.db.get_provider_stats()
|
||||
}
|
||||
|
||||
/// 获取模型统计
|
||||
#[tauri::command]
|
||||
pub fn get_model_stats(state: State<'_, AppState>) -> Result<Vec<ModelStats>, AppError> {
|
||||
state.db.get_model_stats()
|
||||
}
|
||||
|
||||
/// 获取请求日志列表
|
||||
#[tauri::command]
|
||||
pub fn get_request_logs(
|
||||
state: State<'_, AppState>,
|
||||
filters: LogFilters,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
) -> Result<PaginatedLogs, AppError> {
|
||||
state.db.get_request_logs(&filters, page, page_size)
|
||||
}
|
||||
|
||||
/// 获取单个请求详情
|
||||
#[tauri::command]
|
||||
pub fn get_request_detail(
|
||||
state: State<'_, AppState>,
|
||||
request_id: String,
|
||||
) -> Result<Option<RequestLogDetail>, AppError> {
|
||||
state.db.get_request_detail(&request_id)
|
||||
}
|
||||
|
||||
/// 获取模型定价列表
|
||||
#[tauri::command]
|
||||
pub fn get_model_pricing(state: State<'_, AppState>) -> Result<Vec<ModelPricingInfo>, AppError> {
|
||||
log::info!("获取模型定价列表");
|
||||
state.db.ensure_model_pricing_seeded()?;
|
||||
|
||||
let db = state.db.clone();
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
|
||||
// 检查表是否存在
|
||||
let table_exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='model_pricing'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0).map(|count| count > 0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !table_exists {
|
||||
log::error!("model_pricing 表不存在,可能需要重启应用以触发数据库迁移");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing
|
||||
ORDER BY display_name",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(ModelPricingInfo {
|
||||
model_id: row.get(0)?,
|
||||
display_name: row.get(1)?,
|
||||
input_cost_per_million: row.get(2)?,
|
||||
output_cost_per_million: row.get(3)?,
|
||||
cache_read_cost_per_million: row.get(4)?,
|
||||
cache_creation_cost_per_million: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut pricing = Vec::new();
|
||||
for row in rows {
|
||||
pricing.push(row?);
|
||||
}
|
||||
|
||||
log::info!("成功获取 {} 条模型定价数据", pricing.len());
|
||||
Ok(pricing)
|
||||
}
|
||||
|
||||
/// 更新模型定价
|
||||
#[tauri::command]
|
||||
pub fn update_model_pricing(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
display_name: String,
|
||||
input_cost: String,
|
||||
output_cost: String,
|
||||
cache_read_cost: String,
|
||||
cache_creation_cost: String,
|
||||
) -> Result<(), AppError> {
|
||||
let db = state.db.clone();
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO model_pricing (
|
||||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
model_id,
|
||||
display_name,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查 Provider 使用限额
|
||||
#[tauri::command]
|
||||
pub fn check_provider_limits(
|
||||
state: State<'_, AppState>,
|
||||
provider_id: String,
|
||||
app_type: String,
|
||||
) -> Result<crate::services::usage_stats::ProviderLimitStatus, AppError> {
|
||||
state.db.check_provider_limits(&provider_id, &app_type)
|
||||
}
|
||||
|
||||
/// 删除模型定价
|
||||
#[tauri::command]
|
||||
pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Result<(), AppError> {
|
||||
let db = state.db.clone();
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM model_pricing WHERE model_id = ?1",
|
||||
rusqlite::params![model_id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("删除模型定价失败: {e}")))?;
|
||||
|
||||
log::info!("已删除模型定价: {model_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 模型定价信息
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelPricingInfo {
|
||||
pub model_id: String,
|
||||
pub display_name: String,
|
||||
pub input_cost_per_million: String,
|
||||
pub output_cost_per_million: String,
|
||||
pub cache_read_cost_per_million: String,
|
||||
pub cache_creation_cost_per_million: String,
|
||||
}
|
||||
@@ -13,6 +13,8 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
||||
|
||||
impl Database {
|
||||
/// 导出为 SQLite 兼容的 SQL 文本
|
||||
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
|
||||
@@ -36,7 +38,8 @@ impl Database {
|
||||
}
|
||||
|
||||
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
|
||||
let sql_content = Self::sanitize_import_sql(&sql_raw);
|
||||
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
||||
Self::validate_cc_switch_sql_export(sql_content)?;
|
||||
|
||||
// 导入前备份现有数据库
|
||||
let backup_path = self.backup_database_file()?;
|
||||
@@ -51,7 +54,7 @@ impl Database {
|
||||
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
temp_conn
|
||||
.execute_batch(&sql_content)
|
||||
.execute_batch(sql_content)
|
||||
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
|
||||
|
||||
// 补齐缺失表/索引并进行基础校验
|
||||
@@ -93,26 +96,17 @@ impl Database {
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错
|
||||
fn sanitize_import_sql(sql: &str) -> String {
|
||||
let mut cleaned = String::new();
|
||||
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");
|
||||
fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> {
|
||||
let trimmed = sql.trim_start();
|
||||
if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
cleaned
|
||||
Err(AppError::localized(
|
||||
"backup.sql.invalid_format",
|
||||
"仅支持导入由 CC Switch 导出的 SQL 备份文件。",
|
||||
"Only SQL backups exported by CC Switch are supported.",
|
||||
))
|
||||
}
|
||||
|
||||
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
||||
@@ -129,8 +123,15 @@ impl Database {
|
||||
|
||||
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
|
||||
|
||||
let backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
|
||||
let backup_path = backup_dir.join(format!("{backup_id}.db"));
|
||||
let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
|
||||
let mut backup_id = base_id.clone();
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! 故障转移队列 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)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
//! 数据访问对象 (DAO) 模块
|
||||
//! Data Access Object layer
|
||||
//!
|
||||
//! 提供各类数据的 CRUD 操作。
|
||||
//! Database access operations for each domain
|
||||
|
||||
mod mcp;
|
||||
mod prompts;
|
||||
mod providers;
|
||||
mod settings;
|
||||
mod skills;
|
||||
pub mod failover;
|
||||
pub mod mcp;
|
||||
pub mod prompts;
|
||||
pub mod providers;
|
||||
pub mod proxy;
|
||||
pub mod settings;
|
||||
pub mod skills;
|
||||
pub mod stream_check;
|
||||
pub mod universal_providers;
|
||||
|
||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||
// 导出 FailoverQueueItem 供外部使用
|
||||
pub use failover::FailoverQueueItem;
|
||||
|
||||
@@ -17,7 +17,7 @@ impl Database {
|
||||
) -> Result<IndexMap<String, Provider>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
||||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||
FROM providers WHERE app_type = ?1
|
||||
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -35,6 +35,7 @@ impl Database {
|
||||
let icon: Option<String> = row.get(8)?;
|
||||
let icon_color: Option<String> = row.get(9)?;
|
||||
let meta_str: String = row.get(10)?;
|
||||
let in_failover_queue: bool = row.get(11)?;
|
||||
|
||||
let settings_config =
|
||||
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||
@@ -54,6 +55,7 @@ impl Database {
|
||||
meta: Some(meta),
|
||||
icon,
|
||||
icon_color,
|
||||
in_failover_queue,
|
||||
},
|
||||
))
|
||||
})
|
||||
@@ -121,6 +123,57 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 ID 获取单个供应商
|
||||
pub fn get_provider_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
app_type: &str,
|
||||
) -> Result<Option<Provider>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let result = conn.query_row(
|
||||
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||
FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
params![id, app_type],
|
||||
|row| {
|
||||
let name: String = row.get(0)?;
|
||||
let settings_config_str: String = row.get(1)?;
|
||||
let website_url: Option<String> = row.get(2)?;
|
||||
let category: Option<String> = row.get(3)?;
|
||||
let created_at: Option<i64> = row.get(4)?;
|
||||
let sort_index: Option<usize> = row.get(5)?;
|
||||
let notes: Option<String> = row.get(6)?;
|
||||
let icon: Option<String> = row.get(7)?;
|
||||
let icon_color: Option<String> = row.get(8)?;
|
||||
let meta_str: String = row.get(9)?;
|
||||
let in_failover_queue: bool = row.get(10)?;
|
||||
|
||||
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();
|
||||
|
||||
Ok(Provider {
|
||||
id: id.to_string(),
|
||||
name,
|
||||
settings_config,
|
||||
website_url,
|
||||
category,
|
||||
created_at,
|
||||
sort_index,
|
||||
notes,
|
||||
meta: Some(meta),
|
||||
icon,
|
||||
icon_color,
|
||||
in_failover_queue,
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(provider) => Ok(Some(provider)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存供应商(新增或更新)
|
||||
///
|
||||
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
|
||||
@@ -135,17 +188,18 @@ impl Database {
|
||||
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
||||
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
||||
|
||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current)
|
||||
let existing: Option<bool> = tx
|
||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue)
|
||||
let existing: Option<(bool, bool)> = tx
|
||||
.query_row(
|
||||
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
params![provider.id, app_type],
|
||||
|row| row.get(0),
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.ok();
|
||||
|
||||
let is_update = existing.is_some();
|
||||
let is_current = existing.unwrap_or(false);
|
||||
let (is_current, in_failover_queue) =
|
||||
existing.unwrap_or((false, provider.in_failover_queue));
|
||||
|
||||
if is_update {
|
||||
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
||||
@@ -161,8 +215,9 @@ impl Database {
|
||||
icon = ?8,
|
||||
icon_color = ?9,
|
||||
meta = ?10,
|
||||
is_current = ?11
|
||||
WHERE id = ?12 AND app_type = ?13",
|
||||
is_current = ?11,
|
||||
in_failover_queue = ?12
|
||||
WHERE id = ?13 AND app_type = ?14",
|
||||
params![
|
||||
provider.name,
|
||||
serde_json::to_string(&provider.settings_config).unwrap(),
|
||||
@@ -175,6 +230,7 @@ impl Database {
|
||||
provider.icon_color,
|
||||
serde_json::to_string(&meta_clone).unwrap(),
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
provider.id,
|
||||
app_type,
|
||||
],
|
||||
@@ -185,8 +241,8 @@ impl Database {
|
||||
tx.execute(
|
||||
"INSERT INTO providers (
|
||||
id, app_type, name, settings_config, website_url, category,
|
||||
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
created_at, sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
params![
|
||||
provider.id,
|
||||
app_type,
|
||||
@@ -201,6 +257,7 @@ impl Database {
|
||||
provider.icon_color,
|
||||
serde_json::to_string(&meta_clone).unwrap(),
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -256,6 +313,26 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新供应商的 settings_config(仅更新配置,不改变其他字段)
|
||||
pub fn update_provider_settings_config(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
settings_config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||
params![
|
||||
serde_json::to_string(settings_config).unwrap(),
|
||||
provider_id,
|
||||
app_type
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 添加自定义端点
|
||||
pub fn add_custom_endpoint(
|
||||
&self,
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
//! 代理功能数据访问层
|
||||
//!
|
||||
//! 处理代理配置、Provider健康状态和使用统计的数据库操作
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::proxy::types::*;
|
||||
|
||||
use super::super::{lock_conn, Database};
|
||||
|
||||
impl Database {
|
||||
// ==================== Global 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: 60,
|
||||
streaming_idle_timeout: 120,
|
||||
non_streaming_timeout: 600,
|
||||
circuit_failure_threshold: 4,
|
||||
circuit_success_threshold: 2,
|
||||
circuit_timeout_seconds: 60,
|
||||
circuit_error_rate_threshold: 0.6,
|
||||
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> {
|
||||
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||
let result = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT listen_address, listen_port, max_retries,
|
||||
enable_logging,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
|
||||
FROM proxy_config WHERE app_type = 'claude'",
|
||||
[],
|
||||
|row| {
|
||||
Ok(ProxyConfig {
|
||||
listen_address: row.get(0)?,
|
||||
listen_port: row.get::<_, i32>(1)? as u16,
|
||||
max_retries: row.get::<_, i32>(2)? as u8,
|
||||
request_timeout: 600, // 废弃字段,返回默认值
|
||||
enable_logging: row.get::<_, i32>(3)? != 0,
|
||||
live_takeover_active: false, // 废弃字段
|
||||
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(60) as u64,
|
||||
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(120) as u64,
|
||||
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(600) as u64,
|
||||
})
|
||||
},
|
||||
)
|
||||
};
|
||||
// conn 已在 block 结束时释放
|
||||
|
||||
match result {
|
||||
Ok(config) => Ok(config),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
// 如果不存在,初始化默认配置
|
||||
self.init_proxy_config_rows().await?;
|
||||
Ok(ProxyConfig::default())
|
||||
}
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新代理配置(兼容旧接口,更新所有三行的公共字段)
|
||||
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// 更新所有三行的公共字段
|
||||
conn.execute(
|
||||
"UPDATE proxy_config SET
|
||||
listen_address = ?1,
|
||||
listen_port = ?2,
|
||||
max_retries = ?3,
|
||||
enable_logging = ?4,
|
||||
streaming_first_byte_timeout = ?5,
|
||||
streaming_idle_timeout = ?6,
|
||||
non_streaming_timeout = ?7,
|
||||
updated_at = datetime('now')",
|
||||
rusqlite::params![
|
||||
config.listen_address,
|
||||
config.listen_port as i32,
|
||||
config.max_retries as i32,
|
||||
if config.enable_logging { 1 } else { 0 },
|
||||
config.streaming_first_byte_timeout as i32,
|
||||
config.streaming_idle_timeout as i32,
|
||||
config.non_streaming_timeout as i32,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
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健康状态
|
||||
pub async fn get_provider_health(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
) -> Result<ProviderHealth, AppError> {
|
||||
let result = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.query_row(
|
||||
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
|
||||
last_success_at, last_failure_at, last_error, updated_at
|
||||
FROM provider_health
|
||||
WHERE provider_id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
|row| {
|
||||
Ok(ProviderHealth {
|
||||
provider_id: row.get(0)?,
|
||||
app_type: row.get(1)?,
|
||||
is_healthy: row.get::<_, i64>(2)? != 0,
|
||||
consecutive_failures: row.get::<_, i64>(3)? as u32,
|
||||
last_success_at: row.get(4)?,
|
||||
last_failure_at: row.get(5)?,
|
||||
last_error: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
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健康状态
|
||||
///
|
||||
/// 使用默认阈值(5)判断是否健康,建议使用 `update_provider_health_with_threshold` 传入配置的阈值
|
||||
pub async fn update_provider_health(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
success: bool,
|
||||
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> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// 先查询当前状态
|
||||
let current = conn.query_row(
|
||||
"SELECT consecutive_failures FROM provider_health
|
||||
WHERE provider_id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
|row| Ok(row.get::<_, i64>(0)? as u32),
|
||||
);
|
||||
|
||||
let (is_healthy, consecutive_failures) = if success {
|
||||
// 成功:重置失败计数
|
||||
(1, 0)
|
||||
} else {
|
||||
// 失败:增加失败计数
|
||||
let failures = current.unwrap_or(0) + 1;
|
||||
// 使用传入的阈值而非硬编码
|
||||
let healthy = if failures >= failure_threshold { 0 } else { 1 };
|
||||
(healthy, failures)
|
||||
};
|
||||
|
||||
let (last_success_at, last_failure_at) = if success {
|
||||
(Some(now.clone()), None)
|
||||
} else {
|
||||
(None, Some(now.clone()))
|
||||
};
|
||||
|
||||
// UPSERT
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO provider_health
|
||||
(provider_id, app_type, is_healthy, consecutive_failures,
|
||||
last_success_at, last_failure_at, last_error, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4,
|
||||
COALESCE(?5, (SELECT last_success_at FROM provider_health
|
||||
WHERE provider_id = ?1 AND app_type = ?2)),
|
||||
COALESCE(?6, (SELECT last_failure_at FROM provider_health
|
||||
WHERE provider_id = ?1 AND app_type = ?2)),
|
||||
?7, ?8)",
|
||||
rusqlite::params![
|
||||
provider_id,
|
||||
app_type,
|
||||
is_healthy,
|
||||
consecutive_failures as i64,
|
||||
last_success_at,
|
||||
last_failure_at,
|
||||
error_msg,
|
||||
&now,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 重置Provider健康状态
|
||||
pub async fn reset_provider_health(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
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::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![
|
||||
config.failure_threshold as i32,
|
||||
config.success_threshold as i32,
|
||||
config.timeout_seconds as i64,
|
||||
config.error_rate_threshold,
|
||||
config.min_requests as i32,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== Live Backup ====================
|
||||
|
||||
/// 保存 Live 配置备份
|
||||
pub async fn save_live_backup(
|
||||
&self,
|
||||
app_type: &str,
|
||||
config_json: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO proxy_live_backup (app_type, original_config, backed_up_at)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![app_type, config_json, now],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
log::info!("已备份 {app_type} Live 配置");
|
||||
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()))?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// 获取 Live 配置备份
|
||||
pub async fn get_live_backup(&self, app_type: &str) -> Result<Option<LiveBackup>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let result = conn.query_row(
|
||||
"SELECT app_type, original_config, backed_up_at FROM proxy_live_backup WHERE app_type = ?1",
|
||||
rusqlite::params![app_type],
|
||||
|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 配置备份
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -62,4 +62,70 @@ impl Database {
|
||||
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,59 +1,156 @@
|
||||
//! Skills 数据访问对象
|
||||
//!
|
||||
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
|
||||
//!
|
||||
//! v3.10.0+ 统一管理架构:
|
||||
//! - Skills 使用统一的 id 主键,支持三应用启用标志
|
||||
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
|
||||
|
||||
use crate::app_config::{InstalledSkill, SkillApps};
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::services::skill::{SkillRepo, SkillState};
|
||||
use crate::services::skill::SkillRepo;
|
||||
use indexmap::IndexMap;
|
||||
use rusqlite::params;
|
||||
|
||||
impl Database {
|
||||
/// 获取所有 Skills 状态
|
||||
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
|
||||
// ========== InstalledSkill CRUD ==========
|
||||
|
||||
/// 获取所有已安装的 Skills
|
||||
pub fn get_all_installed_skills(&self) -> Result<IndexMap<String, InstalledSkill>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC")
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
|
||||
FROM skills ORDER BY name ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let skill_iter = stmt
|
||||
.query_map([], |row| {
|
||||
let key: String = row.get(0)?;
|
||||
let installed: bool = row.get(1)?;
|
||||
let installed_at_ts: i64 = row.get(2)?;
|
||||
|
||||
let installed_at =
|
||||
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
|
||||
|
||||
Ok((
|
||||
key,
|
||||
SkillState {
|
||||
installed,
|
||||
installed_at,
|
||||
Ok(InstalledSkill {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
directory: row.get(3)?,
|
||||
repo_owner: row.get(4)?,
|
||||
repo_name: row.get(5)?,
|
||||
repo_branch: row.get(6)?,
|
||||
readme_url: row.get(7)?,
|
||||
apps: SkillApps {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
},
|
||||
))
|
||||
installed_at: row.get(11)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let mut skills = IndexMap::new();
|
||||
for skill_res in skill_iter {
|
||||
let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
skills.insert(key, skill);
|
||||
let skill = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
skills.insert(skill.id.clone(), skill);
|
||||
}
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
/// 更新 Skill 状态
|
||||
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
|
||||
/// 获取单个已安装的 Skill
|
||||
pub fn get_installed_skill(&self, id: &str) -> Result<Option<InstalledSkill>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
|
||||
FROM skills WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let result = stmt.query_row([id], |row| {
|
||||
Ok(InstalledSkill {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
directory: row.get(3)?,
|
||||
repo_owner: row.get(4)?,
|
||||
repo_name: row.get(5)?,
|
||||
repo_branch: row.get(6)?,
|
||||
readme_url: row.get(7)?,
|
||||
apps: SkillApps {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
},
|
||||
installed_at: row.get(11)?,
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(skill) => Ok(Some(skill)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存 Skill(添加或更新)
|
||||
pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
||||
params![key, state.installed, state.installed_at.timestamp()],
|
||||
"INSERT OR REPLACE INTO skills
|
||||
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![
|
||||
skill.id,
|
||||
skill.name,
|
||||
skill.description,
|
||||
skill.directory,
|
||||
skill.repo_owner,
|
||||
skill.repo_name,
|
||||
skill.repo_branch,
|
||||
skill.readme_url,
|
||||
skill.apps.claude,
|
||||
skill.apps.codex,
|
||||
skill.apps.gemini,
|
||||
skill.installed_at,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除 Skill
|
||||
pub fn delete_skill(&self, id: &str) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute("DELETE FROM skills WHERE id = ?1", params![id])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
}
|
||||
|
||||
/// 清空所有 Skills(用于迁移)
|
||||
pub fn clear_skills(&self) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute("DELETE FROM skills", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新 Skill 的应用启用状态
|
||||
pub fn update_skill_apps(&self, id: &str, apps: &SkillApps) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4",
|
||||
params![apps.claude, apps.codex, apps.gemini, id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
}
|
||||
|
||||
// ========== SkillRepo CRUD(保持原有) ==========
|
||||
|
||||
/// 获取所有 Skill 仓库
|
||||
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
@@ -87,7 +184,8 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![repo.owner, repo.name, repo.branch, repo.enabled],
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! 流式健康检查日志 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! 统一供应商 (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(())
|
||||
}
|
||||
}
|
||||
@@ -192,13 +192,16 @@ impl Database {
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
config: &MultiAppConfig,
|
||||
) -> Result<(), AppError> {
|
||||
for (key, state) in &config.skills.skills {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
||||
params![key, state.installed, state.installed_at.timestamp()],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?;
|
||||
}
|
||||
// v3.10.0+:Skills 的 SSOT 已迁移到文件系统(~/.cc-switch/skills/)+ 数据库统一结构。
|
||||
//
|
||||
// 旧版 config.json 里的 `skills.skills` 仅记录“安装状态”,但不包含完整元数据,
|
||||
// 且无法保证 SSOT 目录中一定存在对应的 skill 文件。
|
||||
//
|
||||
// 因此这里不再直接把旧的安装状态写入新 skills 表,避免产生“数据库显示已安装但文件缺失”的不一致。
|
||||
// 迁移后可通过:
|
||||
// - 前端「导入已有」(扫描各应用的 skills 目录并复制到 SSOT)
|
||||
// - 或后续启动时的自动扫描逻辑
|
||||
// 来重建已安装技能记录。
|
||||
|
||||
for repo in &config.skills.repos {
|
||||
tx.execute(
|
||||
|
||||
@@ -31,6 +31,9 @@ mod schema;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
// DAO 类型导出供外部使用
|
||||
pub use dao::FailoverQueueItem;
|
||||
|
||||
use crate::config::get_app_config_dir;
|
||||
use crate::error::AppError;
|
||||
use rusqlite::Connection;
|
||||
@@ -44,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 1;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 3;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
@@ -95,6 +98,7 @@ impl Database {
|
||||
};
|
||||
db.create_tables()?;
|
||||
db.apply_schema_migrations()?;
|
||||
db.ensure_model_pricing_seeded()?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
@@ -111,6 +115,7 @@ impl Database {
|
||||
conn: Mutex::new(conn),
|
||||
};
|
||||
db.create_tables()?;
|
||||
db.ensure_model_pricing_seeded()?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::*;
|
||||
use crate::app_config::MultiAppConfig;
|
||||
use crate::provider::{Provider, ProviderManager};
|
||||
use indexmap::IndexMap;
|
||||
use rusqlite::Connection;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -51,9 +51,76 @@ const LEGACY_SCHEMA_SQL: &str = r#"
|
||||
);
|
||||
"#;
|
||||
|
||||
// v3.8.x(schema v1)的真实表结构快照:用于验证从 v3.8.* 升级到当前版本的迁移链路
|
||||
// 参考:tag v3.8.3 的 src-tauri/src/database/schema.rs
|
||||
const V3_8_SCHEMA_V1_SQL: &str = r#"
|
||||
CREATE TABLE providers (
|
||||
id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
settings_config TEXT NOT NULL,
|
||||
website_url TEXT,
|
||||
category TEXT,
|
||||
created_at INTEGER,
|
||||
sort_index INTEGER,
|
||||
notes TEXT,
|
||||
icon TEXT,
|
||||
icon_color TEXT,
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
is_current BOOLEAN NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id, app_type)
|
||||
);
|
||||
CREATE TABLE provider_endpoints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
added_at INTEGER,
|
||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
server_config TEXT NOT NULL,
|
||||
description TEXT,
|
||||
homepage TEXT,
|
||||
docs TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE prompts (
|
||||
id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
description TEXT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
PRIMARY KEY (id, app_type)
|
||||
);
|
||||
CREATE TABLE skills (
|
||||
key TEXT PRIMARY KEY,
|
||||
installed BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE skill_repos (
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
branch TEXT NOT NULL DEFAULT 'main',
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (owner, name)
|
||||
);
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
"#;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ColumnInfo {
|
||||
name: String,
|
||||
r#type: String,
|
||||
notnull: i64,
|
||||
default: Option<String>,
|
||||
@@ -65,10 +132,9 @@ fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo {
|
||||
.expect("prepare pragma");
|
||||
let mut rows = stmt.query([]).expect("query pragma");
|
||||
while let Some(row) = rows.next().expect("read row") {
|
||||
let name: String = row.get(1).expect("name");
|
||||
if name.eq_ignore_ascii_case(column) {
|
||||
let column_name: String = row.get(1).expect("name");
|
||||
if column_name.eq_ignore_ascii_case(column) {
|
||||
return ColumnInfo {
|
||||
name,
|
||||
r#type: row.get::<_, String>(2).expect("type"),
|
||||
notnull: row.get::<_, i64>(3).expect("notnull"),
|
||||
default: row.get::<_, Option<String>>(4).ok().flatten(),
|
||||
@@ -201,6 +267,171 @@ 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");
|
||||
|
||||
// 模拟测试版 v2:user_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]
|
||||
fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||
.expect("enable foreign keys");
|
||||
|
||||
// 模拟 v3.8.* 用户的数据库(schema v1)
|
||||
conn.execute_batch(V3_8_SCHEMA_V1_SQL)
|
||||
.expect("seed v3.8 schema v1");
|
||||
Database::set_user_version(&conn, 1).expect("set user_version=1");
|
||||
|
||||
// 插入一条旧版 Provider + Skill(用于验证迁移不会破坏既有数据)
|
||||
conn.execute(
|
||||
"INSERT INTO providers (
|
||||
id, app_type, name, settings_config, website_url, category,
|
||||
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
"p1",
|
||||
"claude",
|
||||
"Test Provider",
|
||||
serde_json::to_string(&json!({ "anthropicApiKey": "sk-test" })).unwrap(),
|
||||
Option::<String>::None,
|
||||
Option::<String>::None,
|
||||
Option::<i64>::None,
|
||||
Option::<usize>::None,
|
||||
Option::<String>::None,
|
||||
Option::<String>::None,
|
||||
Option::<String>::None,
|
||||
"{}",
|
||||
1,
|
||||
],
|
||||
)
|
||||
.expect("seed provider");
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
||||
params!["claude:demo-skill", 1, 1700000000i64],
|
||||
)
|
||||
.expect("seed legacy skill");
|
||||
|
||||
// 按应用启动流程:先 create_tables(补齐新增表),再 apply_schema_migrations(按 user_version 迁移)
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||||
|
||||
assert_eq!(
|
||||
Database::get_user_version(&conn).expect("user_version after migration"),
|
||||
SCHEMA_VERSION
|
||||
);
|
||||
|
||||
// v1 -> v2:providers 新增字段必须补齐
|
||||
for column in [
|
||||
"cost_multiplier",
|
||||
"limit_daily_usd",
|
||||
"limit_monthly_usd",
|
||||
"provider_type",
|
||||
"in_failover_queue",
|
||||
] {
|
||||
assert!(
|
||||
Database::has_column(&conn, "providers", column).expect("check column"),
|
||||
"providers.{column} should exist after migration"
|
||||
);
|
||||
}
|
||||
|
||||
// 旧 provider 不应丢失,且新增字段应有默认值
|
||||
let provider_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM providers WHERE id = 'p1' AND app_type = 'claude'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("count providers");
|
||||
assert_eq!(provider_count, 1);
|
||||
|
||||
let cost_multiplier: String = conn
|
||||
.query_row(
|
||||
"SELECT cost_multiplier FROM providers WHERE id = 'p1' AND app_type = 'claude'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("read cost_multiplier");
|
||||
assert_eq!(cost_multiplier, "1.0");
|
||||
|
||||
// v2 -> v3:skills 表重建为统一结构,并设置 pending 标记(后续由启动时扫描文件系统重建数据)
|
||||
assert!(
|
||||
Database::has_column(&conn, "skills", "enabled_claude").expect("check skills v3 column"),
|
||||
"skills table should be migrated to v3 structure"
|
||||
);
|
||||
let skills_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM skills", [], |r| r.get(0))
|
||||
.expect("count skills");
|
||||
assert_eq!(skills_count, 0, "skills table should be rebuilt empty");
|
||||
|
||||
let pending: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'skills_ssot_migration_pending'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok();
|
||||
assert!(
|
||||
matches!(pending.as_deref(), Some("true") | Some("1")),
|
||||
"skills_ssot_migration_pending should be set after v2->v3 migration"
|
||||
);
|
||||
|
||||
// v3.9+ 新增:proxy_config 三行 seed 必须存在(否则 UI 会查不到默认值)
|
||||
let proxy_rows: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
|
||||
.expect("count proxy_config rows");
|
||||
assert_eq!(proxy_rows, 3);
|
||||
|
||||
// model_pricing 应具备默认数据(迁移时会 seed)
|
||||
let pricing_rows: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM model_pricing", [], |r| r.get(0))
|
||||
.expect("count model_pricing rows");
|
||||
assert!(pricing_rows > 0, "model_pricing should be seeded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_does_not_write_to_disk() {
|
||||
// Create minimal valid config for migration
|
||||
@@ -245,12 +476,14 @@ fn dry_run_validates_schema_compatibility() {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
},
|
||||
);
|
||||
|
||||
let mut manager = ProviderManager::default();
|
||||
manager.providers = providers;
|
||||
manager.current = "test-provider".to_string();
|
||||
let manager = ProviderManager {
|
||||
providers,
|
||||
current: "test-provider".to_string(),
|
||||
};
|
||||
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), manager);
|
||||
@@ -272,3 +505,62 @@ fn dry_run_validates_schema_compatibility() {
|
||||
"Dry-run should succeed with provider data: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_pricing_is_seeded_on_init() {
|
||||
let db = Database::memory().expect("create memory db");
|
||||
|
||||
let conn = db.conn.lock().expect("lock conn");
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
|
||||
.expect("count pricing");
|
||||
|
||||
assert!(
|
||||
count > 0,
|
||||
"模型定价数据应该在初始化时自动填充,实际数量: {}",
|
||||
count
|
||||
);
|
||||
|
||||
// 验证包含 Claude 模型
|
||||
let claude_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'claude-%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("check claude");
|
||||
assert!(
|
||||
claude_count > 0,
|
||||
"应该包含 Claude 模型定价,实际数量: {}",
|
||||
claude_count
|
||||
);
|
||||
|
||||
// 验证包含 GPT 模型
|
||||
let gpt_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gpt-%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("check gpt");
|
||||
assert!(
|
||||
gpt_count > 0,
|
||||
"应该包含 GPT 模型定价,实际数量: {}",
|
||||
gpt_count
|
||||
);
|
||||
|
||||
// 验证包含 Gemini 模型
|
||||
let gemini_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gemini-%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("check gemini");
|
||||
assert!(
|
||||
gemini_count > 0,
|
||||
"应该包含 Gemini 模型定价,实际数量: {}",
|
||||
gemini_count
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,4 +113,27 @@ pub struct DeepLinkImportRequest {
|
||||
/// Remote config URL
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -122,6 +122,19 @@ fn parse_provider_deeplink(
|
||||
let config_url = params.get("configUrl").cloned();
|
||||
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 {
|
||||
version,
|
||||
resource,
|
||||
@@ -146,6 +159,13 @@ fn parse_provider_deeplink(
|
||||
config,
|
||||
config_format,
|
||||
config_url,
|
||||
usage_enabled,
|
||||
usage_script,
|
||||
usage_api_key,
|
||||
usage_base_url,
|
||||
usage_access_token,
|
||||
usage_user_id,
|
||||
usage_auto_interval,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -206,6 +226,13 @@ fn parse_prompt_deeplink(
|
||||
config: None,
|
||||
config_format: 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -261,6 +288,13 @@ fn parse_mcp_deeplink(
|
||||
directory: None,
|
||||
branch: 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -309,5 +343,12 @@ fn parse_skill_deeplink(
|
||||
config: None,
|
||||
config_format: 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
|
||||
use super::DeepLinkImportRequest;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::provider::{Provider, ProviderMeta, UsageScript};
|
||||
use crate::services::ProviderService;
|
||||
use crate::store::AppState;
|
||||
use crate::AppType;
|
||||
@@ -117,6 +117,9 @@ pub(crate) fn build_provider_from_request(
|
||||
AppType::Gemini => build_gemini_settings(request),
|
||||
};
|
||||
|
||||
// Build usage script configuration if provided
|
||||
let meta = build_provider_meta(request)?;
|
||||
|
||||
let provider = Provider {
|
||||
id: String::new(), // Will be generated by caller
|
||||
name: request.name.clone().unwrap_or_default(),
|
||||
@@ -126,14 +129,66 @@ pub(crate) fn build_provider_from_request(
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: request.notes.clone(),
|
||||
meta: None,
|
||||
meta,
|
||||
icon: request.icon.clone(),
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
};
|
||||
|
||||
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
|
||||
fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let mut env = serde_json::Map::new();
|
||||
|
||||
@@ -145,6 +145,13 @@ fn test_build_gemini_provider_with_model() {
|
||||
content: None,
|
||||
description: 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();
|
||||
@@ -191,6 +198,13 @@ fn test_build_gemini_provider_without_model() {
|
||||
content: None,
|
||||
description: 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();
|
||||
@@ -232,6 +246,13 @@ fn test_parse_and_merge_config_claude() {
|
||||
content: None,
|
||||
description: 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();
|
||||
@@ -275,6 +296,13 @@ fn test_parse_and_merge_config_url_override() {
|
||||
content: None,
|
||||
description: 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();
|
||||
@@ -347,7 +375,7 @@ fn test_parse_prompt_deeplink() {
|
||||
assert_eq!(request.name.unwrap(), "test");
|
||||
assert_eq!(request.content.unwrap(), content_b64);
|
||||
assert_eq!(request.description.unwrap(), "desc");
|
||||
assert_eq!(request.enabled.unwrap(), true);
|
||||
assert!(request.enabled.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -363,13 +391,13 @@ fn test_parse_mcp_deeplink() {
|
||||
assert_eq!(request.resource, "mcp");
|
||||
assert_eq!(request.apps.unwrap(), "claude,codex");
|
||||
assert_eq!(request.config.unwrap(), config_b64);
|
||||
assert_eq!(request.enabled.unwrap(), true);
|
||||
assert!(request.enabled.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_skill_deeplink() {
|
||||
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.repo.unwrap(), "owner/repo");
|
||||
|
||||
@@ -52,6 +52,10 @@ pub enum AppError {
|
||||
},
|
||||
#[error("数据库错误: {0}")]
|
||||
Database(String),
|
||||
#[error("所有供应商已熔断,无可用渠道")]
|
||||
AllProvidersCircuitOpen,
|
||||
#[error("未配置供应商")]
|
||||
NoProvidersConfigured,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
@@ -91,12 +95,27 @@ impl<T> From<PoisonError<T>> for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for AppError {
|
||||
fn from(err: rusqlite::Error) -> Self {
|
||||
Self::Database(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppError> for String {
|
||||
fn from(err: AppError) -> Self {
|
||||
err.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for AppError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化为 JSON 错误字符串,前端可解析为结构化错误
|
||||
pub fn format_skill_error(
|
||||
code: &str,
|
||||
|
||||
@@ -38,22 +38,12 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
|
||||
atomic_write(path, json.as_bytes())
|
||||
}
|
||||
|
||||
/// 读取 Gemini MCP 配置文件的完整 JSON 文本
|
||||
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
||||
let path = user_config_path();
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
Ok(Some(content))
|
||||
}
|
||||
|
||||
/// 读取 Gemini settings.json 中的 mcpServers 映射
|
||||
///
|
||||
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
|
||||
/// - httpUrl → url + type: "http"
|
||||
/// - 仅有 url 字段 → 保持不变(SSE 类型)
|
||||
/// - 仅有 command 字段 → 保持不变(stdio 类型)
|
||||
/// - 仅有 url 字段 → 补齐 type: "sse"(Gemini 以字段名推断传输类型)
|
||||
/// - 仅有 command 字段 → 补齐 type: "stdio"
|
||||
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
|
||||
let path = user_config_path();
|
||||
if !path.exists() {
|
||||
@@ -75,8 +65,15 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
|
||||
obj.insert("url".to_string(), http_url);
|
||||
obj.insert("type".to_string(), Value::String("http".to_string()));
|
||||
}
|
||||
// 如果有 url 但没有 type,不添加 type(默认为 SSE)
|
||||
// 如果有 command 但没有 type,不添加 type(默认为 stdio)
|
||||
|
||||
// Gemini CLI 不使用 type 字段:这里补齐成统一结构,便于校验与导入
|
||||
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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,47 @@ pub fn take_migration_success() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Skills SSOT 迁移结果状态
|
||||
// ============================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SkillsMigrationPayload {
|
||||
pub count: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
static SKILLS_MIGRATION_RESULT: OnceLock<RwLock<Option<SkillsMigrationPayload>>> = OnceLock::new();
|
||||
|
||||
fn skills_migration_cell() -> &'static RwLock<Option<SkillsMigrationPayload>> {
|
||||
SKILLS_MIGRATION_RESULT.get_or_init(|| RwLock::new(None))
|
||||
}
|
||||
|
||||
pub fn set_skills_migration_result(count: usize) {
|
||||
if let Ok(mut guard) = skills_migration_cell().write() {
|
||||
*guard = Some(SkillsMigrationPayload { count, error: None });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_skills_migration_error(error: String) {
|
||||
if let Ok(mut guard) = skills_migration_cell().write() {
|
||||
*guard = Some(SkillsMigrationPayload {
|
||||
count: 0,
|
||||
error: Some(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取并消费 Skills 迁移结果(只返回一次 Some,之后返回 None)
|
||||
pub fn take_skills_migration_result() -> Option<SkillsMigrationPayload> {
|
||||
if let Ok(mut guard) = skills_migration_cell().write() {
|
||||
guard.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -9,7 +9,7 @@ mod config;
|
||||
mod database;
|
||||
mod deeplink;
|
||||
mod error;
|
||||
mod gemini_config; // 新增
|
||||
mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod init_status;
|
||||
mod mcp;
|
||||
@@ -17,9 +17,11 @@ mod prompt;
|
||||
mod prompt_files;
|
||||
mod provider;
|
||||
mod provider_defaults;
|
||||
mod proxy;
|
||||
mod services;
|
||||
mod settings;
|
||||
mod store;
|
||||
mod tray;
|
||||
mod usage_script;
|
||||
|
||||
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
||||
@@ -37,8 +39,8 @@ pub use mcp::{
|
||||
};
|
||||
pub use provider::{Provider, ProviderMeta};
|
||||
pub use services::{
|
||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SkillService,
|
||||
SpeedtestService,
|
||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
|
||||
SkillService, SpeedtestService,
|
||||
};
|
||||
pub use settings::{update_settings, AppSettings};
|
||||
pub use store::AppState;
|
||||
@@ -46,263 +48,12 @@ use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::{
|
||||
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
||||
tray::{TrayIconBuilder, TrayIconEvent},
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::{ActivationPolicy, RunEvent};
|
||||
use tauri::image::Image;
|
||||
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::RunEvent;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TrayTexts {
|
||||
show_main: &'static str,
|
||||
no_provider_hint: &'static str,
|
||||
quit: &'static str,
|
||||
}
|
||||
|
||||
impl TrayTexts {
|
||||
fn from_language(language: &str) -> Self {
|
||||
match language {
|
||||
"en" => Self {
|
||||
show_main: "Open main window",
|
||||
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||
quit: "Quit",
|
||||
},
|
||||
"ja" => Self {
|
||||
show_main: "メインウィンドウを開く",
|
||||
no_provider_hint:
|
||||
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
||||
quit: "終了",
|
||||
},
|
||||
_ => Self {
|
||||
show_main: "打开主界面",
|
||||
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||
quit: "退出",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TrayAppSection {
|
||||
app_type: AppType,
|
||||
prefix: &'static str,
|
||||
header_id: &'static str,
|
||||
empty_id: &'static str,
|
||||
header_label: &'static str,
|
||||
log_name: &'static str,
|
||||
}
|
||||
|
||||
const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
TrayAppSection {
|
||||
app_type: AppType::Claude,
|
||||
prefix: "claude_",
|
||||
header_id: "claude_header",
|
||||
empty_id: "claude_empty",
|
||||
header_label: "─── Claude ───",
|
||||
log_name: "Claude",
|
||||
},
|
||||
TrayAppSection {
|
||||
app_type: AppType::Codex,
|
||||
prefix: "codex_",
|
||||
header_id: "codex_header",
|
||||
empty_id: "codex_empty",
|
||||
header_label: "─── Codex ───",
|
||||
log_name: "Codex",
|
||||
},
|
||||
TrayAppSection {
|
||||
app_type: AppType::Gemini,
|
||||
prefix: "gemini_",
|
||||
header_id: "gemini_header",
|
||||
empty_id: "gemini_empty",
|
||||
header_label: "─── Gemini ───",
|
||||
log_name: "Gemini",
|
||||
},
|
||||
];
|
||||
|
||||
fn append_provider_section<'a>(
|
||||
app: &'a tauri::AppHandle,
|
||||
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
|
||||
manager: Option<&crate::provider::ProviderManager>,
|
||||
section: &TrayAppSection,
|
||||
tray_texts: &TrayTexts,
|
||||
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
||||
let Some(manager) = manager else {
|
||||
return Ok(menu_builder);
|
||||
};
|
||||
|
||||
let header = MenuItem::with_id(
|
||||
app,
|
||||
section.header_id,
|
||||
section.header_label,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
|
||||
menu_builder = menu_builder.item(&header);
|
||||
|
||||
if manager.providers.is_empty() {
|
||||
let empty_hint = MenuItem::with_id(
|
||||
app,
|
||||
section.empty_id,
|
||||
tray_texts.no_provider_hint,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
|
||||
return Ok(menu_builder.item(&empty_hint));
|
||||
}
|
||||
|
||||
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||
match (a.sort_index, b.sort_index) {
|
||||
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
||||
(Some(_), None) => return std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => return std::cmp::Ordering::Greater,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match (a.created_at, b.created_at) {
|
||||
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
|
||||
(Some(_), None) => return std::cmp::Ordering::Greater,
|
||||
(None, Some(_)) => return std::cmp::Ordering::Less,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
a.name.cmp(&b.name)
|
||||
});
|
||||
|
||||
for (id, provider) in sorted_providers {
|
||||
let is_current = manager.current == *id;
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("{}{}", section.prefix, id),
|
||||
&provider.name,
|
||||
true,
|
||||
is_current,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
|
||||
menu_builder = menu_builder.item(&item);
|
||||
}
|
||||
|
||||
Ok(menu_builder)
|
||||
}
|
||||
|
||||
fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
|
||||
log::info!("切换到{}供应商: {provider_id}", section.log_name);
|
||||
let app_handle = app.clone();
|
||||
let provider_id = provider_id.to_string();
|
||||
let app_type = section.app_type.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
|
||||
log::error!("切换{}供应商失败: {e}", section.log_name);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 创建动态托盘菜单
|
||||
fn create_tray_menu(
|
||||
app: &tauri::AppHandle,
|
||||
app_state: &AppState,
|
||||
) -> Result<Menu<tauri::Wry>, AppError> {
|
||||
let app_settings = crate::settings::get_settings();
|
||||
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
|
||||
|
||||
let mut menu_builder = MenuBuilder::new(app);
|
||||
|
||||
// 顶部:打开主界面
|
||||
let show_main_item =
|
||||
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
|
||||
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
|
||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||
|
||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
let app_type_str = section.app_type.as_str();
|
||||
let providers = app_state.db.get_all_providers(app_type_str)?;
|
||||
|
||||
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
|
||||
let current_id =
|
||||
crate::settings::get_effective_current_provider(&app_state.db, §ion.app_type)?
|
||||
.unwrap_or_default();
|
||||
|
||||
let manager = crate::provider::ProviderManager {
|
||||
providers,
|
||||
current: current_id,
|
||||
};
|
||||
|
||||
menu_builder =
|
||||
append_provider_section(app, menu_builder, Some(&manager), section, &tray_texts)?;
|
||||
}
|
||||
|
||||
// 分隔符和退出菜单
|
||||
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
||||
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
|
||||
|
||||
menu_builder = menu_builder.separator().item(&quit_item);
|
||||
|
||||
menu_builder
|
||||
.build()
|
||||
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
|
||||
let desired_policy = if dock_visible {
|
||||
ActivationPolicy::Regular
|
||||
} else {
|
||||
ActivationPolicy::Accessory
|
||||
};
|
||||
|
||||
if let Err(err) = app.set_dock_visibility(dock_visible) {
|
||||
log::warn!("设置 Dock 显示状态失败: {err}");
|
||||
}
|
||||
|
||||
if let Err(err) = app.set_activation_policy(desired_policy) {
|
||||
log::warn!("设置激活策略失败: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理托盘菜单事件
|
||||
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
log::info!("处理托盘菜单事件: {event_id}");
|
||||
|
||||
match event_id {
|
||||
"show_main" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = window.set_skip_taskbar(false);
|
||||
}
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
apply_tray_policy(app, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
log::info!("退出应用");
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {
|
||||
if handle_provider_tray_event(app, event_id) {
|
||||
return;
|
||||
}
|
||||
log::warn!("未处理的菜单事件: {event_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一处理 ccswitch:// 深链接 URL
|
||||
///
|
||||
/// - 解析 URL
|
||||
@@ -362,50 +113,13 @@ fn handle_deeplink_url(
|
||||
true
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// 内部切换供应商函数
|
||||
fn switch_provider_internal(
|
||||
app: &tauri::AppHandle,
|
||||
app_type: crate::app_config::AppType,
|
||||
provider_id: String,
|
||||
) -> Result<(), AppError> {
|
||||
if let Some(app_state) = app.try_state::<AppState>() {
|
||||
// 在使用前先保存需要的值
|
||||
let app_type_str = app_type.as_str().to_string();
|
||||
let provider_id_clone = provider_id.clone();
|
||||
|
||||
crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id)
|
||||
.map_err(AppError::Message)?;
|
||||
|
||||
// 切换成功后重新创建托盘菜单
|
||||
if let Ok(new_menu) = 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!("更新托盘菜单失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发射事件到前端,通知供应商已切换
|
||||
let event_data = serde_json::json!({
|
||||
"appType": app_type_str,
|
||||
"providerId": provider_id_clone
|
||||
});
|
||||
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||
log::error!("发射供应商切换事件失败: {e}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新托盘菜单的Tauri命令
|
||||
#[tauri::command]
|
||||
async fn update_tray_menu(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
match create_tray_menu(&app, state.inner()) {
|
||||
match tray::create_tray_menu(&app, state.inner()) {
|
||||
Ok(new_menu) => {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
tray.set_menu(Some(new_menu))
|
||||
@@ -421,6 +135,19 @@ 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)]
|
||||
pub fn run() {
|
||||
let mut builder = tauri::Builder::default();
|
||||
@@ -473,7 +200,7 @@ pub fn run() {
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
apply_tray_policy(window.app_handle(), false);
|
||||
tray::apply_tray_policy(window.app_handle(), false);
|
||||
}
|
||||
} else {
|
||||
window.app_handle().exit(0);
|
||||
@@ -496,44 +223,6 @@ pub fn run() {
|
||||
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) {
|
||||
app.handle().plugin(
|
||||
@@ -584,12 +273,25 @@ pub fn run() {
|
||||
None
|
||||
};
|
||||
|
||||
// 现在创建数据库
|
||||
let db = match crate::database::Database::init() {
|
||||
Ok(db) => Arc::new(db),
|
||||
Err(e) => {
|
||||
log::error!("Failed to init database: {e}");
|
||||
return Err(Box::new(e));
|
||||
// 现在创建数据库(包含 Schema 迁移)
|
||||
//
|
||||
// 说明:从 v3.8.* 升级的用户通常会走到这里的 SQLite schema 迁移,
|
||||
// 若迁移失败(数据库损坏/权限不足/user_version 过新等),需要给用户明确提示,
|
||||
// 否则表现可能只是“应用打不开/闪退”。
|
||||
let db = loop {
|
||||
match crate::database::Database::init() {
|
||||
Ok(db) => break Arc::new(db),
|
||||
Err(e) => {
|
||||
log::error!("Failed to init database: {e}");
|
||||
|
||||
if !show_database_init_error_dialog(app.handle(), &db_path, &e.to_string())
|
||||
{
|
||||
log::info!("用户选择退出程序");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
log::info!("用户选择重试初始化数据库");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -619,6 +321,9 @@ pub fn run() {
|
||||
|
||||
let app_state = AppState::new(db);
|
||||
|
||||
// 设置 AppHandle 用于代理故障转移时的 UI 更新
|
||||
app_state.proxy_service.set_app_handle(app.handle().clone());
|
||||
|
||||
// ============================================================
|
||||
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响)
|
||||
// ============================================================
|
||||
@@ -632,6 +337,47 @@ pub fn run() {
|
||||
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
|
||||
}
|
||||
|
||||
// 1.1. Skills 统一管理迁移:当数据库迁移到 v3 结构后,自动从各应用目录导入到 SSOT
|
||||
// 触发条件由 schema 迁移设置 settings.skills_ssot_migration_pending = true 控制。
|
||||
match app_state.db.get_setting("skills_ssot_migration_pending") {
|
||||
Ok(Some(flag)) if flag == "true" || flag == "1" => {
|
||||
// 安全保护:如果用户已经有 v3 结构的 Skills 数据,就不要自动清空重建。
|
||||
let has_existing = app_state
|
||||
.db
|
||||
.get_all_installed_skills()
|
||||
.map(|skills| !skills.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_existing {
|
||||
log::info!(
|
||||
"Detected skills_ssot_migration_pending but skills table not empty; skipping auto import."
|
||||
);
|
||||
let _ = app_state
|
||||
.db
|
||||
.set_setting("skills_ssot_migration_pending", "false");
|
||||
} else {
|
||||
match crate::services::skill::migrate_skills_to_ssot(&app_state.db) {
|
||||
Ok(count) => {
|
||||
log::info!("✓ Auto imported {count} skill(s) into SSOT");
|
||||
if count > 0 {
|
||||
crate::init_status::set_skills_migration_result(count);
|
||||
}
|
||||
let _ = app_state
|
||||
.db
|
||||
.set_setting("skills_ssot_migration_pending", "false");
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("✗ Failed to auto import legacy skills to SSOT: {e}");
|
||||
crate::init_status::set_skills_migration_error(e.to_string());
|
||||
// 保留 pending 标志,方便下次启动重试
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {} // 未开启迁移标志,静默跳过
|
||||
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
|
||||
}
|
||||
|
||||
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过)
|
||||
for app in [
|
||||
crate::app_config::AppType::Claude,
|
||||
@@ -644,6 +390,27 @@ pub fn run() {
|
||||
) {
|
||||
Ok(true) => {
|
||||
log::info!("✓ Imported default provider for {}", app.as_str());
|
||||
|
||||
// 首次运行:自动提取通用配置片段(仅当通用配置为空时)
|
||||
if app_state
|
||||
.db
|
||||
.get_config_snippet(app.as_str())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none()
|
||||
{
|
||||
match crate::services::provider::ProviderService::extract_common_config_snippet(&app_state, app.clone()) {
|
||||
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
||||
if let Err(e) = app_state.db.set_config_snippet(app.as_str(), Some(snippet)) {
|
||||
log::warn!("✗ Failed to save common config snippet for {}: {e}", app.as_str());
|
||||
} else {
|
||||
log::info!("✓ Extracted common config snippet for {}", app.as_str());
|
||||
}
|
||||
}
|
||||
Ok(_) => log::debug!("○ No common config to extract for {}", app.as_str()),
|
||||
Err(e) => log::debug!("○ Failed to extract common config for {}: {e}", app.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => {} // 已有供应商,静默跳过
|
||||
Err(e) => {
|
||||
@@ -773,7 +540,7 @@ pub fn run() {
|
||||
log::info!("✓ Deep-link URL handler registered");
|
||||
|
||||
// 创建动态托盘菜单
|
||||
let menu = create_tray_menu(app.handle(), &app_state)?;
|
||||
let menu = tray::create_tray_menu(app.handle(), &app_state)?;
|
||||
|
||||
// 构建托盘
|
||||
let mut tray_builder = TrayIconBuilder::with_id("main")
|
||||
@@ -784,15 +551,30 @@ pub fn run() {
|
||||
})
|
||||
.menu(&menu)
|
||||
.on_menu_event(|app, event| {
|
||||
handle_tray_menu_event(app, &event.id.0);
|
||||
tray::handle_tray_menu_event(app, &event.id.0);
|
||||
})
|
||||
.show_menu_on_left_click(true);
|
||||
|
||||
// 统一使用应用默认图标;待托盘模板图标就绪后再启用
|
||||
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");
|
||||
// 使用平台对应的托盘图标(macOS 使用模板图标适配深浅色)
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(icon) = macos_tray_icon() {
|
||||
tray_builder = tray_builder.icon(icon).icon_as_template(true);
|
||||
} 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)?;
|
||||
@@ -800,14 +582,37 @@ pub fn run() {
|
||||
app.manage(app_state);
|
||||
|
||||
// 初始化 SkillService
|
||||
match SkillService::new() {
|
||||
Ok(skill_service) => {
|
||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||
let skill_service = SkillService::new();
|
||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||
|
||||
// 异常退出恢复 + 代理状态自动恢复
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app_handle.state::<AppState>();
|
||||
|
||||
// 检查是否有 Live 备份(表示上次异常退出时可能处于接管状态)
|
||||
let has_backups = match state.db.has_any_live_backup().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::error!("检查 Live 备份失败: {e}");
|
||||
false
|
||||
}
|
||||
};
|
||||
// 检查 Live 配置是否仍处于被接管状态(包含占位符)
|
||||
let live_taken_over = state.proxy_service.detect_takeover_in_live_configs();
|
||||
|
||||
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::warn!("初始化 SkillService 失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||
restore_proxy_state_on_startup(&state).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -828,12 +633,14 @@ pub fn run() {
|
||||
commands::open_external,
|
||||
commands::get_init_error,
|
||||
commands::get_migration_result,
|
||||
commands::get_skills_migration_result,
|
||||
commands::get_app_config_path,
|
||||
commands::open_app_config_folder,
|
||||
commands::get_claude_common_config_snippet,
|
||||
commands::set_claude_common_config_snippet,
|
||||
commands::get_common_config_snippet,
|
||||
commands::set_common_config_snippet,
|
||||
commands::extract_common_config_snippet,
|
||||
commands::read_live_provider_settings,
|
||||
commands::get_settings,
|
||||
commands::save_settings,
|
||||
@@ -844,6 +651,8 @@ pub fn run() {
|
||||
commands::read_claude_plugin_config,
|
||||
commands::apply_claude_plugin_config,
|
||||
commands::is_claude_plugin_applied,
|
||||
commands::apply_claude_onboarding_skip,
|
||||
commands::clear_claude_onboarding_skip,
|
||||
// Claude MCP management
|
||||
commands::get_claude_mcp_status,
|
||||
commands::read_claude_mcp_config,
|
||||
@@ -858,11 +667,12 @@ pub fn run() {
|
||||
commands::upsert_mcp_server_in_config,
|
||||
commands::delete_mcp_server_in_config,
|
||||
commands::set_mcp_enabled,
|
||||
// v3.7.0: Unified MCP management
|
||||
// Unified MCP management
|
||||
commands::get_mcp_servers,
|
||||
commands::upsert_mcp_server,
|
||||
commands::delete_mcp_server,
|
||||
commands::toggle_mcp_app,
|
||||
commands::import_mcp_from_apps,
|
||||
// Prompt management
|
||||
commands::get_prompts,
|
||||
commands::upsert_prompt,
|
||||
@@ -897,16 +707,79 @@ pub fn run() {
|
||||
commands::check_env_conflicts,
|
||||
commands::delete_env_vars,
|
||||
commands::restore_env_backup,
|
||||
// Skill management
|
||||
// Skill management (v3.10.0+ unified)
|
||||
commands::get_installed_skills,
|
||||
commands::install_skill_unified,
|
||||
commands::uninstall_skill_unified,
|
||||
commands::toggle_skill_app,
|
||||
commands::scan_unmanaged_skills,
|
||||
commands::import_skills_from_apps,
|
||||
commands::discover_available_skills,
|
||||
// Skill management (legacy API compatibility)
|
||||
commands::get_skills,
|
||||
commands::get_skills_for_app,
|
||||
commands::install_skill,
|
||||
commands::install_skill_for_app,
|
||||
commands::uninstall_skill,
|
||||
commands::uninstall_skill_for_app,
|
||||
commands::get_skill_repos,
|
||||
commands::add_skill_repo,
|
||||
commands::remove_skill_repo,
|
||||
// Auto launch
|
||||
commands::set_auto_launch,
|
||||
commands::get_auto_launch_status,
|
||||
// Proxy server management
|
||||
commands::start_proxy_server,
|
||||
commands::stop_proxy_with_restore,
|
||||
commands::get_proxy_takeover_status,
|
||||
commands::set_proxy_takeover_for_app,
|
||||
commands::get_proxy_status,
|
||||
commands::get_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_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
|
||||
commands::get_usage_summary,
|
||||
commands::get_usage_trends,
|
||||
commands::get_provider_stats,
|
||||
commands::get_model_stats,
|
||||
commands::get_request_logs,
|
||||
commands::get_request_detail,
|
||||
commands::get_model_pricing,
|
||||
commands::update_model_pricing,
|
||||
commands::delete_model_pricing,
|
||||
commands::check_provider_limits,
|
||||
// Stream health check
|
||||
commands::stream_check_provider,
|
||||
commands::stream_check_all_providers,
|
||||
commands::get_stream_check_config,
|
||||
commands::save_stream_check_config,
|
||||
commands::get_tool_versions,
|
||||
// Universal Provider management
|
||||
commands::get_universal_providers,
|
||||
commands::get_universal_provider,
|
||||
commands::upsert_universal_provider,
|
||||
commands::delete_universal_provider,
|
||||
commands::sync_universal_provider,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
@@ -914,6 +787,26 @@ pub fn run() {
|
||||
.expect("error while running tauri application");
|
||||
|
||||
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")]
|
||||
{
|
||||
match event {
|
||||
@@ -927,7 +820,7 @@ pub fn run() {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
apply_tray_policy(app_handle, true);
|
||||
tray::apply_tray_policy(app_handle, true);
|
||||
}
|
||||
}
|
||||
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)
|
||||
@@ -993,6 +886,103 @@ 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 迁移错误对话框辅助函数
|
||||
// ============================================================
|
||||
@@ -1056,3 +1046,68 @@ fn show_migration_error_dialog(app: &tauri::AppHandle, error: &str) -> bool {
|
||||
))
|
||||
.blocking_show()
|
||||
}
|
||||
|
||||
/// 显示数据库初始化/Schema 迁移失败对话框
|
||||
/// 返回 true 表示用户选择重试,false 表示用户选择退出
|
||||
fn show_database_init_error_dialog(
|
||||
app: &tauri::AppHandle,
|
||||
db_path: &std::path::Path,
|
||||
error: &str,
|
||||
) -> bool {
|
||||
let title = if is_chinese_locale() {
|
||||
"数据库初始化失败"
|
||||
} else {
|
||||
"Database Initialization Failed"
|
||||
};
|
||||
|
||||
let message = if is_chinese_locale() {
|
||||
format!(
|
||||
"初始化数据库或迁移数据库结构时发生错误:\n\n{error}\n\n\
|
||||
数据库文件路径:\n{db}\n\n\
|
||||
您的数据尚未丢失,应用不会自动删除数据库文件。\n\
|
||||
常见原因包括:数据库版本过新、文件损坏、权限不足、磁盘空间不足等。\n\n\
|
||||
建议:\n\
|
||||
1) 先备份整个配置目录(包含 cc-switch.db)\n\
|
||||
2) 如果提示“数据库版本过新”,请升级到更新版本\n\
|
||||
3) 如果刚升级出现异常,可回退旧版本导出/备份后再升级\n\n\
|
||||
点击「重试」重新尝试初始化\n\
|
||||
点击「退出」关闭程序",
|
||||
db = db_path.display()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"An error occurred while initializing or migrating the database:\n\n{error}\n\n\
|
||||
Database file path:\n{db}\n\n\
|
||||
Your data is NOT lost - the app will not delete the database automatically.\n\
|
||||
Common causes include: newer database version, corrupted file, permission issues, or low disk space.\n\n\
|
||||
Suggestions:\n\
|
||||
1) Back up the entire config directory (including cc-switch.db)\n\
|
||||
2) If you see “database version is newer”, please upgrade CC Switch\n\
|
||||
3) If this happened right after upgrading, consider rolling back to export/backup then upgrade again\n\n\
|
||||
Click 'Retry' to attempt initialization again\n\
|
||||
Click 'Exit' to close the program",
|
||||
db = db_path.display()
|
||||
)
|
||||
};
|
||||
|
||||
let retry_text = if is_chinese_locale() {
|
||||
"重试"
|
||||
} else {
|
||||
"Retry"
|
||||
};
|
||||
let exit_text = if is_chinese_locale() {
|
||||
"退出"
|
||||
} else {
|
||||
"Exit"
|
||||
};
|
||||
|
||||
app.dialog()
|
||||
.message(&message)
|
||||
.title(title)
|
||||
.kind(MessageDialogKind::Error)
|
||||
.buttons(MessageDialogButtons::OkCancelCustom(
|
||||
retry_text.to_string(),
|
||||
exit_text.to_string(),
|
||||
))
|
||||
.blocking_show()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Claude MCP 同步和导入模块
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
|
||||
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)
|
||||
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
|
||||
let mut out = HashMap::new();
|
||||
for (id, entry) in cfg.servers.iter() {
|
||||
let enabled = entry
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
match extract_server_spec(entry) {
|
||||
Ok(spec) => {
|
||||
out.insert(id.clone(), spec);
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("跳过无效的 MCP 条目 '{id}': {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
|
||||
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);
|
||||
crate::claude_mcp::set_mcp_servers_map(&enabled)
|
||||
}
|
||||
|
||||
/// 从 ~/.claude.json 导入 mcpServers 到统一结构(v3.7.0+)
|
||||
/// 已存在的服务器将启用 Claude 应用,不覆盖其他字段和应用状态
|
||||
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
let text_opt = crate::claude_mcp::read_mcp_json()?;
|
||||
let Some(text) = text_opt else { return Ok(0) };
|
||||
|
||||
let v: Value = serde_json::from_str(&text)
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {e}")))?;
|
||||
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
// 确保新结构存在
|
||||
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||
|
||||
let mut changed = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (id, spec) in map.iter() {
|
||||
// 校验:单项失败不中止,收集错误继续处理
|
||||
if let Err(e) = validate_server_spec(spec) {
|
||||
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
|
||||
errors.push(format!("{id}: {e}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = servers.get_mut(id) {
|
||||
// 已存在:仅启用 Claude 应用
|
||||
if !existing.apps.claude {
|
||||
existing.apps.claude = true;
|
||||
changed += 1;
|
||||
log::info!("MCP 服务器 '{id}' 已启用 Claude 应用");
|
||||
}
|
||||
} else {
|
||||
// 新建服务器:默认仅启用 Claude
|
||||
servers.insert(
|
||||
id.clone(),
|
||||
McpServer {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
server: spec.clone(),
|
||||
apps: McpApps {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
changed += 1;
|
||||
log::info!("导入新 MCP 服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// 将单个 MCP 服务器同步到 Claude live 配置
|
||||
pub fn sync_single_server_to_claude(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
if !should_sync_claude_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
// 读取现有的 MCP 配置
|
||||
let current = crate::claude_mcp::read_mcp_servers_map()?;
|
||||
|
||||
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
|
||||
let mut updated = current;
|
||||
updated.insert(id.to_string(), server_spec.clone());
|
||||
|
||||
// 写回
|
||||
crate::claude_mcp::set_mcp_servers_map(&updated)
|
||||
}
|
||||
|
||||
/// 从 Claude live 配置中移除单个 MCP 服务器
|
||||
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
|
||||
if !should_sync_claude_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
// 读取现有的 MCP 配置
|
||||
let mut current = crate::claude_mcp::read_mcp_servers_map()?;
|
||||
|
||||
// 移除指定服务器
|
||||
current.remove(id);
|
||||
|
||||
// 写回
|
||||
crate::claude_mcp::set_mcp_servers_map(¤t)
|
||||
}
|
||||
@@ -1,200 +1,22 @@
|
||||
//! Codex MCP 同步和导入模块
|
||||
//!
|
||||
//! 包含 Codex 的 MCP 配置管理:
|
||||
//! - 从 ~/.codex/config.toml 导入
|
||||
//! - 同步到 ~/.codex/config.toml
|
||||
//! - JSON 到 TOML 的转换逻辑
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::app_config::{AppType, McpConfig, MultiAppConfig};
|
||||
use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
|
||||
/// 基础校验:允许 stdio/http/sse;或省略 type(视为 stdio)。对应必填字段存在
|
||||
fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
|
||||
if !spec.is_object() {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器连接定义必须为 JSON 对象".into(),
|
||||
));
|
||||
}
|
||||
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
||||
// 支持三种:stdio/http/sse;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
|
||||
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true);
|
||||
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
||||
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
|
||||
use super::validation::{extract_server_spec, validate_server_spec};
|
||||
|
||||
if !(is_stdio || is_http || is_sse) {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if is_stdio {
|
||||
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if cmd.trim().is_empty() {
|
||||
return Err(AppError::McpValidation(
|
||||
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if is_http {
|
||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if url.trim().is_empty() {
|
||||
return Err(AppError::McpValidation(
|
||||
"http 类型的 MCP 服务器缺少 url 字段".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if is_sse {
|
||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if url.trim().is_empty() {
|
||||
return Err(AppError::McpValidation(
|
||||
"sse 类型的 MCP 服务器缺少 url 字段".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // v3.7.0: 旧的验证逻辑,保留用于未来可能的迁移
|
||||
fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> {
|
||||
let obj = entry
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
|
||||
|
||||
let server = obj
|
||||
.get("server")
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
|
||||
validate_server_spec(server)?;
|
||||
|
||||
for key in ["name", "description", "homepage", "docs"] {
|
||||
if let Some(val) = obj.get(key) {
|
||||
if !val.is_string() {
|
||||
return Err(AppError::McpValidation(format!(
|
||||
"MCP 服务器 {key} 必须为字符串"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tags) = obj.get("tags") {
|
||||
let arr = tags
|
||||
.as_array()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器 tags 必须为字符串数组".into()))?;
|
||||
if !arr.iter().all(|item| item.is_string()) {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器 tags 必须为字符串数组".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(enabled) = obj.get("enabled") {
|
||||
if !enabled.is_boolean() {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器 enabled 必须为布尔值".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_server_keys(map: &mut HashMap<String, Value>) -> usize {
|
||||
let mut change_count = 0usize;
|
||||
let mut renames: Vec<(String, String)> = Vec::new();
|
||||
|
||||
for (key_ref, value) in map.iter_mut() {
|
||||
let key = key_ref.clone();
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let id_value = obj.get("id").cloned();
|
||||
|
||||
let target_id: String;
|
||||
|
||||
match id_value {
|
||||
Some(id_val) => match id_val.as_str() {
|
||||
Some(id_str) => {
|
||||
let trimmed = id_str.trim();
|
||||
if trimmed.is_empty() {
|
||||
obj.insert("id".into(), json!(key.clone()));
|
||||
change_count += 1;
|
||||
target_id = key.clone();
|
||||
} else {
|
||||
if trimmed != id_str {
|
||||
obj.insert("id".into(), json!(trimmed));
|
||||
change_count += 1;
|
||||
}
|
||||
target_id = trimmed.to_string();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
obj.insert("id".into(), json!(key.clone()));
|
||||
change_count += 1;
|
||||
target_id = key.clone();
|
||||
}
|
||||
},
|
||||
None => {
|
||||
obj.insert("id".into(), json!(key.clone()));
|
||||
change_count += 1;
|
||||
target_id = key.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if target_id != key {
|
||||
renames.push((key, target_id));
|
||||
}
|
||||
}
|
||||
|
||||
for (old_key, new_key) in renames {
|
||||
if old_key == new_key {
|
||||
continue;
|
||||
}
|
||||
if map.contains_key(&new_key) {
|
||||
log::warn!("MCP 条目 '{old_key}' 的内部 id '{new_key}' 与现有键冲突,回退为原键");
|
||||
if let Some(value) = map.get_mut(&old_key) {
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
if obj
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s != old_key)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
obj.insert("id".into(), json!(old_key.clone()));
|
||||
change_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(mut value) = map.remove(&old_key) {
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert("id".into(), json!(new_key.clone()));
|
||||
}
|
||||
log::info!("MCP 条目键名已自动修复: '{old_key}' -> '{new_key}'");
|
||||
map.insert(new_key, value);
|
||||
change_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
change_count
|
||||
}
|
||||
|
||||
pub fn normalize_servers_for(config: &mut MultiAppConfig, app: &AppType) -> usize {
|
||||
let servers = &mut config.mcp_for_mut(app).servers;
|
||||
normalize_server_keys(servers)
|
||||
}
|
||||
|
||||
fn extract_server_spec(entry: &Value) -> Result<Value, AppError> {
|
||||
let obj = entry
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
|
||||
let server = obj
|
||||
.get("server")
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
|
||||
|
||||
if !server.is_object() {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器 server 字段必须为 JSON 对象".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(server.clone())
|
||||
fn should_sync_codex_mcp() -> bool {
|
||||
// Codex 未安装/未初始化时:~/.codex 目录不存在。
|
||||
// 按用户偏好:目录缺失时跳过写入/删除,不创建任何文件或目录。
|
||||
crate::codex_config::get_codex_config_dir().exists()
|
||||
}
|
||||
|
||||
/// 返回已启用的 MCP 服务器(过滤 enabled==true)
|
||||
@@ -220,185 +42,6 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
|
||||
out
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||
pub fn get_servers_snapshot_for(
|
||||
config: &mut MultiAppConfig,
|
||||
app: &AppType,
|
||||
) -> (HashMap<String, Value>, usize) {
|
||||
let normalized = normalize_servers_for(config, app);
|
||||
let mut snapshot = config.mcp_for(app).servers.clone();
|
||||
snapshot.retain(|id, value| {
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
log::warn!("跳过无效的 MCP 条目 '{id}': 必须为 JSON 对象");
|
||||
return false;
|
||||
};
|
||||
|
||||
obj.entry(String::from("id")).or_insert(json!(id));
|
||||
|
||||
match validate_mcp_entry(value) {
|
||||
Ok(()) => true,
|
||||
Err(err) => {
|
||||
log::error!("config.json 中存在无效的 MCP 条目 '{id}': {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
});
|
||||
(snapshot, normalized)
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||
pub fn upsert_in_config_for(
|
||||
config: &mut MultiAppConfig,
|
||||
app: &AppType,
|
||||
id: &str,
|
||||
spec: Value,
|
||||
) -> Result<bool, AppError> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||
}
|
||||
normalize_servers_for(config, app);
|
||||
validate_mcp_entry(&spec)?;
|
||||
|
||||
let mut entry_obj = spec
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
|
||||
if let Some(existing_id) = entry_obj.get("id") {
|
||||
let Some(existing_id_str) = existing_id.as_str() else {
|
||||
return Err(AppError::McpValidation("MCP 服务器 id 必须为字符串".into()));
|
||||
};
|
||||
if existing_id_str != id {
|
||||
return Err(AppError::McpValidation(format!(
|
||||
"MCP 服务器条目中的 id '{existing_id_str}' 与参数 id '{id}' 不一致"
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
entry_obj.insert(String::from("id"), json!(id));
|
||||
}
|
||||
|
||||
let value = Value::Object(entry_obj);
|
||||
|
||||
let servers = &mut config.mcp_for_mut(app).servers;
|
||||
let before = servers.get(id).cloned();
|
||||
servers.insert(id.to_string(), value);
|
||||
|
||||
Ok(before.is_none())
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||
pub fn delete_in_config_for(
|
||||
config: &mut MultiAppConfig,
|
||||
app: &AppType,
|
||||
id: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||
}
|
||||
normalize_servers_for(config, app);
|
||||
let existed = config.mcp_for_mut(app).servers.remove(id).is_some();
|
||||
Ok(existed)
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||
/// 设置启用状态(不执行落盘或文件同步)
|
||||
pub fn set_enabled_flag_for(
|
||||
config: &mut MultiAppConfig,
|
||||
app: &AppType,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<bool, AppError> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||
}
|
||||
normalize_servers_for(config, app);
|
||||
if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) {
|
||||
// 写入 enabled 字段
|
||||
let mut obj = spec
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器定义必须为 JSON 对象".into()))?;
|
||||
obj.insert("enabled".into(), json!(enabled));
|
||||
*spec = Value::Object(obj);
|
||||
} else {
|
||||
// 若不存在则直接返回 false
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
|
||||
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||
let enabled = collect_enabled_servers(&config.mcp.claude);
|
||||
crate::claude_mcp::set_mcp_servers_map(&enabled)
|
||||
}
|
||||
|
||||
/// 从 ~/.claude.json 导入 mcpServers 到统一结构(v3.7.0+)
|
||||
/// 已存在的服务器将启用 Claude 应用,不覆盖其他字段和应用状态
|
||||
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
use crate::app_config::{McpApps, McpServer};
|
||||
|
||||
let text_opt = crate::claude_mcp::read_mcp_json()?;
|
||||
let Some(text) = text_opt else { return Ok(0) };
|
||||
|
||||
let v: Value = serde_json::from_str(&text)
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {e}")))?;
|
||||
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
// 确保新结构存在
|
||||
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||
|
||||
let mut changed = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (id, spec) in map.iter() {
|
||||
// 校验:单项失败不中止,收集错误继续处理
|
||||
if let Err(e) = validate_server_spec(spec) {
|
||||
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
|
||||
errors.push(format!("{id}: {e}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = servers.get_mut(id) {
|
||||
// 已存在:仅启用 Claude 应用
|
||||
if !existing.apps.claude {
|
||||
existing.apps.claude = true;
|
||||
changed += 1;
|
||||
log::info!("MCP 服务器 '{id}' 已启用 Claude 应用");
|
||||
}
|
||||
} else {
|
||||
// 新建服务器:默认仅启用 Claude
|
||||
servers.insert(
|
||||
id.clone(),
|
||||
McpServer {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
server: spec.clone(),
|
||||
apps: McpApps {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
changed += 1;
|
||||
log::info!("导入新 MCP 服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// 从 ~/.codex/config.toml 导入 MCP 到统一结构(v3.7.0+)
|
||||
///
|
||||
/// 格式支持:
|
||||
@@ -407,8 +50,6 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
///
|
||||
/// 已存在的服务器将启用 Codex 应用,不覆盖其他字段和应用状态
|
||||
pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
use crate::app_config::{McpApps, McpServer};
|
||||
|
||||
let text = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||
if text.trim().is_empty() {
|
||||
return Ok(0);
|
||||
@@ -638,6 +279,9 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||
/// - 仅更新 `mcp_servers` 表,保留其它键
|
||||
/// - 仅写入启用项;无启用项时清理 mcp_servers 表
|
||||
pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||
if !should_sync_codex_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
use toml_edit::{Item, Table};
|
||||
|
||||
// 1) 收集启用项(Codex 维度)
|
||||
@@ -697,111 +341,113 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将 config.json 中 enabled==true 的项投影写入 ~/.gemini/settings.json
|
||||
pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||
let enabled = collect_enabled_servers(&config.mcp.gemini);
|
||||
crate::gemini_mcp::set_mcp_servers_map(&enabled)
|
||||
}
|
||||
|
||||
/// 从 ~/.gemini/settings.json 导入 mcpServers 到统一结构(v3.7.0+)
|
||||
/// 已存在的服务器将启用 Gemini 应用,不覆盖其他字段和应用状态
|
||||
pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
use crate::app_config::{McpApps, McpServer};
|
||||
|
||||
let text_opt = crate::gemini_mcp::read_mcp_json()?;
|
||||
let Some(text) = text_opt else { return Ok(0) };
|
||||
|
||||
let v: Value = serde_json::from_str(&text)
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 ~/.gemini/settings.json 失败: {e}")))?;
|
||||
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
// 确保新结构存在
|
||||
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||
|
||||
let mut changed = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (id, spec) in map.iter() {
|
||||
// 校验:单项失败不中止,收集错误继续处理
|
||||
if let Err(e) = validate_server_spec(spec) {
|
||||
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
|
||||
errors.push(format!("{id}: {e}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = servers.get_mut(id) {
|
||||
// 已存在:仅启用 Gemini 应用
|
||||
if !existing.apps.gemini {
|
||||
existing.apps.gemini = true;
|
||||
changed += 1;
|
||||
log::info!("MCP 服务器 '{id}' 已启用 Gemini 应用");
|
||||
}
|
||||
} else {
|
||||
// 新建服务器:默认仅启用 Gemini
|
||||
servers.insert(
|
||||
id.clone(),
|
||||
McpServer {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
server: spec.clone(),
|
||||
apps: McpApps {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: true,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
changed += 1;
|
||||
log::info!("导入新 MCP 服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// v3.7.0 新增:单个服务器同步和删除函数
|
||||
// ============================================================================
|
||||
|
||||
/// 将单个 MCP 服务器同步到 Claude live 配置
|
||||
pub fn sync_single_server_to_claude(
|
||||
/// 将单个 MCP 服务器同步到 Codex live 配置
|
||||
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
|
||||
pub fn sync_single_server_to_codex(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
// 读取现有的 MCP 配置
|
||||
let current = crate::claude_mcp::read_mcp_servers_map()?;
|
||||
if !should_sync_codex_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
use toml_edit::Item;
|
||||
|
||||
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
|
||||
let mut updated = current;
|
||||
updated.insert(id.to_string(), server_spec.clone());
|
||||
// 读取现有的 config.toml
|
||||
let config_path = crate::codex_config::get_codex_config_path();
|
||||
|
||||
// 写回
|
||||
crate::claude_mcp::set_mcp_servers_map(&updated)
|
||||
let mut doc = if config_path.exists() {
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
// 尝试解析现有配置,如果失败则创建新文档(容错处理)
|
||||
match content.parse::<toml_edit::DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(e) => {
|
||||
log::warn!("解析 Codex config.toml 失败: {e},将创建新配置");
|
||||
toml_edit::DocumentMut::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toml_edit::DocumentMut::new()
|
||||
};
|
||||
|
||||
// 清理可能存在的错误格式 [mcp.servers]
|
||||
if let Some(mcp_item) = doc.get_mut("mcp") {
|
||||
if let Some(tbl) = mcp_item.as_table_like_mut() {
|
||||
if tbl.contains_key("servers") {
|
||||
log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
|
||||
tbl.remove("servers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 [mcp_servers] 表存在
|
||||
if !doc.contains_key("mcp_servers") {
|
||||
doc["mcp_servers"] = toml_edit::table();
|
||||
}
|
||||
|
||||
// 将 JSON 服务器规范转换为 TOML 表
|
||||
let toml_table = json_server_to_toml_table(server_spec)?;
|
||||
|
||||
// 使用唯一正确的格式:[mcp_servers]
|
||||
doc["mcp_servers"][id] = Item::Table(toml_table);
|
||||
|
||||
// 写回文件
|
||||
let new_text = doc.to_string();
|
||||
crate::config::write_text_file(&config_path, &new_text)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 Claude live 配置中移除单个 MCP 服务器
|
||||
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
|
||||
// 读取现有的 MCP 配置
|
||||
let mut current = crate::claude_mcp::read_mcp_servers_map()?;
|
||||
/// 从 Codex live 配置中移除单个 MCP 服务器
|
||||
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
|
||||
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();
|
||||
|
||||
// 移除指定服务器
|
||||
current.remove(id);
|
||||
if !config_path.exists() {
|
||||
return Ok(()); // 文件不存在,无需删除
|
||||
}
|
||||
|
||||
// 写回
|
||||
crate::claude_mcp::set_mcp_servers_map(¤t)
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
|
||||
// 尝试解析现有配置,如果失败则直接返回(无法删除不存在的内容)
|
||||
let mut doc = match content.parse::<toml_edit::DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(e) => {
|
||||
log::warn!("解析 Codex config.toml 失败: {e},跳过删除操作");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// 从正确的位置删除:[mcp_servers]
|
||||
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
|
||||
mcp_servers.remove(id);
|
||||
}
|
||||
|
||||
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
|
||||
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
|
||||
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
|
||||
if servers.remove(id).is_some() {
|
||||
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 写回文件
|
||||
let new_text = doc.to_string();
|
||||
crate::config::write_text_file(&config_path, &new_text)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TOML 转换辅助函数
|
||||
// ============================================================================
|
||||
|
||||
/// 通用 JSON 值到 TOML 值转换器(支持简单类型和浅层嵌套)
|
||||
///
|
||||
/// 支持的类型转换:
|
||||
@@ -1030,117 +676,3 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
|
||||
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// 将单个 MCP 服务器同步到 Codex live 配置
|
||||
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
|
||||
pub fn sync_single_server_to_codex(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
use toml_edit::Item;
|
||||
|
||||
// 读取现有的 config.toml
|
||||
let config_path = crate::codex_config::get_codex_config_path();
|
||||
|
||||
let mut doc = if config_path.exists() {
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
content
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
|
||||
} else {
|
||||
toml_edit::DocumentMut::new()
|
||||
};
|
||||
|
||||
// 清理可能存在的错误格式 [mcp.servers]
|
||||
if let Some(mcp_item) = doc.get_mut("mcp") {
|
||||
if let Some(tbl) = mcp_item.as_table_like_mut() {
|
||||
if tbl.contains_key("servers") {
|
||||
log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
|
||||
tbl.remove("servers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 [mcp_servers] 表存在
|
||||
if !doc.contains_key("mcp_servers") {
|
||||
doc["mcp_servers"] = toml_edit::table();
|
||||
}
|
||||
|
||||
// 将 JSON 服务器规范转换为 TOML 表
|
||||
let toml_table = json_server_to_toml_table(server_spec)?;
|
||||
|
||||
// 使用唯一正确的格式:[mcp_servers]
|
||||
doc["mcp_servers"][id] = Item::Table(toml_table);
|
||||
|
||||
// 写回文件
|
||||
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 Codex live 配置中移除单个 MCP 服务器
|
||||
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
|
||||
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
||||
let config_path = crate::codex_config::get_codex_config_path();
|
||||
|
||||
if !config_path.exists() {
|
||||
return Ok(()); // 文件不存在,无需删除
|
||||
}
|
||||
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
|
||||
let mut doc = content
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
|
||||
|
||||
// 从正确的位置删除:[mcp_servers]
|
||||
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
|
||||
mcp_servers.remove(id);
|
||||
}
|
||||
|
||||
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
|
||||
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
|
||||
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
|
||||
if servers.remove(id).is_some() {
|
||||
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 写回文件
|
||||
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将单个 MCP 服务器同步到 Gemini live 配置
|
||||
pub fn sync_single_server_to_gemini(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
// 读取现有的 MCP 配置
|
||||
let current = crate::gemini_mcp::read_mcp_servers_map()?;
|
||||
|
||||
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
|
||||
let mut updated = current;
|
||||
updated.insert(id.to_string(), server_spec.clone());
|
||||
|
||||
// 写回
|
||||
crate::gemini_mcp::set_mcp_servers_map(&updated)
|
||||
}
|
||||
|
||||
/// 从 Gemini live 配置中移除单个 MCP 服务器
|
||||
pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
|
||||
// 读取现有的 MCP 配置
|
||||
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
|
||||
|
||||
// 移除指定服务器
|
||||
current.remove(id);
|
||||
|
||||
// 写回
|
||||
crate::gemini_mcp::set_mcp_servers_map(¤t)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Gemini MCP 同步和导入模块
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
|
||||
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)
|
||||
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
|
||||
let mut out = HashMap::new();
|
||||
for (id, entry) in cfg.servers.iter() {
|
||||
let enabled = entry
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
match extract_server_spec(entry) {
|
||||
Ok(spec) => {
|
||||
out.insert(id.clone(), spec);
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("跳过无效的 MCP 条目 '{id}': {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 将 config.json 中 Gemini 的 enabled==true 项写入 Gemini MCP 配置
|
||||
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);
|
||||
crate::gemini_mcp::set_mcp_servers_map(&enabled)
|
||||
}
|
||||
|
||||
/// 从 Gemini MCP 配置导入到统一结构(v3.7.0+)
|
||||
/// 已存在的服务器将启用 Gemini 应用,不覆盖其他字段和应用状态
|
||||
pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
let map = crate::gemini_mcp::read_mcp_servers_map()?;
|
||||
if map.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// 确保新结构存在
|
||||
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||
|
||||
let mut changed = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (id, spec) in map.iter() {
|
||||
// 校验:单项失败不中止,收集错误继续处理
|
||||
if let Err(e) = validate_server_spec(spec) {
|
||||
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
|
||||
errors.push(format!("{id}: {e}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = servers.get_mut(id) {
|
||||
// 已存在:仅启用 Gemini 应用
|
||||
if !existing.apps.gemini {
|
||||
existing.apps.gemini = true;
|
||||
changed += 1;
|
||||
log::info!("MCP 服务器 '{id}' 已启用 Gemini 应用");
|
||||
}
|
||||
} else {
|
||||
// 新建服务器:默认仅启用 Gemini
|
||||
servers.insert(
|
||||
id.clone(),
|
||||
McpServer {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
server: spec.clone(),
|
||||
apps: McpApps {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: true,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
changed += 1;
|
||||
log::info!("导入新 MCP 服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// 将单个 MCP 服务器同步到 Gemini live 配置
|
||||
pub fn sync_single_server_to_gemini(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
if !should_sync_gemini_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
// 读取现有的 MCP 配置
|
||||
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
|
||||
|
||||
// 添加/更新当前服务器
|
||||
current.insert(id.to_string(), server_spec.clone());
|
||||
|
||||
// 写回
|
||||
crate::gemini_mcp::set_mcp_servers_map(¤t)
|
||||
}
|
||||
|
||||
/// 从 Gemini live 配置中移除单个 MCP 服务器
|
||||
pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
|
||||
if !should_sync_gemini_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
// 读取现有的 MCP 配置
|
||||
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
|
||||
|
||||
// 移除指定服务器
|
||||
current.remove(id);
|
||||
|
||||
// 写回
|
||||
crate::gemini_mcp::set_mcp_servers_map(¤t)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! MCP (Model Context Protocol) 服务器管理模块
|
||||
//!
|
||||
//! 本模块负责 MCP 服务器配置的验证、同步和导入导出。
|
||||
//!
|
||||
//! ## 模块结构
|
||||
//!
|
||||
//! - `validation` - 服务器配置验证
|
||||
//! - `claude` - Claude MCP 同步和导入
|
||||
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
|
||||
//! - `gemini` - Gemini MCP 同步和导入
|
||||
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
mod validation;
|
||||
|
||||
// 重新导出公共 API
|
||||
pub use claude::{
|
||||
import_from_claude, remove_server_from_claude, sync_enabled_to_claude,
|
||||
sync_single_server_to_claude,
|
||||
};
|
||||
pub use codex::{
|
||||
import_from_codex, remove_server_from_codex, sync_enabled_to_codex, sync_single_server_to_codex,
|
||||
};
|
||||
pub use gemini::{
|
||||
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
||||
sync_single_server_to_gemini,
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
//! MCP 服务器配置验证模块
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// 基础校验:允许 stdio/http/sse;或省略 type(视为 stdio)。对应必填字段存在
|
||||
pub fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
|
||||
if !spec.is_object() {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器连接定义必须为 JSON 对象".into(),
|
||||
));
|
||||
}
|
||||
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
||||
// 支持三种:stdio/http/sse;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
|
||||
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true);
|
||||
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
||||
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
|
||||
|
||||
if !(is_stdio || is_http || is_sse) {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if is_stdio {
|
||||
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if cmd.trim().is_empty() {
|
||||
return Err(AppError::McpValidation(
|
||||
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if is_http {
|
||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if url.trim().is_empty() {
|
||||
return Err(AppError::McpValidation(
|
||||
"http 类型的 MCP 服务器缺少 url 字段".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if is_sse {
|
||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if url.trim().is_empty() {
|
||||
return Err(AppError::McpValidation(
|
||||
"sse 类型的 MCP 服务器缺少 url 字段".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 MCP 条目中提取服务器规范
|
||||
pub fn extract_server_spec(entry: &Value) -> Result<Value, AppError> {
|
||||
let obj = entry
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
|
||||
let server = obj
|
||||
.get("server")
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
|
||||
|
||||
if !server.is_object() {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器 server 字段必须为 JSON 对象".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(server.clone())
|
||||
}
|
||||
@@ -36,6 +36,10 @@ pub struct Provider {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "iconColor")]
|
||||
pub icon_color: Option<String>,
|
||||
/// 是否加入故障转移队列
|
||||
#[serde(default)]
|
||||
#[serde(rename = "inFailoverQueue")]
|
||||
pub in_failover_queue: bool,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
@@ -58,6 +62,7 @@ impl Provider {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +156,15 @@ pub struct ProviderMeta {
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub partner_promotion_key: Option<String>,
|
||||
/// 成本倍数(用于计算实际成本)
|
||||
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
|
||||
pub cost_multiplier: Option<String>,
|
||||
/// 每日消费限额(USD)
|
||||
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
|
||||
pub limit_daily_usd: Option<String>,
|
||||
/// 每月消费限额(USD)
|
||||
#[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
|
||||
pub limit_monthly_usd: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
@@ -159,3 +173,285 @@ impl ProviderManager {
|
||||
&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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//! 请求体过滤模块
|
||||
//!
|
||||
//! 过滤不应透传到上游的私有参数,防止内部信息泄露。
|
||||
//!
|
||||
//! ## 过滤规则
|
||||
//! - 以 `_` 开头的字段被视为私有参数,会被递归过滤
|
||||
//! - 支持白名单机制,允许透传特定的 `_` 前缀字段
|
||||
//! - 支持嵌套对象和数组的深度过滤
|
||||
//!
|
||||
//! ## 使用场景
|
||||
//! - `_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)
|
||||
}
|
||||
|
||||
/// 递归过滤实现(支持白名单)
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
//! 熔断器模块
|
||||
//!
|
||||
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
|
||||
|
||||
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: 4,
|
||||
success_threshold: 2,
|
||||
timeout_seconds: 60,
|
||||
error_rate_threshold: 0.6,
|
||||
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=0,allow_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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ProxyError {
|
||||
#[error("服务器已在运行")]
|
||||
AlreadyRunning,
|
||||
|
||||
#[error("服务器未运行")]
|
||||
NotRunning,
|
||||
|
||||
#[error("地址绑定失败: {0}")]
|
||||
BindFailed(String),
|
||||
|
||||
#[error("请求转发失败: {0}")]
|
||||
ForwardFailed(String),
|
||||
|
||||
#[error("无可用的Provider")]
|
||||
NoAvailableProvider,
|
||||
|
||||
#[error("所有供应商已熔断,无可用渠道")]
|
||||
AllProvidersCircuitOpen,
|
||||
|
||||
#[error("未配置供应商")]
|
||||
NoProvidersConfigured,
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[error("Provider不健康: {0}")]
|
||||
ProviderUnhealthy(String),
|
||||
|
||||
#[error("上游错误 (状态码 {status}): {body:?}")]
|
||||
UpstreamError { status: u16, body: Option<String> },
|
||||
|
||||
#[error("超过最大重试次数")]
|
||||
MaxRetriesExceeded,
|
||||
|
||||
#[error("数据库错误: {0}")]
|
||||
DatabaseError(String),
|
||||
|
||||
#[error("配置错误: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[error("格式转换错误: {0}")]
|
||||
TransformError(String),
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[error("无效的请求: {0}")]
|
||||
InvalidRequest(String),
|
||||
|
||||
#[error("超时: {0}")]
|
||||
Timeout(String),
|
||||
|
||||
/// 流式响应空闲超时
|
||||
#[allow(dead_code)]
|
||||
#[error("流式响应空闲超时: {0}秒无数据")]
|
||||
StreamIdleTimeout(u64),
|
||||
|
||||
/// 认证错误
|
||||
#[allow(dead_code)]
|
||||
#[error("认证失败: {0}")]
|
||||
AuthError(String),
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[error("内部错误: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for ProxyError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
ProxyError::UpstreamError {
|
||||
status: upstream_status,
|
||||
body: upstream_body,
|
||||
} => {
|
||||
let http_status =
|
||||
StatusCode::from_u16(*upstream_status).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
|
||||
// 尝试解析上游响应体为 JSON,如果失败则包装为字符串
|
||||
let error_body = if let Some(body_str) = upstream_body {
|
||||
if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(body_str) {
|
||||
// 上游返回的是 JSON,直接透传
|
||||
json_body
|
||||
} else {
|
||||
// 上游返回的不是 JSON,包装为错误消息
|
||||
json!({
|
||||
"error": {
|
||||
"message": body_str,
|
||||
"type": "upstream_error",
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
json!({
|
||||
"error": {
|
||||
"message": format!("Upstream error (status {})", upstream_status),
|
||||
"type": "upstream_error",
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
(http_status, error_body)
|
||||
}
|
||||
_ => {
|
||||
let (http_status, message) = match &self {
|
||||
ProxyError::AlreadyRunning => (StatusCode::CONFLICT, self.to_string()),
|
||||
ProxyError::NotRunning => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()),
|
||||
ProxyError::BindFailed(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
|
||||
ProxyError::NoAvailableProvider => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||
}
|
||||
ProxyError::AllProvidersCircuitOpen => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||
}
|
||||
ProxyError::NoProvidersConfigured => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||
}
|
||||
ProxyError::ProviderUnhealthy(_) => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||
}
|
||||
ProxyError::MaxRetriesExceeded => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||
}
|
||||
ProxyError::DatabaseError(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
ProxyError::ConfigError(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
ProxyError::TransformError(_) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
}
|
||||
ProxyError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
ProxyError::Timeout(_) => (StatusCode::GATEWAY_TIMEOUT, self.to_string()),
|
||||
ProxyError::StreamIdleTimeout(_) => {
|
||||
(StatusCode::GATEWAY_TIMEOUT, self.to_string())
|
||||
}
|
||||
ProxyError::AuthError(_) => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||
ProxyError::Internal(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
ProxyError::UpstreamError { .. } => unreachable!(),
|
||||
};
|
||||
|
||||
let error_body = json!({
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": "proxy_error",
|
||||
}
|
||||
});
|
||||
|
||||
(http_status, error_body)
|
||||
}
|
||||
};
|
||||
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误分类
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ErrorCategory {
|
||||
/// 可重试错误(网络问题、5xx)
|
||||
Retryable, // 网络超时、5xx 错误
|
||||
/// 不可重试错误(4xx、认证失败)
|
||||
NonRetryable, // 认证失败、参数错误、4xx 错误
|
||||
#[allow(dead_code)]
|
||||
ClientAbort, // 客户端主动中断
|
||||
}
|
||||
|
||||
/// 判断错误是否可重试
|
||||
#[allow(dead_code)]
|
||||
pub fn categorize_error(error: &reqwest::Error) -> ErrorCategory {
|
||||
if error.is_timeout() || error.is_connect() {
|
||||
return ErrorCategory::Retryable;
|
||||
}
|
||||
|
||||
if let Some(status) = error.status() {
|
||||
if status.is_server_error() {
|
||||
ErrorCategory::Retryable
|
||||
} else if status.is_client_error() {
|
||||
ErrorCategory::NonRetryable
|
||||
} else {
|
||||
ErrorCategory::Retryable
|
||||
}
|
||||
} else {
|
||||
ErrorCategory::Retryable
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! 错误类型到 HTTP 状态码的映射
|
||||
//!
|
||||
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录
|
||||
|
||||
use super::ProxyError;
|
||||
|
||||
/// 将 ProxyError 映射到 HTTP 状态码
|
||||
///
|
||||
/// 映射规则:
|
||||
/// - 上游错误:直接使用上游返回的状态码
|
||||
/// - 超时:504 Gateway Timeout
|
||||
/// - 连接失败:502 Bad Gateway
|
||||
/// - 无可用 Provider:503 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,
|
||||
|
||||
// 无可用 Provider:503 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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! 故障转移切换模块
|
||||
//!
|
||||
//! 处理故障转移成功后的供应商切换逻辑,包括:
|
||||
//! - 去重控制(避免多个请求同时触发)
|
||||
//! - 数据库更新
|
||||
//! - 托盘菜单更新
|
||||
//! - 前端事件发射
|
||||
//! - 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
//! 请求转发器
|
||||
//!
|
||||
//! 负责将请求转发到上游Provider,支持故障转移
|
||||
|
||||
use super::{
|
||||
body_filter::filter_private_params_with_whitelist,
|
||||
error::*,
|
||||
failover_switch::FailoverSwitchManager,
|
||||
provider_router::ProviderRouter,
|
||||
providers::{get_adapter, ProviderAdapter},
|
||||
types::ProxyStatus,
|
||||
ProxyError,
|
||||
};
|
||||
use crate::{app_config::AppType, provider::Provider};
|
||||
use reqwest::{Client, Response};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Headers 黑名单 - 不透传到上游的 Headers
|
||||
///
|
||||
/// 精简版黑名单,只过滤必须覆盖或可能导致问题的 header
|
||||
/// 参考成功透传的请求,保留更多原始 header
|
||||
///
|
||||
/// 注意:客户端 IP 类(x-forwarded-for, x-real-ip)默认透传
|
||||
const HEADER_BLACKLIST: &[&str] = &[
|
||||
// 认证类(会被覆盖)
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
// 连接类(由 HTTP 客户端管理)
|
||||
"host",
|
||||
"content-length",
|
||||
"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",
|
||||
// anthropic 特定头单独处理,避免重复
|
||||
"anthropic-beta",
|
||||
"anthropic-version",
|
||||
// 客户端 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 {
|
||||
client: Client,
|
||||
/// 共享的 ProviderRouter(持有熔断器状态)
|
||||
router: Arc<ProviderRouter>,
|
||||
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 {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
router: Arc<ProviderRouter>,
|
||||
non_streaming_timeout: u64,
|
||||
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 {
|
||||
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
|
||||
// 参考 Claude Code Hub 的 undici 全局超时设计
|
||||
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
|
||||
|
||||
let mut client_builder = Client::builder();
|
||||
if non_streaming_timeout > 0 {
|
||||
// 使用配置的非流式超时
|
||||
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
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self {
|
||||
client,
|
||||
router,
|
||||
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(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
endpoint: &str,
|
||||
body: Value,
|
||||
headers: axum::http::HeaderMap,
|
||||
providers: Vec<Provider>,
|
||||
) -> Result<ForwardResult, ForwardError> {
|
||||
// 获取适配器
|
||||
let adapter = get_adapter(app_type);
|
||||
let app_type_str = app_type.as_str();
|
||||
|
||||
if providers.is_empty() {
|
||||
return Err(ForwardError {
|
||||
error: ProxyError::NoAvailableProvider,
|
||||
provider: None,
|
||||
});
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] 故障转移链: {} 个可用供应商",
|
||||
app_type_str,
|
||||
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.sort_index.unwrap_or(999999)
|
||||
);
|
||||
|
||||
// 更新状态中的当前Provider信息
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.current_provider = Some(provider.name.clone());
|
||||
status.current_provider_id = Some(provider.id.clone());
|
||||
status.total_requests += 1;
|
||||
status.last_request_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
|
||||
match self
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
|
||||
// 成功:记录成功并更新熔断器
|
||||
if let Err(e) = self
|
||||
.router
|
||||
.record_result(
|
||||
&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;
|
||||
status.success_requests += 1;
|
||||
status.last_error = None;
|
||||
let should_switch =
|
||||
self.current_provider_id_at_start.as_str() != provider.id.as_str();
|
||||
if should_switch {
|
||||
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 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] 请求成功 - Provider: {} - {}ms",
|
||||
app_type_str,
|
||||
provider.name,
|
||||
latency
|
||||
);
|
||||
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
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);
|
||||
|
||||
match category {
|
||||
ErrorCategory::Retryable => {
|
||||
// 可重试:更新错误信息,继续尝试下一个供应商
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.last_error =
|
||||
Some(format!("Provider {} 失败: {}", provider.name, e));
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"[{}] Provider {} 失败(可重试): {} - {}ms",
|
||||
app_type_str,
|
||||
provider.name,
|
||||
e,
|
||||
latency
|
||||
);
|
||||
|
||||
last_error = Some(e);
|
||||
last_provider = Some(provider.clone());
|
||||
// 继续尝试下一个供应商
|
||||
continue;
|
||||
}
|
||||
ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => {
|
||||
// 不可重试:直接返回错误
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(e.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
}
|
||||
log::error!(
|
||||
"[{}] Provider {} 失败(不可重试): {}",
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
log::error!(
|
||||
"[{}] 所有 {} 个供应商都失败了",
|
||||
app_type_str,
|
||||
providers.len()
|
||||
);
|
||||
|
||||
Err(ForwardError {
|
||||
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
|
||||
provider: last_provider,
|
||||
})
|
||||
}
|
||||
|
||||
/// 转发单个请求(使用适配器)
|
||||
async fn forward(
|
||||
&self,
|
||||
provider: &Provider,
|
||||
endpoint: &str,
|
||||
body: &Value,
|
||||
headers: &axum::http::HeaderMap,
|
||||
adapter: &dyn ProviderAdapter,
|
||||
) -> Result<Response, ProxyError> {
|
||||
// 使用适配器提取 base_url
|
||||
let base_url = adapter.extract_base_url(provider)?;
|
||||
log::info!("[{}] base_url: {}", adapter.name(), base_url);
|
||||
|
||||
// 检查是否需要格式转换
|
||||
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
|
||||
log::info!(
|
||||
"[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}",
|
||||
adapter.name(),
|
||||
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 {
|
||||
log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name());
|
||||
let transformed = adapter.transform_request(mapped_body, provider)?;
|
||||
log::info!(
|
||||
"[{}] >>> 转换后的请求 JSON:\n{}",
|
||||
adapter.name(),
|
||||
serde_json::to_string_pretty(&transformed).unwrap_or_default()
|
||||
);
|
||||
transformed
|
||||
} else {
|
||||
mapped_body
|
||||
};
|
||||
|
||||
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
|
||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||
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!(
|
||||
"[{}] 转发请求: {} -> {}",
|
||||
adapter.name(),
|
||||
provider.name,
|
||||
url
|
||||
);
|
||||
|
||||
// 构建请求
|
||||
let mut request = self.client.post(&url);
|
||||
|
||||
// ========== 详细 Headers 日志 ==========
|
||||
log::info!("[{}] ====== 客户端原始 Headers ======", adapter.name());
|
||||
for (key, value) in headers {
|
||||
log::info!(
|
||||
"[{}] {}: {:?}",
|
||||
adapter.name(),
|
||||
key.as_str(),
|
||||
value.to_str().unwrap_or("<binary>")
|
||||
);
|
||||
}
|
||||
|
||||
// 过滤黑名单 Headers,保护隐私并避免冲突
|
||||
let mut filtered_headers: Vec<String> = Vec::new();
|
||||
let mut passed_headers: Vec<(String, String)> = Vec::new();
|
||||
|
||||
for (key, value) in headers {
|
||||
let key_str = key.as_str().to_lowercase();
|
||||
if HEADER_BLACKLIST.contains(&key_str.as_str()) {
|
||||
filtered_headers.push(key_str);
|
||||
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(仅 Claude)
|
||||
// 关键:确保包含 claude-code-20250219 标记,这是上游服务验证请求来源的依据
|
||||
// 如果客户端发送的 beta 标记中没有包含 claude-code-20250219,需要补充
|
||||
if adapter.name() == "Claude" {
|
||||
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
|
||||
let beta_value = if let Some(beta) = headers.get("anthropic-beta") {
|
||||
if let Ok(beta_str) = beta.to_str() {
|
||||
// 检查是否已包含 claude-code-20250219
|
||||
if beta_str.contains(CLAUDE_CODE_BETA) {
|
||||
beta_str.to_string()
|
||||
} else {
|
||||
// 补充 claude-code-20250219
|
||||
format!("{CLAUDE_CODE_BETA},{beta_str}")
|
||||
}
|
||||
} else {
|
||||
CLAUDE_CODE_BETA.to_string()
|
||||
}
|
||||
} else {
|
||||
// 如果客户端没有发送,使用默认值
|
||||
CLAUDE_CODE_BETA.to_string()
|
||||
};
|
||||
request = request.header("anthropic-beta", &beta_value);
|
||||
passed_headers.push(("anthropic-beta".to_string(), beta_value.clone()));
|
||||
log::info!("[{}] 设置 anthropic-beta: {}", adapter.name(), beta_value);
|
||||
}
|
||||
|
||||
// 客户端 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) {
|
||||
log::debug!(
|
||||
"[{}] 使用认证: {:?} (key: {})",
|
||||
adapter.name(),
|
||||
auth.strategy,
|
||||
auth.masked_key()
|
||||
);
|
||||
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 {
|
||||
log::error!(
|
||||
"[{}] 未找到 API Key!Provider: {}",
|
||||
adapter.name(),
|
||||
provider.name
|
||||
);
|
||||
}
|
||||
|
||||
// anthropic-version 统一处理(仅 Claude):优先使用客户端的版本号,否则使用默认值
|
||||
// 注意:只设置一次,避免重复
|
||||
if adapter.name() == "Claude" {
|
||||
let version_str = headers
|
||||
.get("anthropic-version")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("2023-06-01");
|
||||
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);
|
||||
let response = request.json(&filtered_body).send().await.map_err(|e| {
|
||||
log::error!("[{}] 请求失败: {}", adapter.name(), e);
|
||||
if e.is_timeout() {
|
||||
ProxyError::Timeout(format!("请求超时: {e}"))
|
||||
} else if e.is_connect() {
|
||||
ProxyError::ForwardFailed(format!("连接失败: {e}"))
|
||||
} else {
|
||||
ProxyError::ForwardFailed(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
// 检查响应状态
|
||||
let status = response.status();
|
||||
log::info!("[{}] 响应状态: {}", adapter.name(), status);
|
||||
|
||||
if status.is_success() {
|
||||
Ok(response)
|
||||
} else {
|
||||
let status_code = status.as_u16();
|
||||
let body_text = response.text().await.ok();
|
||||
log::error!(
|
||||
"[{}] 上游错误 ({}): {:?}",
|
||||
adapter.name(),
|
||||
status_code,
|
||||
body_text
|
||||
);
|
||||
|
||||
Err(ProxyError::UpstreamError {
|
||||
status: status_code,
|
||||
body: body_text,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
|
||||
match error {
|
||||
// 网络和上游错误:都应该尝试下一个供应商
|
||||
ProxyError::Timeout(_) => ErrorCategory::Retryable,
|
||||
ProxyError::ForwardFailed(_) => 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,
|
||||
// 其他错误(数据库/内部错误等):不是换供应商能解决的问题
|
||||
_ => ErrorCategory::NonRetryable,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! 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,
|
||||
};
|
||||
@@ -0,0 +1,247 @@
|
||||
//! 请求上下文模块
|
||||
//!
|
||||
//! 提供请求生命周期的上下文管理,封装通用初始化逻辑
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
//! 请求处理器
|
||||
//!
|
||||
//! 处理各种API端点的HTTP请求
|
||||
//!
|
||||
//! 重构后的结构:
|
||||
//! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块
|
||||
//! - 各 handler 只保留独特的业务逻辑
|
||||
//! - Claude 的格式转换逻辑保留在此文件(用于 OpenRouter 旧接口回退)
|
||||
|
||||
use super::{
|
||||
error_mapper::{get_error_message, map_proxy_error_to_status},
|
||||
handler_config::{
|
||||
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
|
||||
},
|
||||
handler_context::RequestContext,
|
||||
providers::{get_adapter, streaming::create_anthropic_sse_stream, transform},
|
||||
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
|
||||
server::ProxyState,
|
||||
types::*,
|
||||
usage::parser::TokenUsage,
|
||||
ProxyError,
|
||||
};
|
||||
use crate::app_config::AppType;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
|
||||
// ============================================================================
|
||||
// 健康检查和状态查询(简单端点)
|
||||
// ============================================================================
|
||||
|
||||
/// 健康检查
|
||||
pub async fn health_check() -> (StatusCode, Json<Value>) {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"status": "healthy",
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取服务状态
|
||||
pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxyStatus>, ProxyError> {
|
||||
let status = state.status.read().await.clone();
|
||||
Ok(Json(status))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Claude API 处理器(包含格式转换逻辑)
|
||||
// ============================================================================
|
||||
|
||||
/// 处理 /v1/messages 请求(Claude API)
|
||||
///
|
||||
/// Claude 处理器包含独特的格式转换逻辑:
|
||||
/// - 过去用于 OpenRouter 的 OpenAI Chat Completions 兼容接口(Anthropic ↔ OpenAI 转换)
|
||||
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
|
||||
pub async fn handle_messages(
|
||||
State(state): State<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
.and_then(|s| s.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// 转发请求
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Claude,
|
||||
"/v1/messages",
|
||||
body.clone(),
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(mut err) => {
|
||||
if let Some(provider) = err.provider.take() {
|
||||
ctx.provider = provider;
|
||||
}
|
||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||
return Err(err.error);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||||
let adapter = get_adapter(&AppType::Claude);
|
||||
let needs_transform = adapter.needs_transform(&ctx.provider);
|
||||
|
||||
log::info!(
|
||||
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
|
||||
ctx.provider.name,
|
||||
needs_transform,
|
||||
is_stream
|
||||
);
|
||||
|
||||
let status = response.status();
|
||||
log::info!("[Claude] 上游响应状态: {status}");
|
||||
|
||||
// Claude 特有:格式转换处理
|
||||
if needs_transform {
|
||||
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
|
||||
}
|
||||
|
||||
// 通用响应处理(透传模式)
|
||||
process_response(response, &ctx, &state, &CLAUDE_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
/// Claude 格式转换处理(独有逻辑)
|
||||
///
|
||||
/// 处理 OpenRouter 旧 OpenAI 兼容接口的回退方案(当前默认不启用)
|
||||
async fn handle_claude_transform(
|
||||
response: reqwest::Response,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
_original_body: &Value,
|
||||
is_stream: bool,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let status = response.status();
|
||||
|
||||
if is_stream {
|
||||
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
|
||||
log::info!("[Claude] 开始流式响应转换 (OpenAI SSE → Anthropic SSE)");
|
||||
|
||||
let stream = response.bytes_stream();
|
||||
let sse_stream = create_anthropic_sse_stream(stream);
|
||||
|
||||
// 创建使用量收集器
|
||||
let usage_collector = {
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let model = ctx.request_model.clone();
|
||||
let status_code = status.as_u16();
|
||||
let start_time = ctx.start_time;
|
||||
|
||||
SseUsageCollector::new(start_time, move |events, first_token_ms| {
|
||||
if let Some(usage) = TokenUsage::from_claude_stream_events(&events) {
|
||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let model = model.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage(
|
||||
&state,
|
||||
&provider_id,
|
||||
"claude",
|
||||
&model,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
true,
|
||||
status_code,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
} else {
|
||||
log::debug!("[Claude] OpenRouter 流式响应缺少 usage 统计,跳过消费记录");
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// 获取流式超时配置
|
||||
let timeout_config = ctx.streaming_timeout_config();
|
||||
|
||||
let logged_stream = create_logged_passthrough_stream(
|
||||
sse_stream,
|
||||
"Claude/OpenRouter",
|
||||
Some(usage_collector),
|
||||
timeout_config,
|
||||
);
|
||||
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
"Content-Type",
|
||||
axum::http::HeaderValue::from_static("text/event-stream"),
|
||||
);
|
||||
headers.insert(
|
||||
"Cache-Control",
|
||||
axum::http::HeaderValue::from_static("no-cache"),
|
||||
);
|
||||
headers.insert(
|
||||
"Connection",
|
||||
axum::http::HeaderValue::from_static("keep-alive"),
|
||||
);
|
||||
|
||||
let body = axum::body::Body::from_stream(logged_stream);
|
||||
log::info!("[Claude] ====== 请求结束 (流式转换) ======");
|
||||
return Ok((headers, body).into_response());
|
||||
}
|
||||
|
||||
// 非流式响应转换 (OpenAI → Anthropic)
|
||||
log::info!("[Claude] 开始转换响应 (OpenAI → Anthropic)");
|
||||
|
||||
let response_headers = response.headers().clone();
|
||||
|
||||
let body_bytes = response.bytes().await.map_err(|e| {
|
||||
log::error!("[Claude] 读取响应体失败: {e}");
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
})?;
|
||||
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
log::info!("[Claude] OpenAI 响应长度: {} bytes", body_bytes.len());
|
||||
log::debug!("[Claude] OpenAI 原始响应: {body_str}");
|
||||
|
||||
let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||
log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}");
|
||||
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
|
||||
})?;
|
||||
|
||||
log::info!("[Claude] 解析 OpenAI 响应成功");
|
||||
log::info!(
|
||||
"[Claude] <<< OpenAI 响应 JSON:\n{}",
|
||||
serde_json::to_string_pretty(&openai_response).unwrap_or_default()
|
||||
);
|
||||
|
||||
let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| {
|
||||
log::error!("[Claude] 转换响应失败: {e}");
|
||||
e
|
||||
})?;
|
||||
|
||||
log::info!("[Claude] 转换响应成功");
|
||||
log::info!(
|
||||
"[Claude] <<< Anthropic 响应 JSON:\n{}",
|
||||
serde_json::to_string_pretty(&anthropic_response).unwrap_or_default()
|
||||
);
|
||||
|
||||
// 记录使用量
|
||||
if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {
|
||||
let model = anthropic_response
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let latency_ms = ctx.latency_ms();
|
||||
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let model = model.to_string();
|
||||
async move {
|
||||
log_usage(
|
||||
&state,
|
||||
&provider_id,
|
||||
"claude",
|
||||
&model,
|
||||
usage,
|
||||
latency_ms,
|
||||
None,
|
||||
false,
|
||||
status.as_u16(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
log::info!("[Claude] ====== 请求结束 ======");
|
||||
|
||||
// 构建响应
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
|
||||
for (key, value) in response_headers.iter() {
|
||||
if key.as_str().to_lowercase() != "content-length"
|
||||
&& key.as_str().to_lowercase() != "transfer-encoding"
|
||||
{
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
builder = builder.header("content-type", "application/json");
|
||||
|
||||
let response_body = serde_json::to_vec(&anthropic_response).map_err(|e| {
|
||||
log::error!("[Claude] 序列化响应失败: {e}");
|
||||
ProxyError::TransformError(format!("Failed to serialize response: {e}"))
|
||||
})?;
|
||||
|
||||
log::info!(
|
||||
"[Claude] 返回转换后的响应, 长度: {} bytes",
|
||||
response_body.len()
|
||||
);
|
||||
|
||||
let body = axum::body::Body::from(response_body);
|
||||
Ok(builder.body(body).unwrap())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Codex API 处理器
|
||||
// ============================================================================
|
||||
|
||||
/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI)
|
||||
pub async fn handle_chat_completions(
|
||||
State(state): State<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
log::info!(
|
||||
"[Codex] 请求模型: {}, 流式: {}",
|
||||
ctx.request_model,
|
||||
is_stream
|
||||
);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/v1/chat/completions",
|
||||
body,
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(mut err) => {
|
||||
if let Some(provider) = err.provider.take() {
|
||||
ctx.provider = provider;
|
||||
}
|
||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||
return Err(err.error);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||
|
||||
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传)
|
||||
pub async fn handle_responses(
|
||||
State(state): State<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/v1/responses",
|
||||
body,
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(mut err) => {
|
||||
if let Some(provider) = err.provider.take() {
|
||||
ctx.provider = provider;
|
||||
}
|
||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||
return Err(err.error);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||
|
||||
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Gemini API 处理器
|
||||
// ============================================================================
|
||||
|
||||
/// 处理 Gemini API 请求(透传,包括查询参数)
|
||||
pub async fn handle_gemini(
|
||||
State(state): State<ProxyState>,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
// Gemini 的模型名称在 URI 中
|
||||
let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini")
|
||||
.await?
|
||||
.with_model_from_uri(&uri);
|
||||
|
||||
// 提取完整的路径和查询参数
|
||||
let endpoint = uri
|
||||
.path_and_query()
|
||||
.map(|pq| pq.as_str())
|
||||
.unwrap_or(uri.path());
|
||||
|
||||
log::info!("[Gemini] 请求端点: {endpoint}");
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Gemini,
|
||||
endpoint,
|
||||
body,
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(mut err) => {
|
||||
if let Some(provider) = err.provider.take() {
|
||||
ctx.provider = provider;
|
||||
}
|
||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||
return Err(err.error);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
log::info!("[Gemini] 上游响应状态: {}", response.status());
|
||||
|
||||
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 使用量记录(保留用于 Claude 转换逻辑)
|
||||
// ============================================================================
|
||||
|
||||
fn log_forward_error(
|
||||
state: &ProxyState,
|
||||
ctx: &RequestContext,
|
||||
is_streaming: bool,
|
||||
error: &ProxyError,
|
||||
) {
|
||||
use super::usage::logger::UsageLogger;
|
||||
|
||||
let logger = UsageLogger::new(&state.db);
|
||||
let status_code = map_proxy_error_to_status(error);
|
||||
let error_message = get_error_message(error);
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
if let Err(e) = logger.log_error_with_context(
|
||||
request_id,
|
||||
ctx.provider.id.clone(),
|
||||
ctx.app_type_str.to_string(),
|
||||
ctx.request_model.clone(),
|
||||
status_code,
|
||||
error_message,
|
||||
ctx.latency_ms(),
|
||||
is_streaming,
|
||||
Some(ctx.session_id.clone()),
|
||||
None,
|
||||
) {
|
||||
log::warn!("记录失败请求日志失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录请求使用量
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn log_usage(
|
||||
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,
|
||||
) {
|
||||
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();
|
||||
|
||||
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,
|
||||
None,
|
||||
None, // provider_type
|
||||
is_streaming,
|
||||
) {
|
||||
log::warn!("记录使用量失败: {e}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! 健康检查器
|
||||
//!
|
||||
//! 负责定期检查Provider健康状态(占位实现)
|
||||
|
||||
// 占位实现,稍后添加完整逻辑
|
||||
#[allow(dead_code)]
|
||||
pub struct HealthChecker;
|
||||
@@ -0,0 +1,46 @@
|
||||
//! 代理服务器模块
|
||||
//!
|
||||
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
|
||||
|
||||
pub mod body_filter;
|
||||
pub mod circuit_breaker;
|
||||
pub mod error;
|
||||
pub mod error_mapper;
|
||||
pub(crate) mod failover_switch;
|
||||
mod forwarder;
|
||||
pub mod handler_config;
|
||||
pub mod handler_context;
|
||||
mod handlers;
|
||||
mod health;
|
||||
pub mod model_mapper;
|
||||
pub mod provider_router;
|
||||
pub mod providers;
|
||||
pub mod response_handler;
|
||||
pub mod response_processor;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub(crate) mod types;
|
||||
pub mod usage;
|
||||
|
||||
// 公开导出给外部使用(commands, services等模块需要)
|
||||
#[allow(unused_imports)]
|
||||
pub use circuit_breaker::{
|
||||
CircuitBreaker, CircuitBreakerConfig, CircuitBreakerStats, CircuitState,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use error::ProxyError;
|
||||
#[allow(unused_imports)]
|
||||
pub use provider_router::ProviderRouter;
|
||||
#[allow(unused_imports)]
|
||||
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
|
||||
#[allow(unused_imports)]
|
||||
pub use session::{
|
||||
extract_session_id, ClientFormat, ProxySession, SessionIdResult, SessionIdSource,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};
|
||||
|
||||
// 内部模块间共享(供子模块使用)
|
||||
// 注意:这个导出用于模块内部,编译器可能警告未使用但实际被子模块使用
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use types::*;
|
||||
@@ -0,0 +1,311 @@
|
||||
//! 模型映射模块
|
||||
//!
|
||||
//! 在请求转发前,根据 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()
|
||||
|| self.reasoning_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,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_provider_with_reasoning_only() -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
settings_config: json!({
|
||||
"env": {
|
||||
"ANTHROPIC_REASONING_MODEL": "reasoning-only-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,
|
||||
}
|
||||
}
|
||||
|
||||
#[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_reasoning_only_mapping_in_thinking_mode() {
|
||||
let provider = create_provider_with_reasoning_only();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "enabled"}
|
||||
});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "reasoning-only-model");
|
||||
assert_eq!(mapped, Some("reasoning-only-model".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_only_mapping_does_not_affect_non_thinking() {
|
||||
let provider = create_provider_with_reasoning_only();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "disabled"}
|
||||
});
|
||||
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_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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
//! 供应商路由器模块
|
||||
//!
|
||||
//! 负责选择和管理代理目标供应商,实现智能故障转移
|
||||
|
||||
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(¤t_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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Provider Adapter Trait
|
||||
//!
|
||||
//! 定义供应商适配器的统一接口,抽象不同上游供应商的处理逻辑。
|
||||
|
||||
use super::auth::AuthInfo;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 供应商适配器 Trait
|
||||
///
|
||||
/// 所有供应商适配器都需要实现此 trait,提供统一的接口来处理:
|
||||
/// - URL 构建
|
||||
/// - 认证信息提取和头部注入
|
||||
/// - 请求/响应格式转换(可选)
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
/// ```ignore
|
||||
/// pub struct ClaudeAdapter;
|
||||
///
|
||||
/// impl ProviderAdapter for ClaudeAdapter {
|
||||
/// fn name(&self) -> &'static str { "Claude" }
|
||||
///
|
||||
/// fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||
/// // 从 provider 配置中提取 base_url
|
||||
/// }
|
||||
///
|
||||
/// fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
/// // 从 provider 配置中提取认证信息
|
||||
/// }
|
||||
///
|
||||
/// fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
/// format!("{}{}", base_url.trim_end_matches('/'), endpoint)
|
||||
/// }
|
||||
///
|
||||
/// fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
/// // 添加认证头
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub trait ProviderAdapter: Send + Sync {
|
||||
/// 适配器名称(用于日志和调试)
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// 从 Provider 配置中提取 base_url
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 配置
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(String)` - 提取到的 base_url(已去除尾部斜杠)
|
||||
/// * `Err(ProxyError)` - 提取失败
|
||||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError>;
|
||||
|
||||
/// 从 Provider 配置中提取认证信息
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 配置
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Some(AuthInfo)` - 提取到的认证信息
|
||||
/// * `None` - 未找到认证信息
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo>;
|
||||
|
||||
/// 构建请求 URL
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `base_url` - 基础 URL
|
||||
/// * `endpoint` - 请求端点(如 `/v1/messages`)
|
||||
///
|
||||
/// # Returns
|
||||
/// 完整的请求 URL
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String;
|
||||
|
||||
/// 添加认证头到请求
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - reqwest RequestBuilder
|
||||
/// * `auth` - 认证信息
|
||||
///
|
||||
/// # Returns
|
||||
/// 添加了认证头的 RequestBuilder
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder;
|
||||
|
||||
/// 是否需要格式转换
|
||||
///
|
||||
/// 默认返回 `false`(透传模式)。
|
||||
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 配置
|
||||
fn needs_transform(&self, _provider: &Provider) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// 转换请求体
|
||||
///
|
||||
/// 将请求体从一种格式转换为另一种格式(如 Anthropic → OpenAI)。
|
||||
/// 默认实现直接返回原始请求体(透传)。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `body` - 原始请求体
|
||||
/// * `provider` - Provider 配置(用于获取模型映射等)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Value)` - 转换后的请求体
|
||||
/// * `Err(ProxyError)` - 转换失败
|
||||
fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> {
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// 转换响应体
|
||||
///
|
||||
/// 将响应体从一种格式转换为另一种格式(如 OpenAI → Anthropic)。
|
||||
/// 默认实现直接返回原始响应体(透传)。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `body` - 原始响应体
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Value)` - 转换后的响应体
|
||||
/// * `Err(ProxyError)` - 转换失败
|
||||
///
|
||||
/// Note: 响应转换将在 handler 层集成,目前预留接口
|
||||
#[allow(dead_code)]
|
||||
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Authentication Types
|
||||
//!
|
||||
//! 定义认证信息和认证策略,支持多种上游供应商的认证方式。
|
||||
|
||||
/// 认证信息
|
||||
///
|
||||
/// 包含 API Key 和对应的认证策略
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthInfo {
|
||||
/// API Key
|
||||
pub api_key: String,
|
||||
/// 认证策略
|
||||
pub strategy: AuthStrategy,
|
||||
/// OAuth access_token(用于 GoogleOAuth 策略)
|
||||
pub access_token: Option<String>,
|
||||
}
|
||||
|
||||
impl AuthInfo {
|
||||
/// 创建新的认证信息
|
||||
pub fn new(api_key: String, strategy: AuthStrategy) -> Self {
|
||||
Self {
|
||||
api_key,
|
||||
strategy,
|
||||
access_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建带有 access_token 的认证信息(用于 OAuth)
|
||||
pub fn with_access_token(api_key: String, access_token: String) -> Self {
|
||||
Self {
|
||||
api_key,
|
||||
strategy: AuthStrategy::GoogleOAuth,
|
||||
access_token: Some(access_token),
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回遮蔽后的 API Key(用于日志输出)
|
||||
///
|
||||
/// 显示前4位和后4位,中间用 `...` 代替
|
||||
/// 如果 key 长度不足8位,则返回 `***`
|
||||
pub fn masked_key(&self) -> String {
|
||||
if self.api_key.len() > 8 {
|
||||
format!(
|
||||
"{}...{}",
|
||||
&self.api_key[..4],
|
||||
&self.api_key[self.api_key.len() - 4..]
|
||||
)
|
||||
} else {
|
||||
"***".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回遮蔽后的 access_token(用于日志输出)
|
||||
#[allow(dead_code)]
|
||||
pub fn masked_access_token(&self) -> Option<String> {
|
||||
self.access_token.as_ref().map(|token| {
|
||||
if token.len() > 8 {
|
||||
format!("{}...{}", &token[..4], &token[token.len() - 4..])
|
||||
} else {
|
||||
"***".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 认证策略
|
||||
///
|
||||
/// 不同供应商使用不同的认证方式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthStrategy {
|
||||
/// Anthropic 认证方式
|
||||
/// - Header: `x-api-key: <api_key>`
|
||||
/// - Header: `anthropic-version: 2023-06-01`
|
||||
Anthropic,
|
||||
|
||||
/// Claude 中转服务认证方式(仅 Bearer,无 x-api-key)
|
||||
///
|
||||
/// - Header: `Authorization: Bearer <api_key>`
|
||||
///
|
||||
/// 用于不支持 x-api-key 的中转服务
|
||||
ClaudeAuth,
|
||||
|
||||
/// Bearer Token 认证方式(OpenAI 等)
|
||||
///
|
||||
/// - Header: `Authorization: Bearer <api_key>`
|
||||
Bearer,
|
||||
|
||||
/// Google API Key 认证方式
|
||||
///
|
||||
/// - Header: `x-goog-api-key: <api_key>`
|
||||
Google,
|
||||
|
||||
/// Google OAuth 认证方式
|
||||
///
|
||||
/// - Header: `Authorization: Bearer <access_token>`
|
||||
///
|
||||
/// 用于 Gemini CLI 等需要 OAuth 的场景
|
||||
GoogleOAuth,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_masked_key_long() {
|
||||
let auth = AuthInfo::new("sk-1234567890abcdef".to_string(), AuthStrategy::Bearer);
|
||||
assert_eq!(auth.masked_key(), "sk-1...cdef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_key_short() {
|
||||
let auth = AuthInfo::new("short".to_string(), AuthStrategy::Bearer);
|
||||
assert_eq!(auth.masked_key(), "***");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_key_exactly_8() {
|
||||
let auth = AuthInfo::new("12345678".to_string(), AuthStrategy::Bearer);
|
||||
assert_eq!(auth.masked_key(), "***");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_key_9_chars() {
|
||||
let auth = AuthInfo::new("123456789".to_string(), AuthStrategy::Bearer);
|
||||
assert_eq!(auth.masked_key(), "1234...6789");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_strategy_equality() {
|
||||
assert_eq!(AuthStrategy::Anthropic, AuthStrategy::Anthropic);
|
||||
assert_ne!(AuthStrategy::Anthropic, AuthStrategy::Bearer);
|
||||
assert_ne!(AuthStrategy::Bearer, AuthStrategy::Google);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_info_new_has_no_access_token() {
|
||||
let auth = AuthInfo::new("api-key".to_string(), AuthStrategy::Bearer);
|
||||
assert!(auth.access_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_info_with_access_token() {
|
||||
let auth = AuthInfo::with_access_token(
|
||||
"refresh-token".to_string(),
|
||||
"ya29.access-token-12345".to_string(),
|
||||
);
|
||||
assert_eq!(auth.api_key, "refresh-token");
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
assert_eq!(
|
||||
auth.access_token,
|
||||
Some("ya29.access-token-12345".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_access_token_long() {
|
||||
let auth =
|
||||
AuthInfo::with_access_token("refresh".to_string(), "ya29.1234567890abcdef".to_string());
|
||||
assert_eq!(auth.masked_access_token(), Some("ya29...cdef".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_access_token_short() {
|
||||
let auth = AuthInfo::with_access_token("refresh".to_string(), "short".to_string());
|
||||
assert_eq!(auth.masked_access_token(), Some("***".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_access_token_none() {
|
||||
let auth = AuthInfo::new("api-key".to_string(), AuthStrategy::Bearer);
|
||||
assert!(auth.masked_access_token().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_auth_strategy() {
|
||||
let auth = AuthInfo::new("sk-test".to_string(), AuthStrategy::ClaudeAuth);
|
||||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||||
assert_ne!(auth.strategy, AuthStrategy::Anthropic);
|
||||
assert_ne!(auth.strategy, AuthStrategy::Bearer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_google_oauth_strategy() {
|
||||
let auth = AuthInfo::new("refresh-token".to_string(), AuthStrategy::GoogleOAuth);
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
assert_ne!(auth.strategy, AuthStrategy::Google);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_strategies_are_distinct() {
|
||||
let strategies = [
|
||||
AuthStrategy::Anthropic,
|
||||
AuthStrategy::ClaudeAuth,
|
||||
AuthStrategy::Bearer,
|
||||
AuthStrategy::Google,
|
||||
AuthStrategy::GoogleOAuth,
|
||||
];
|
||||
|
||||
for (i, s1) in strategies.iter().enumerate() {
|
||||
for (j, s2) in strategies.iter().enumerate() {
|
||||
if i == j {
|
||||
assert_eq!(s1, s2);
|
||||
} else {
|
||||
assert_ne!(s1, s2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
//! Claude (Anthropic) Provider Adapter
|
||||
//!
|
||||
//! 支持透传模式和 OpenRouter 兼容模式
|
||||
//!
|
||||
//! ## 认证模式
|
||||
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||||
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||||
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用)
|
||||
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
|
||||
/// Claude 适配器
|
||||
pub struct ClaudeAdapter;
|
||||
|
||||
impl ClaudeAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// 获取供应商类型
|
||||
///
|
||||
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
|
||||
/// - OpenRouter: base_url 包含 openrouter.ai
|
||||
/// - ClaudeAuth: auth_mode 为 bearer_only
|
||||
/// - Claude: 默认 Anthropic 官方
|
||||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||||
// 检测 OpenRouter
|
||||
if self.is_openrouter(provider) {
|
||||
return ProviderType::OpenRouter;
|
||||
}
|
||||
|
||||
// 检测 ClaudeAuth (仅 Bearer 认证)
|
||||
if self.is_bearer_only_mode(provider) {
|
||||
return ProviderType::ClaudeAuth;
|
||||
}
|
||||
|
||||
ProviderType::Claude
|
||||
}
|
||||
|
||||
/// 检测是否使用 OpenRouter
|
||||
fn is_openrouter(&self, provider: &Provider) -> bool {
|
||||
if let Ok(base_url) = self.extract_base_url(provider) {
|
||||
return base_url.contains("openrouter.ai");
|
||||
}
|
||||
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 认证模式
|
||||
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
|
||||
// 检查 settings_config 中的 auth_mode
|
||||
if let Some(auth_mode) = provider
|
||||
.settings_config
|
||||
.get("auth_mode")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if auth_mode == "bearer_only" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 env 中的 AUTH_MODE
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) {
|
||||
if auth_mode == "bearer_only" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 从 Provider 配置中提取 API Key
|
||||
fn extract_key(&self, provider: &Provider) -> Option<String> {
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
// Anthropic 标准 key
|
||||
if let Some(key) = env
|
||||
.get("ANTHROPIC_AUTH_TOKEN")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
|
||||
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
|
||||
if let Some(key) = env
|
||||
.get("OPENROUTER_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 OPENROUTER_API_KEY");
|
||||
return Some(key.to_string());
|
||||
}
|
||||
// 备选 OpenAI key (用于 OpenRouter)
|
||||
if let Some(key) = env
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 OPENAI_API_KEY");
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试直接获取
|
||||
if let Some(key) = provider
|
||||
.settings_config
|
||||
.get("apiKey")
|
||||
.or_else(|| provider.settings_config.get("api_key"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 apiKey/api_key");
|
||||
return Some(key.to_string());
|
||||
}
|
||||
|
||||
log::warn!("[Claude] 未找到有效的 API Key");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClaudeAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderAdapter for ClaudeAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"Claude"
|
||||
}
|
||||
|
||||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||
// 1. 从 env 中获取
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试直接获取
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
.get("baseURL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
.get("apiEndpoint")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
Err(ProxyError::ConfigError(
|
||||
"Claude Provider 缺少 base_url 配置".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
let provider_type = self.provider_type(provider);
|
||||
let strategy = match provider_type {
|
||||
ProviderType::OpenRouter => AuthStrategy::Bearer,
|
||||
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
|
||||
_ => AuthStrategy::Anthropic,
|
||||
};
|
||||
|
||||
self.extract_key(provider)
|
||||
.map(|key| AuthInfo::new(key, strategy))
|
||||
}
|
||||
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
// NOTE:
|
||||
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages`
|
||||
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。
|
||||
//
|
||||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||
|
||||
let base = format!(
|
||||
"{}/{}",
|
||||
base_url.trim_end_matches('/'),
|
||||
endpoint.trim_start_matches('/')
|
||||
);
|
||||
|
||||
// 为 /v1/messages 端点添加 ?beta=true 参数
|
||||
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
||||
if endpoint.contains("/v1/messages") && !endpoint.contains("?") {
|
||||
format!("{base}?beta=true")
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
// 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
|
||||
// 这里不再设置 anthropic-version,避免 header 重复
|
||||
match auth.strategy {
|
||||
// Anthropic 官方: Authorization Bearer + x-api-key
|
||||
AuthStrategy::Anthropic => request
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("x-api-key", &auth.api_key),
|
||||
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
|
||||
AuthStrategy::ClaudeAuth => {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
}
|
||||
// OpenRouter: Bearer
|
||||
AuthStrategy::Bearer => {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
}
|
||||
_ => request,
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_transform(&self, _provider: &Provider) -> bool {
|
||||
// NOTE:
|
||||
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
|
||||
// Anthropic ↔ OpenAI 的格式转换。
|
||||
//
|
||||
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
|
||||
self.is_openrouter_compat_enabled(_provider)
|
||||
}
|
||||
|
||||
fn transform_request(
|
||||
&self,
|
||||
body: serde_json::Value,
|
||||
provider: &Provider,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
super::transform::anthropic_to_openai(body, provider)
|
||||
}
|
||||
|
||||
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
||||
super::transform::openai_to_anthropic(body)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn create_provider(config: serde_json::Value) -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test Claude".to_string(),
|
||||
settings_config: config,
|
||||
website_url: None,
|
||||
category: Some("claude".to_string()),
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_base_url_from_env() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
}
|
||||
}));
|
||||
|
||||
let url = adapter.extract_base_url(&provider).unwrap();
|
||||
assert_eq!(url, "https://api.anthropic.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_anthropic() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "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]
|
||||
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]
|
||||
fn test_extract_auth_openrouter() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||||
"OPENROUTER_API_KEY": "sk-or-test-key"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "sk-or-test-key");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_claude_auth_mode() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key"
|
||||
},
|
||||
"auth_mode": "bearer_only"
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "sk-proxy-key");
|
||||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_claude_auth_env_mode() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key",
|
||||
"AUTH_MODE": "bearer_only"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "sk-proxy-key");
|
||||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_detection() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
// Anthropic 官方
|
||||
let anthropic = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
|
||||
}
|
||||
}));
|
||||
assert_eq!(adapter.provider_type(&anthropic), ProviderType::Claude);
|
||||
|
||||
// OpenRouter
|
||||
let openrouter = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||||
"OPENROUTER_API_KEY": "sk-or-test"
|
||||
}
|
||||
}));
|
||||
assert_eq!(adapter.provider_type(&openrouter), ProviderType::OpenRouter);
|
||||
|
||||
// ClaudeAuth
|
||||
let claude_auth = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-test"
|
||||
},
|
||||
"auth_mode": "bearer_only"
|
||||
}));
|
||||
assert_eq!(
|
||||
adapter.provider_type(&claude_auth),
|
||||
ProviderType::ClaudeAuth
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_anthropic() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// /v1/messages 端点会自动添加 ?beta=true 参数
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_openrouter() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// /v1/messages 端点会自动添加 ?beta=true 参数
|
||||
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
|
||||
assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_other_endpoints() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// 非 /v1/messages 端点不添加 ?beta=true
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_preserve_existing_query() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// 已有查询参数时不重复添加
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_transform() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
let anthropic_provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
}
|
||||
}));
|
||||
assert!(!adapter.needs_transform(&anthropic_provider));
|
||||
|
||||
let openrouter_provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||
}
|
||||
}));
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Codex (OpenAI) Provider Adapter
|
||||
//!
|
||||
//! 仅透传模式,支持直连 OpenAI API
|
||||
//!
|
||||
//! ## 客户端检测
|
||||
//! 支持检测官方 Codex 客户端 (codex_vscode, codex_cli_rs)
|
||||
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use regex::Regex;
|
||||
use reqwest::RequestBuilder;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// 官方 Codex 客户端 User-Agent 正则
|
||||
#[allow(dead_code)]
|
||||
static CODEX_CLIENT_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^(codex_vscode|codex_cli_rs)/[\d.]+").unwrap());
|
||||
|
||||
/// Codex 适配器
|
||||
pub struct CodexAdapter;
|
||||
|
||||
impl CodexAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// 检测是否为官方 Codex 客户端
|
||||
///
|
||||
/// 匹配 User-Agent 模式: `^(codex_vscode|codex_cli_rs)/[\d.]+`
|
||||
#[allow(dead_code)]
|
||||
pub fn is_official_client(user_agent: &str) -> bool {
|
||||
CODEX_CLIENT_REGEX.is_match(user_agent)
|
||||
}
|
||||
|
||||
/// 从 Provider 配置中提取 API Key
|
||||
fn extract_key(&self, provider: &Provider) -> Option<String> {
|
||||
// 1. 尝试从 env 中获取
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
if let Some(key) = env.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试从 auth 中获取 (Codex CLI 格式)
|
||||
if let Some(auth) = provider.settings_config.get("auth") {
|
||||
if let Some(key) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 尝试直接获取
|
||||
if let Some(key) = provider
|
||||
.settings_config
|
||||
.get("apiKey")
|
||||
.or_else(|| provider.settings_config.get("api_key"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Some(key.to_string());
|
||||
}
|
||||
|
||||
// 4. 尝试从 config 对象中获取
|
||||
if let Some(config) = provider.settings_config.get("config") {
|
||||
if let Some(key) = config
|
||||
.get("api_key")
|
||||
.or_else(|| config.get("apiKey"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CodexAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderAdapter for CodexAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"Codex"
|
||||
}
|
||||
|
||||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||
// 1. 尝试直接获取 base_url 字段
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
// 2. 尝试 baseURL
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
.get("baseURL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
// 3. 尝试从 config 对象中获取
|
||||
if let Some(config) = provider.settings_config.get("config") {
|
||||
if let Some(url) = config.get("base_url").and_then(|v| v.as_str()) {
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
// 尝试解析 TOML 字符串格式
|
||||
if let Some(config_str) = config.as_str() {
|
||||
if let Some(start) = config_str.find("base_url = \"") {
|
||||
let rest = &config_str[start + 12..];
|
||||
if let Some(end) = rest.find('"') {
|
||||
return Ok(rest[..end].trim_end_matches('/').to_string());
|
||||
}
|
||||
}
|
||||
if let Some(start) = config_str.find("base_url = '") {
|
||||
let rest = &config_str[start + 12..];
|
||||
if let Some(end) = rest.find('\'') {
|
||||
return Ok(rest[..end].trim_end_matches('/').to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(ProxyError::ConfigError(
|
||||
"Codex Provider 缺少 base_url 配置".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
self.extract_key(provider)
|
||||
.map(|key| AuthInfo::new(key, AuthStrategy::Bearer))
|
||||
}
|
||||
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
let base_trimmed = base_url.trim_end_matches('/');
|
||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||
|
||||
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
|
||||
|
||||
// 去除重复的 /v1/v1
|
||||
if url.contains("/v1/v1") {
|
||||
url = url.replace("/v1/v1", "/v1");
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn create_provider(config: serde_json::Value) -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test Codex".to_string(),
|
||||
settings_config: config,
|
||||
website_url: None,
|
||||
category: Some("codex".to_string()),
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_base_url_direct() {
|
||||
let adapter = CodexAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"base_url": "https://api.openai.com/v1"
|
||||
}));
|
||||
|
||||
let url = adapter.extract_base_url(&provider).unwrap();
|
||||
assert_eq!(url, "https://api.openai.com/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_from_auth_field() {
|
||||
let adapter = CodexAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "sk-test-key-12345678"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "sk-test-key-12345678");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_from_env() {
|
||||
let adapter = CodexAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-env-key-12345678"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "sk-env-key-12345678");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url() {
|
||||
let adapter = CodexAdapter::new();
|
||||
let url = adapter.build_url("https://api.openai.com/v1", "/responses");
|
||||
assert_eq!(url, "https://api.openai.com/v1/responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_dedup_v1() {
|
||||
let adapter = CodexAdapter::new();
|
||||
// base_url 已包含 /v1,endpoint 也包含 /v1
|
||||
let url = adapter.build_url("https://www.packyapi.com/v1", "/v1/responses");
|
||||
assert_eq!(url, "https://www.packyapi.com/v1/responses");
|
||||
}
|
||||
|
||||
// 官方客户端检测测试
|
||||
#[test]
|
||||
fn test_is_official_client_vscode() {
|
||||
assert!(CodexAdapter::is_official_client("codex_vscode/1.0.0"));
|
||||
assert!(CodexAdapter::is_official_client("codex_vscode/2.3.4"));
|
||||
assert!(CodexAdapter::is_official_client("codex_vscode/0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_official_client_cli() {
|
||||
assert!(CodexAdapter::is_official_client("codex_cli_rs/1.0.0"));
|
||||
assert!(CodexAdapter::is_official_client("codex_cli_rs/0.5.2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_not_official_client() {
|
||||
assert!(!CodexAdapter::is_official_client("Mozilla/5.0"));
|
||||
assert!(!CodexAdapter::is_official_client("curl/7.68.0"));
|
||||
assert!(!CodexAdapter::is_official_client("python-requests/2.25.1"));
|
||||
assert!(!CodexAdapter::is_official_client("codex_other/1.0.0"));
|
||||
assert!(!CodexAdapter::is_official_client(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_official_client_partial_match() {
|
||||
// 必须从开头匹配
|
||||
assert!(!CodexAdapter::is_official_client("some codex_vscode/1.0.0"));
|
||||
assert!(!CodexAdapter::is_official_client(
|
||||
"prefix_codex_cli_rs/1.0.0"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
//! Gemini (Google) Provider Adapter
|
||||
//!
|
||||
//! 支持 API Key 和 OAuth 两种认证方式
|
||||
//!
|
||||
//! ## 认证模式
|
||||
//! - **Gemini**: API Key 认证 (x-goog-api-key)
|
||||
//! - **GeminiCli**: OAuth Bearer 认证 (用于 Gemini CLI)
|
||||
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
|
||||
/// Gemini 适配器
|
||||
pub struct GeminiAdapter;
|
||||
|
||||
/// OAuth 凭证结构
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct OAuthCredentials {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub client_id: Option<String>,
|
||||
pub client_secret: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl OAuthCredentials {
|
||||
/// 检查是否需要刷新 token(有 refresh_token 但没有有效的 access_token)
|
||||
pub fn needs_refresh(&self) -> bool {
|
||||
self.refresh_token.is_some() && self.access_token.is_empty()
|
||||
}
|
||||
|
||||
/// 检查是否可以刷新 token
|
||||
pub fn can_refresh(&self) -> bool {
|
||||
self.refresh_token.is_some() && self.client_id.is_some() && self.client_secret.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl GeminiAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// 获取供应商类型
|
||||
///
|
||||
/// 根据 API Key 格式检测:
|
||||
/// - GeminiCli: access_token (ya29. 开头) 或 JSON 格式凭证
|
||||
/// - Gemini: 普通 API Key
|
||||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||||
if let Some(key) = self.extract_key_raw(provider) {
|
||||
// OAuth access_token 以 ya29. 开头
|
||||
if key.starts_with("ya29.") {
|
||||
return ProviderType::GeminiCli;
|
||||
}
|
||||
// JSON 格式的 OAuth 凭证
|
||||
if key.starts_with('{') {
|
||||
return ProviderType::GeminiCli;
|
||||
}
|
||||
}
|
||||
ProviderType::Gemini
|
||||
}
|
||||
|
||||
/// 检测认证类型
|
||||
pub fn detect_auth_type(&self, provider: &Provider) -> AuthStrategy {
|
||||
match self.provider_type(provider) {
|
||||
ProviderType::GeminiCli => AuthStrategy::GoogleOAuth,
|
||||
_ => AuthStrategy::Google,
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 OAuth 凭证
|
||||
pub fn parse_oauth_credentials(&self, key: &str) -> Option<OAuthCredentials> {
|
||||
// 直接是 access_token
|
||||
if key.starts_with("ya29.") {
|
||||
return Some(OAuthCredentials {
|
||||
access_token: key.to_string(),
|
||||
refresh_token: None,
|
||||
client_id: None,
|
||||
client_secret: None,
|
||||
});
|
||||
}
|
||||
|
||||
// JSON 格式
|
||||
if key.starts_with('{') {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(key) {
|
||||
let access_token = json
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let refresh_token = json
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let client_id = json
|
||||
.get("client_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let client_secret = json
|
||||
.get("client_secret")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// 如果有 access_token 或 refresh_token,返回凭证
|
||||
if !access_token.is_empty() || refresh_token.is_some() {
|
||||
return Some(OAuthCredentials {
|
||||
access_token,
|
||||
refresh_token,
|
||||
client_id,
|
||||
client_secret,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 从 Provider 配置中提取原始 API Key
|
||||
fn extract_key_raw(&self, provider: &Provider) -> Option<String> {
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
// 使用 GEMINI_API_KEY
|
||||
if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试直接获取
|
||||
if let Some(key) = provider
|
||||
.settings_config
|
||||
.get("apiKey")
|
||||
.or_else(|| provider.settings_config.get("api_key"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Some(key.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GeminiAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderAdapter for GeminiAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"Gemini"
|
||||
}
|
||||
|
||||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||
// 从 env 中获取
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
if let Some(url) = env.get("GOOGLE_GEMINI_BASE_URL").and_then(|v| v.as_str()) {
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试直接获取
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
.get("baseURL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
Err(ProxyError::ConfigError(
|
||||
"Gemini Provider 缺少 base_url 配置".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
let key = self.extract_key_raw(provider)?;
|
||||
let strategy = self.detect_auth_type(provider);
|
||||
|
||||
match strategy {
|
||||
AuthStrategy::GoogleOAuth => {
|
||||
// 解析 OAuth 凭证
|
||||
if let Some(creds) = self.parse_oauth_credentials(&key) {
|
||||
Some(AuthInfo::with_access_token(key, creds.access_token))
|
||||
} else {
|
||||
// 回退到普通 API Key
|
||||
Some(AuthInfo::new(key, AuthStrategy::Google))
|
||||
}
|
||||
}
|
||||
_ => Some(AuthInfo::new(key, AuthStrategy::Google)),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
let base_trimmed = base_url.trim_end_matches('/');
|
||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||
|
||||
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
|
||||
|
||||
// 处理 /v1beta 路径去重
|
||||
let version_patterns = ["/v1beta", "/v1"];
|
||||
for pattern in &version_patterns {
|
||||
let duplicate = format!("{pattern}{pattern}");
|
||||
if url.contains(&duplicate) {
|
||||
url = url.replace(&duplicate, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
match auth.strategy {
|
||||
// OAuth Bearer 认证
|
||||
AuthStrategy::GoogleOAuth => {
|
||||
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
|
||||
request
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("x-goog-api-client", "GeminiCLI/1.0")
|
||||
}
|
||||
// API Key 认证
|
||||
_ => request.header("x-goog-api-key", &auth.api_key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn create_provider(config: serde_json::Value) -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test Gemini".to_string(),
|
||||
settings_config: config,
|
||||
website_url: None,
|
||||
category: Some("gemini".to_string()),
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_base_url_from_env() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com/v1beta"
|
||||
}
|
||||
}));
|
||||
|
||||
let url = adapter.extract_base_url(&provider).unwrap();
|
||||
assert_eq!(url, "https://generativelanguage.googleapis.com/v1beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_api_key() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GEMINI_API_KEY": "AIza-test-key-12345678"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "AIza-test-key-12345678");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Google);
|
||||
assert!(auth.access_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_oauth_access_token() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GEMINI_API_KEY": "ya29.test-access-token-12345"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
assert_eq!(
|
||||
auth.access_token,
|
||||
Some("ya29.test-access-token-12345".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_oauth_json() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test-token\",\"refresh_token\":\"1//refresh\"}"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
assert_eq!(auth.access_token, Some("ya29.test-token".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_detection() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
|
||||
// API Key
|
||||
let api_key_provider = create_provider(json!({
|
||||
"env": {
|
||||
"GEMINI_API_KEY": "AIza-test-key"
|
||||
}
|
||||
}));
|
||||
assert_eq!(
|
||||
adapter.provider_type(&api_key_provider),
|
||||
ProviderType::Gemini
|
||||
);
|
||||
|
||||
// OAuth access_token
|
||||
let oauth_provider = create_provider(json!({
|
||||
"env": {
|
||||
"GEMINI_API_KEY": "ya29.test-token"
|
||||
}
|
||||
}));
|
||||
assert_eq!(
|
||||
adapter.provider_type(&oauth_provider),
|
||||
ProviderType::GeminiCli
|
||||
);
|
||||
|
||||
// OAuth JSON
|
||||
let oauth_json_provider = create_provider(json!({
|
||||
"env": {
|
||||
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test\"}"
|
||||
}
|
||||
}));
|
||||
assert_eq!(
|
||||
adapter.provider_type(&oauth_json_provider),
|
||||
ProviderType::GeminiCli
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_fallback() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GEMINI_API_KEY": "AIza-fallback-key"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "AIza-fallback-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_dedup() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
// 模拟 base_url 已包含 /v1beta,endpoint 也包含 /v1beta
|
||||
let url = adapter.build_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
"/v1beta/models/gemini-pro:generateContent",
|
||||
);
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_normal() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let url = adapter.build_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
"/models/gemini-pro:generateContent",
|
||||
);
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_oauth_credentials_direct_token() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let creds = adapter
|
||||
.parse_oauth_credentials("ya29.test-access-token")
|
||||
.unwrap();
|
||||
assert_eq!(creds.access_token, "ya29.test-access-token");
|
||||
assert!(creds.refresh_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_oauth_credentials_json() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let creds = adapter
|
||||
.parse_oauth_credentials(
|
||||
"{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//refresh\"}",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(creds.access_token, "ya29.test");
|
||||
assert_eq!(creds.refresh_token, Some("1//refresh".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_oauth_credentials_invalid() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
assert!(adapter.parse_oauth_credentials("AIza-api-key").is_none());
|
||||
assert!(adapter.parse_oauth_credentials("invalid-json{").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
//! Provider Adapters Module
|
||||
//!
|
||||
//! 供应商适配器模块,提供统一的接口抽象不同上游供应商的处理逻辑。
|
||||
//!
|
||||
//! ## 模块结构
|
||||
//! - `adapter`: 定义 `ProviderAdapter` trait
|
||||
//! - `auth`: 认证类型和策略
|
||||
//! - `claude`: Claude (Anthropic) 适配器
|
||||
//! - `codex`: Codex (OpenAI) 适配器
|
||||
//! - `gemini`: Gemini (Google) 适配器
|
||||
//! - `models`: API 数据模型
|
||||
//! - `transform`: 格式转换
|
||||
|
||||
mod adapter;
|
||||
mod auth;
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
pub mod models;
|
||||
pub mod streaming;
|
||||
pub mod transform;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::Provider;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// 公开导出
|
||||
pub use adapter::ProviderAdapter;
|
||||
pub use auth::{AuthInfo, AuthStrategy};
|
||||
pub use claude::ClaudeAdapter;
|
||||
pub use codex::CodexAdapter;
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
/// 供应商类型枚举
|
||||
///
|
||||
/// 区分不同供应商的具体实现方式,决定认证和请求处理逻辑。
|
||||
/// 比 AppType 更细粒度,支持同一 AppType 下的多种变体。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderType {
|
||||
/// Anthropic 官方 API (x-api-key + anthropic-version)
|
||||
Claude,
|
||||
/// Claude 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||||
ClaudeAuth,
|
||||
/// OpenAI Codex Response API
|
||||
Codex,
|
||||
/// Google Gemini API (x-goog-api-key)
|
||||
Gemini,
|
||||
/// Google Gemini CLI (OAuth Bearer)
|
||||
GeminiCli,
|
||||
/// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用)
|
||||
OpenRouter,
|
||||
}
|
||||
|
||||
impl ProviderType {
|
||||
/// 是否需要格式转换
|
||||
///
|
||||
/// 过去 OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式;
|
||||
/// 现在默认关闭转换(因为 OpenRouter 已支持 Claude Code 兼容接口)。
|
||||
#[allow(dead_code)]
|
||||
pub fn needs_transform(&self) -> bool {
|
||||
match self {
|
||||
ProviderType::OpenRouter => false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取默认端点
|
||||
#[allow(dead_code)]
|
||||
pub fn default_endpoint(&self) -> &'static str {
|
||||
match self {
|
||||
ProviderType::Claude | ProviderType::ClaudeAuth => "https://api.anthropic.com",
|
||||
ProviderType::Codex => "https://api.openai.com",
|
||||
ProviderType::Gemini | ProviderType::GeminiCli => {
|
||||
"https://generativelanguage.googleapis.com"
|
||||
}
|
||||
ProviderType::OpenRouter => "https://openrouter.ai/api",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 AppType 和 Provider 配置推断供应商类型
|
||||
///
|
||||
/// 根据配置中的 base_url、auth_mode、api_key 格式等信息推断具体的供应商类型
|
||||
#[allow(dead_code)]
|
||||
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
// 检测是否为 OpenRouter
|
||||
let adapter = ClaudeAdapter::new();
|
||||
if let Ok(base_url) = adapter.extract_base_url(provider) {
|
||||
if base_url.contains("openrouter.ai") {
|
||||
return ProviderType::OpenRouter;
|
||||
}
|
||||
}
|
||||
// 检测是否为中转服务(仅 Bearer 认证)
|
||||
// 注意:ProviderMeta 没有直接的 auth_mode 字段,
|
||||
// 我们通过检查 settings_config 中的配置来判断
|
||||
// 检查 settings_config 中的 auth_mode
|
||||
if let Some(auth_mode) = provider
|
||||
.settings_config
|
||||
.get("auth_mode")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if auth_mode == "bearer_only" {
|
||||
return ProviderType::ClaudeAuth;
|
||||
}
|
||||
}
|
||||
// 检查 env 中的 auth_mode
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) {
|
||||
if auth_mode == "bearer_only" {
|
||||
return ProviderType::ClaudeAuth;
|
||||
}
|
||||
}
|
||||
}
|
||||
ProviderType::Claude
|
||||
}
|
||||
AppType::Codex => ProviderType::Codex,
|
||||
AppType::Gemini => {
|
||||
// 检测是否为 CLI 模式(OAuth)
|
||||
let adapter = GeminiAdapter::new();
|
||||
if let Some(auth) = adapter.extract_auth(provider) {
|
||||
let key = &auth.api_key;
|
||||
// OAuth access_token 以 ya29. 开头
|
||||
if key.starts_with("ya29.") {
|
||||
return ProviderType::GeminiCli;
|
||||
}
|
||||
// JSON 格式的 OAuth 凭证
|
||||
if key.starts_with('{') {
|
||||
return ProviderType::GeminiCli;
|
||||
}
|
||||
}
|
||||
ProviderType::Gemini
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换为字符串表示
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ProviderType::Claude => "claude",
|
||||
ProviderType::ClaudeAuth => "claude_auth",
|
||||
ProviderType::Codex => "codex",
|
||||
ProviderType::Gemini => "gemini",
|
||||
ProviderType::GeminiCli => "gemini_cli",
|
||||
ProviderType::OpenRouter => "openrouter",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProviderType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ProviderType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"claude" => Ok(ProviderType::Claude),
|
||||
"claude_auth" | "claude-auth" => Ok(ProviderType::ClaudeAuth),
|
||||
"codex" => Ok(ProviderType::Codex),
|
||||
"gemini" => Ok(ProviderType::Gemini),
|
||||
"gemini_cli" | "gemini-cli" => Ok(ProviderType::GeminiCli),
|
||||
"openrouter" => Ok(ProviderType::OpenRouter),
|
||||
_ => Err(format!("Invalid provider type: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 AppType 获取对应的适配器
|
||||
pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
match app_type {
|
||||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 ProviderType 获取对应的适配器
|
||||
#[allow(dead_code)]
|
||||
pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn ProviderAdapter> {
|
||||
match provider_type {
|
||||
ProviderType::Claude | ProviderType::ClaudeAuth | ProviderType::OpenRouter => {
|
||||
Box::new(ClaudeAdapter::new())
|
||||
}
|
||||
ProviderType::Codex => Box::new(CodexAdapter::new()),
|
||||
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn create_provider(config: serde_json::Value) -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test Provider".to_string(),
|
||||
settings_config: config,
|
||||
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_provider_type_needs_transform() {
|
||||
assert!(!ProviderType::Claude.needs_transform());
|
||||
assert!(!ProviderType::ClaudeAuth.needs_transform());
|
||||
assert!(!ProviderType::Codex.needs_transform());
|
||||
assert!(!ProviderType::Gemini.needs_transform());
|
||||
assert!(!ProviderType::GeminiCli.needs_transform());
|
||||
assert!(!ProviderType::OpenRouter.needs_transform());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_default_endpoint() {
|
||||
assert_eq!(
|
||||
ProviderType::Claude.default_endpoint(),
|
||||
"https://api.anthropic.com"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::ClaudeAuth.default_endpoint(),
|
||||
"https://api.anthropic.com"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::Codex.default_endpoint(),
|
||||
"https://api.openai.com"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::Gemini.default_endpoint(),
|
||||
"https://generativelanguage.googleapis.com"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::GeminiCli.default_endpoint(),
|
||||
"https://generativelanguage.googleapis.com"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::OpenRouter.default_endpoint(),
|
||||
"https://openrouter.ai/api"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_from_str() {
|
||||
assert_eq!(
|
||||
"claude".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::Claude
|
||||
);
|
||||
assert_eq!(
|
||||
"claude_auth".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::ClaudeAuth
|
||||
);
|
||||
assert_eq!(
|
||||
"claude-auth".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::ClaudeAuth
|
||||
);
|
||||
assert_eq!(
|
||||
"codex".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::Codex
|
||||
);
|
||||
assert_eq!(
|
||||
"gemini".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::Gemini
|
||||
);
|
||||
assert_eq!(
|
||||
"gemini_cli".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::GeminiCli
|
||||
);
|
||||
assert_eq!(
|
||||
"gemini-cli".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::GeminiCli
|
||||
);
|
||||
assert_eq!(
|
||||
"openrouter".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::OpenRouter
|
||||
);
|
||||
assert!("invalid".parse::<ProviderType>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_as_str() {
|
||||
assert_eq!(ProviderType::Claude.as_str(), "claude");
|
||||
assert_eq!(ProviderType::ClaudeAuth.as_str(), "claude_auth");
|
||||
assert_eq!(ProviderType::Codex.as_str(), "codex");
|
||||
assert_eq!(ProviderType::Gemini.as_str(), "gemini");
|
||||
assert_eq!(ProviderType::GeminiCli.as_str(), "gemini_cli");
|
||||
assert_eq!(ProviderType::OpenRouter.as_str(), "openrouter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_serde() {
|
||||
// Test serialization
|
||||
let claude = ProviderType::Claude;
|
||||
let serialized = serde_json::to_string(&claude).unwrap();
|
||||
assert_eq!(serialized, "\"claude\"");
|
||||
|
||||
let claude_auth = ProviderType::ClaudeAuth;
|
||||
let serialized = serde_json::to_string(&claude_auth).unwrap();
|
||||
assert_eq!(serialized, "\"claude_auth\"");
|
||||
|
||||
// Test deserialization
|
||||
let deserialized: ProviderType = serde_json::from_str("\"claude\"").unwrap();
|
||||
assert_eq!(deserialized, ProviderType::Claude);
|
||||
|
||||
let deserialized: ProviderType = serde_json::from_str("\"gemini_cli\"").unwrap();
|
||||
assert_eq!(deserialized, ProviderType::GeminiCli);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_app_type_claude_direct() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
|
||||
assert_eq!(provider_type, ProviderType::Claude);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_app_type_claude_openrouter() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||||
"OPENROUTER_API_KEY": "sk-or-test"
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
|
||||
assert_eq!(provider_type, ProviderType::OpenRouter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_app_type_claude_auth() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-test"
|
||||
},
|
||||
"auth_mode": "bearer_only"
|
||||
}));
|
||||
|
||||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
|
||||
assert_eq!(provider_type, ProviderType::ClaudeAuth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_app_type_codex() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-test"
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Codex, &provider);
|
||||
assert_eq!(provider_type, ProviderType::Codex);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_app_type_gemini_api_key() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GEMINI_API_KEY": "AIza-test-key"
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
|
||||
assert_eq!(provider_type, ProviderType::Gemini);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_app_type_gemini_cli_oauth() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GEMINI_API_KEY": "ya29.test-access-token"
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
|
||||
assert_eq!(provider_type, ProviderType::GeminiCli);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_app_type_gemini_cli_json() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//test\"}"
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
|
||||
assert_eq!(provider_type, ProviderType::GeminiCli);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_for_provider_type() {
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::Claude);
|
||||
assert_eq!(adapter.name(), "Claude");
|
||||
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::ClaudeAuth);
|
||||
assert_eq!(adapter.name(), "Claude");
|
||||
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::OpenRouter);
|
||||
assert_eq!(adapter.name(), "Claude");
|
||||
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::Codex);
|
||||
assert_eq!(adapter.name(), "Codex");
|
||||
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::Gemini);
|
||||
assert_eq!(adapter.name(), "Gemini");
|
||||
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::GeminiCli);
|
||||
assert_eq!(adapter.name(), "Gemini");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Anthropic API 数据模型
|
||||
//!
|
||||
//! 用于 Anthropic Messages API 的请求/响应格式转换
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Anthropic 请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnthropicRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<AnthropicMessage>,
|
||||
pub max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system: Option<Value>, // 可以是 String 或 Vec<SystemBlock>
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<AnthropicTool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<Value>,
|
||||
}
|
||||
|
||||
/// Anthropic 消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnthropicMessage {
|
||||
pub role: String,
|
||||
pub content: Value, // String 或 Vec<ContentBlock>
|
||||
}
|
||||
|
||||
/// Anthropic 内容块
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AnthropicContentBlock {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "image")]
|
||||
Image { source: ImageSource },
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: Value,
|
||||
},
|
||||
#[serde(rename = "tool_result")]
|
||||
ToolResult { tool_use_id: String, content: Value },
|
||||
}
|
||||
|
||||
/// 图片来源
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageSource {
|
||||
#[serde(rename = "type")]
|
||||
pub source_type: String,
|
||||
pub media_type: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// Anthropic 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnthropicTool {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
/// Anthropic 响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnthropicResponse {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub response_type: String,
|
||||
pub role: String,
|
||||
pub content: Vec<AnthropicResponseContent>,
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop_reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop_sequence: Option<String>,
|
||||
pub usage: AnthropicUsage,
|
||||
}
|
||||
|
||||
/// Anthropic 响应内容
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AnthropicResponseContent {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// Anthropic 使用量
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnthropicUsage {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! API 数据模型
|
||||
//!
|
||||
//! 定义 Anthropic 和 OpenAI API 的请求/响应结构
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod openai;
|
||||
@@ -0,0 +1,113 @@
|
||||
//! OpenAI API 数据模型
|
||||
//!
|
||||
//! 用于 OpenAI Chat Completions API 的请求/响应格式转换
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// OpenAI 请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<OpenAIMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<OpenAITool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<Value>,
|
||||
}
|
||||
|
||||
/// OpenAI 消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIMessage {
|
||||
pub role: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<Value>, // String 或 Vec<ContentPart>
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<OpenAIToolCall>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 内容部分
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum OpenAIContentPart {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "image_url")]
|
||||
ImageUrl { image_url: ImageUrl },
|
||||
}
|
||||
|
||||
/// 图片 URL
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageUrl {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// OpenAI 工具调用
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub function: OpenAIFunction,
|
||||
}
|
||||
|
||||
/// OpenAI 函数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIFunction {
|
||||
pub name: String,
|
||||
pub arguments: String, // JSON 字符串
|
||||
}
|
||||
|
||||
/// OpenAI 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAITool {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub function: OpenAIFunctionDef,
|
||||
}
|
||||
|
||||
/// OpenAI 函数定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIFunctionDef {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// OpenAI 响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIResponse {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub created: u64,
|
||||
pub model: String,
|
||||
pub choices: Vec<OpenAIChoice>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<OpenAIUsage>,
|
||||
}
|
||||
|
||||
/// OpenAI 选择
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIChoice {
|
||||
pub index: u32,
|
||||
pub message: OpenAIMessage,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 使用量
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! 流式响应转换模块
|
||||
//!
|
||||
//! 实现 OpenAI SSE → Anthropic SSE 格式转换
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
/// OpenAI 流式响应数据结构
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAIStreamChunk {
|
||||
id: String,
|
||||
model: String,
|
||||
choices: Vec<StreamChoice>,
|
||||
#[serde(default)]
|
||||
usage: Option<Usage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StreamChoice {
|
||||
delta: Delta,
|
||||
#[serde(default)]
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Delta {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
reasoning: Option<String>, // OpenRouter 的推理内容
|
||||
#[serde(default)]
|
||||
tool_calls: Option<Vec<DeltaToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct DeltaToolCall {
|
||||
index: usize,
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(rename = "type", default)]
|
||||
call_type: Option<String>,
|
||||
#[serde(default)]
|
||||
function: Option<DeltaFunction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct DeltaFunction {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
arguments: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 流式响应的 usage 信息(完整版)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Usage {
|
||||
#[serde(default)]
|
||||
prompt_tokens: u32,
|
||||
#[serde(default)]
|
||||
completion_tokens: u32,
|
||||
}
|
||||
|
||||
/// 创建 Anthropic SSE 流
|
||||
pub fn create_anthropic_sse_stream(
|
||||
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut message_id = None;
|
||||
let mut current_model = None;
|
||||
let mut content_index = 0;
|
||||
let mut has_sent_message_start = false;
|
||||
let mut current_block_type: Option<String> = None;
|
||||
let mut tool_call_id = None;
|
||||
|
||||
log::info!("[Claude/OpenRouter] ====== 开始流式响应转换 ======");
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let line = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for l in line.lines() {
|
||||
if let Some(data) = l.strip_prefix("data: ") {
|
||||
if data.trim() == "[DONE]" {
|
||||
log::info!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
|
||||
let event = json!({"type": "message_stop"});
|
||||
let sse_data = format!("event: message_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
log::info!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
|
||||
// 记录原始 OpenAI 事件(格式化显示)
|
||||
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(data) {
|
||||
log::info!(
|
||||
"[Claude/OpenRouter] <<< OpenAI SSE 事件:\n{}",
|
||||
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
|
||||
);
|
||||
} else {
|
||||
log::info!("[Claude/OpenRouter] <<< OpenAI SSE 数据: {data}");
|
||||
}
|
||||
|
||||
if message_id.is_none() {
|
||||
message_id = Some(chunk.id.clone());
|
||||
}
|
||||
if current_model.is_none() {
|
||||
current_model = Some(chunk.model.clone());
|
||||
}
|
||||
|
||||
if let Some(choice) = chunk.choices.first() {
|
||||
if !has_sent_message_start {
|
||||
let event = json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": message_id.clone().unwrap_or_default(),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": current_model.clone().unwrap_or_default(),
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
}
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: message_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
has_sent_message_start = true;
|
||||
}
|
||||
|
||||
// 处理 reasoning(thinking)
|
||||
if let Some(reasoning) = &choice.delta.reasoning {
|
||||
if current_block_type.is_none() {
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": content_index,
|
||||
"content_block": {
|
||||
"type": "thinking",
|
||||
"thinking": ""
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
current_block_type = Some("thinking".to_string());
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": content_index,
|
||||
"delta": {
|
||||
"type": "thinking_delta",
|
||||
"thinking": reasoning
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
|
||||
// 处理文本内容
|
||||
if let Some(content) = &choice.delta.content {
|
||||
if !content.is_empty() {
|
||||
if current_block_type.as_deref() != Some("text") {
|
||||
if current_block_type.is_some() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": content_index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
content_index += 1;
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": content_index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
current_block_type = Some("text".to_string());
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": content_index,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": content
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if let Some(tool_calls) = &choice.delta.tool_calls {
|
||||
for tool_call in tool_calls {
|
||||
if let Some(id) = &tool_call.id {
|
||||
if current_block_type.is_some() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": content_index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
content_index += 1;
|
||||
}
|
||||
|
||||
tool_call_id = Some(id.clone());
|
||||
}
|
||||
|
||||
if let Some(function) = &tool_call.function {
|
||||
if let Some(name) = &function.name {
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": content_index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": tool_call_id.clone().unwrap_or_default(),
|
||||
"name": name
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
current_block_type = Some("tool_use".to_string());
|
||||
}
|
||||
|
||||
if let Some(args) = &function.arguments {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": content_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": args
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 finish_reason
|
||||
if let Some(finish_reason) = &choice.finish_reason {
|
||||
if current_block_type.is_some() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": content_index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
|
||||
let stop_reason = map_stop_reason(Some(finish_reason));
|
||||
// 构建 usage 信息,包含 input_tokens 和 output_tokens
|
||||
let usage_json = chunk.usage.as_ref().map(|u| json!({
|
||||
"input_tokens": u.prompt_tokens,
|
||||
"output_tokens": u.completion_tokens
|
||||
}));
|
||||
let event = json!({
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": null
|
||||
},
|
||||
"usage": usage_json
|
||||
});
|
||||
let sse_data = format!("event: message_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Stream error: {e}");
|
||||
let error_event = json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "stream_error",
|
||||
"message": format!("Stream error: {e}")
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: error\ndata: {}\n\n",
|
||||
serde_json::to_string(&error_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 映射停止原因
|
||||
fn map_stop_reason(finish_reason: Option<&str>) -> Option<String> {
|
||||
finish_reason.map(|r| {
|
||||
match r {
|
||||
"tool_calls" => "tool_use",
|
||||
"stop" => "end_turn",
|
||||
"length" => "max_tokens",
|
||||
_ => "end_turn",
|
||||
}
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
//! 格式转换模块
|
||||
//!
|
||||
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
|
||||
//! 参考: anthropic-proxy-rs
|
||||
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 从 Provider 配置中获取模型映射
|
||||
fn get_model_from_provider(model: &str, provider: &Provider, body: &Value) -> String {
|
||||
let env = provider.settings_config.get("env");
|
||||
let model_lower = model.to_lowercase();
|
||||
|
||||
// 检测 thinking 参数
|
||||
let has_thinking = body
|
||||
.get("thinking")
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|o| o.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
== Some("enabled");
|
||||
|
||||
if let Some(env) = env {
|
||||
// 如果启用 thinking,优先使用推理模型
|
||||
if has_thinking {
|
||||
if let Some(m) = env
|
||||
.get("ANTHROPIC_REASONING_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
log::debug!("[Transform] 使用推理模型: {m}");
|
||||
return m.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// 根据模型类型选择配置模型
|
||||
if model_lower.contains("haiku") {
|
||||
if let Some(m) = env
|
||||
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return m.to_string();
|
||||
}
|
||||
}
|
||||
if model_lower.contains("opus") {
|
||||
if let Some(m) = env
|
||||
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return m.to_string();
|
||||
}
|
||||
}
|
||||
if model_lower.contains("sonnet") {
|
||||
if let Some(m) = env
|
||||
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return m.to_string();
|
||||
}
|
||||
}
|
||||
// 默认使用 ANTHROPIC_MODEL
|
||||
if let Some(m) = env.get("ANTHROPIC_MODEL").and_then(|v| v.as_str()) {
|
||||
return m.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
model.to_string()
|
||||
}
|
||||
|
||||
/// Anthropic 请求 → OpenAI 请求
|
||||
pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
// 模型映射:使用 Provider 配置中的模型(支持 thinking 参数)
|
||||
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
|
||||
let mapped_model = get_model_from_provider(model, provider, &body);
|
||||
result["model"] = json!(mapped_model);
|
||||
}
|
||||
|
||||
let mut messages = Vec::new();
|
||||
|
||||
// 处理 system prompt
|
||||
if let Some(system) = body.get("system") {
|
||||
if let Some(text) = system.as_str() {
|
||||
// 单个字符串
|
||||
messages.push(json!({"role": "system", "content": text}));
|
||||
} else if let Some(arr) = system.as_array() {
|
||||
// 多个 system message
|
||||
for msg in arr {
|
||||
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
|
||||
messages.push(json!({"role": "system", "content": text}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 转换 messages
|
||||
if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) {
|
||||
for msg in msgs {
|
||||
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
|
||||
let content = msg.get("content");
|
||||
let converted = convert_message_to_openai(role, content)?;
|
||||
messages.extend(converted);
|
||||
}
|
||||
}
|
||||
|
||||
result["messages"] = json!(messages);
|
||||
|
||||
// 转换参数
|
||||
if let Some(v) = body.get("max_tokens") {
|
||||
result["max_tokens"] = v.clone();
|
||||
}
|
||||
if let Some(v) = body.get("temperature") {
|
||||
result["temperature"] = v.clone();
|
||||
}
|
||||
if let Some(v) = body.get("top_p") {
|
||||
result["top_p"] = v.clone();
|
||||
}
|
||||
if let Some(v) = body.get("stop_sequences") {
|
||||
result["stop"] = v.clone();
|
||||
}
|
||||
if let Some(v) = body.get("stream") {
|
||||
result["stream"] = v.clone();
|
||||
}
|
||||
|
||||
// 转换 tools (过滤 BatchTool)
|
||||
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
|
||||
let openai_tools: Vec<Value> = tools
|
||||
.iter()
|
||||
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
|
||||
.map(|t| {
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
|
||||
"description": t.get("description"),
|
||||
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !openai_tools.is_empty() {
|
||||
result["tools"] = json!(openai_tools);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(v) = body.get("tool_choice") {
|
||||
result["tool_choice"] = v.clone();
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
|
||||
fn convert_message_to_openai(
|
||||
role: &str,
|
||||
content: Option<&Value>,
|
||||
) -> Result<Vec<Value>, ProxyError> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let content = match content {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
result.push(json!({"role": role, "content": null}));
|
||||
return Ok(result);
|
||||
}
|
||||
};
|
||||
|
||||
// 字符串内容
|
||||
if let Some(text) = content.as_str() {
|
||||
result.push(json!({"role": role, "content": text}));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// 数组内容(多模态/工具调用)
|
||||
if let Some(blocks) = content.as_array() {
|
||||
let mut content_parts = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
|
||||
for block in blocks {
|
||||
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
match block_type {
|
||||
"text" => {
|
||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||
content_parts.push(json!({"type": "text", "text": text}));
|
||||
}
|
||||
}
|
||||
"image" => {
|
||||
if let Some(source) = block.get("source") {
|
||||
let media_type = source
|
||||
.get("media_type")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("image/png");
|
||||
let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
|
||||
content_parts.push(json!({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": format!("data:{};base64,{}", media_type, data)}
|
||||
}));
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
let input = block.get("input").cloned().unwrap_or(json!({}));
|
||||
tool_calls.push(json!({
|
||||
"id": id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": serde_json::to_string(&input).unwrap_or_default()
|
||||
}
|
||||
}));
|
||||
}
|
||||
"tool_result" => {
|
||||
// tool_result 变成单独的 tool role 消息
|
||||
let tool_use_id = block
|
||||
.get("tool_use_id")
|
||||
.and_then(|i| i.as_str())
|
||||
.unwrap_or("");
|
||||
let content_val = block.get("content");
|
||||
let content_str = match content_val {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(v) => serde_json::to_string(v).unwrap_or_default(),
|
||||
None => String::new(),
|
||||
};
|
||||
result.push(json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_use_id,
|
||||
"content": content_str
|
||||
}));
|
||||
}
|
||||
"thinking" => {
|
||||
// 跳过 thinking blocks
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加带内容和/或工具调用的消息
|
||||
if !content_parts.is_empty() || !tool_calls.is_empty() {
|
||||
let mut msg = json!({"role": role});
|
||||
|
||||
// 内容处理
|
||||
if content_parts.is_empty() {
|
||||
msg["content"] = Value::Null;
|
||||
} else if content_parts.len() == 1 {
|
||||
if let Some(text) = content_parts[0].get("text") {
|
||||
msg["content"] = text.clone();
|
||||
} else {
|
||||
msg["content"] = json!(content_parts);
|
||||
}
|
||||
} else {
|
||||
msg["content"] = json!(content_parts);
|
||||
}
|
||||
|
||||
// 工具调用
|
||||
if !tool_calls.is_empty() {
|
||||
msg["tool_calls"] = json!(tool_calls);
|
||||
}
|
||||
|
||||
result.push(msg);
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// 其他情况直接透传
|
||||
result.push(json!({"role": role, "content": content}));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 清理 JSON schema(移除不支持的 format)
|
||||
fn clean_schema(mut schema: Value) -> Value {
|
||||
if let Some(obj) = schema.as_object_mut() {
|
||||
// 移除 "format": "uri"
|
||||
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
|
||||
obj.remove("format");
|
||||
}
|
||||
|
||||
// 递归清理嵌套 schema
|
||||
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
|
||||
for (_, value) in properties.iter_mut() {
|
||||
*value = clean_schema(value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(items) = obj.get_mut("items") {
|
||||
*items = clean_schema(items.clone());
|
||||
}
|
||||
}
|
||||
schema
|
||||
}
|
||||
|
||||
/// OpenAI 响应 → Anthropic 响应
|
||||
pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
let choices = body
|
||||
.get("choices")
|
||||
.and_then(|c| c.as_array())
|
||||
.ok_or_else(|| ProxyError::TransformError("No choices in response".to_string()))?;
|
||||
|
||||
let choice = choices
|
||||
.first()
|
||||
.ok_or_else(|| ProxyError::TransformError("Empty choices array".to_string()))?;
|
||||
|
||||
let message = choice
|
||||
.get("message")
|
||||
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
|
||||
|
||||
let mut content = Vec::new();
|
||||
|
||||
// 文本内容
|
||||
if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
|
||||
if !text.is_empty() {
|
||||
content.push(json!({"type": "text", "text": text}));
|
||||
}
|
||||
}
|
||||
|
||||
// 工具调用
|
||||
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
|
||||
for tc in tool_calls {
|
||||
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||
let empty_obj = json!({});
|
||||
let func = tc.get("function").unwrap_or(&empty_obj);
|
||||
let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
let args_str = func
|
||||
.get("arguments")
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("{}");
|
||||
let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
|
||||
|
||||
content.push(json!({
|
||||
"type": "tool_use",
|
||||
"id": id,
|
||||
"name": name,
|
||||
"input": input
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 映射 finish_reason → stop_reason
|
||||
let stop_reason = choice
|
||||
.get("finish_reason")
|
||||
.and_then(|r| r.as_str())
|
||||
.map(|r| match r {
|
||||
"stop" => "end_turn",
|
||||
"length" => "max_tokens",
|
||||
"tool_calls" => "tool_use",
|
||||
other => other,
|
||||
});
|
||||
|
||||
// usage
|
||||
let usage = body.get("usage").cloned().unwrap_or(json!({}));
|
||||
let input_tokens = usage
|
||||
.get("prompt_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
let output_tokens = usage
|
||||
.get("completion_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
let result = json!({
|
||||
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens
|
||||
}
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_provider(env_config: Value) -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test Provider".to_string(),
|
||||
settings_config: json!({"env": env_config}),
|
||||
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_openrouter_provider() -> Provider {
|
||||
create_provider(json!({
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||||
"ANTHROPIC_MODEL": "anthropic/claude-sonnet-4.5",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4.5",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-4.5",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-4.5"
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_simple() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
// opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
assert_eq!(result["model"], "anthropic/claude-opus-4.5");
|
||||
assert_eq!(result["max_tokens"], 1024);
|
||||
assert_eq!(result["messages"][0]["role"], "user");
|
||||
assert_eq!(result["messages"][0]["content"], "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_with_system() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": "You are a helpful assistant.",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"You are a helpful assistant."
|
||||
);
|
||||
assert_eq!(result["messages"][1]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_with_tools() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "What's the weather?"}],
|
||||
"tools": [{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather info",
|
||||
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
assert_eq!(result["tools"][0]["type"], "function");
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_use() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Let me check"},
|
||||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "assistant");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_result() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "call_123", "content": "Sunny, 25°C"}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "tool");
|
||||
assert_eq!(msg["tool_call_id"], "call_123");
|
||||
assert_eq!(msg["content"], "Sunny, 25°C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_simple() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"created": 1234567890,
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Hello!"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["id"], "chatcmpl-123");
|
||||
assert_eq!(result["type"], "message");
|
||||
assert_eq!(result["content"][0]["type"], "text");
|
||||
assert_eq!(result["content"][0]["text"], "Hello!");
|
||||
assert_eq!(result["stop_reason"], "end_turn");
|
||||
assert_eq!(result["usage"]["input_tokens"], 10);
|
||||
assert_eq!(result["usage"]["output_tokens"], 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_with_tool_calls() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"created": 1234567890,
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": "{\"location\": \"Tokyo\"}"}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["content"][0]["type"], "tool_use");
|
||||
assert_eq!(result["content"][0]["id"], "call_123");
|
||||
assert_eq!(result["content"][0]["name"], "get_weather");
|
||||
assert_eq!(result["content"][0]["input"]["location"], "Tokyo");
|
||||
assert_eq!(result["stop_reason"], "tool_use");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_mapping_from_provider() {
|
||||
let provider = create_openrouter_provider();
|
||||
let body = json!({"model": "test"});
|
||||
|
||||
// sonnet 模型
|
||||
assert_eq!(
|
||||
get_model_from_provider("claude-sonnet-4-5-20250929", &provider, &body),
|
||||
"anthropic/claude-sonnet-4.5"
|
||||
);
|
||||
|
||||
// haiku 模型
|
||||
assert_eq!(
|
||||
get_model_from_provider("claude-haiku-4-5-20250929", &provider, &body),
|
||||
"anthropic/claude-haiku-4.5"
|
||||
);
|
||||
|
||||
// opus 模型
|
||||
assert_eq!(
|
||||
get_model_from_provider("claude-opus-4-5", &provider, &body),
|
||||
"anthropic/claude-opus-4.5"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_model_mapping() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-sonnet-4-5-20250929",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_parameter_detection() {
|
||||
let mut provider = create_openrouter_provider();
|
||||
// 添加推理模型配置
|
||||
if let Some(env) = provider.settings_config.get_mut("env") {
|
||||
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
|
||||
}
|
||||
|
||||
let input = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "enabled"},
|
||||
"messages": [{"role": "user", "content": "Solve this problem"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
// 应该使用推理模型
|
||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5:extended");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_parameter_disabled() {
|
||||
let mut provider = create_openrouter_provider();
|
||||
if let Some(env) = provider.settings_config.get_mut("env") {
|
||||
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
|
||||
}
|
||||
|
||||
let input = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "disabled"},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
// 应该使用普通模型
|
||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Response Handler - 统一响应处理
|
||||
//!
|
||||
//! 提供流式和非流式响应的统一处理接口
|
||||
|
||||
use super::session::ProxySession;
|
||||
use super::usage::parser::TokenUsage;
|
||||
use super::ProxyError;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// 响应类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ResponseType {
|
||||
/// 流式响应 (SSE)
|
||||
Stream,
|
||||
/// 非流式响应
|
||||
NonStream,
|
||||
}
|
||||
|
||||
impl ResponseType {
|
||||
/// 从 Content-Type 检测响应类型
|
||||
#[allow(dead_code)]
|
||||
pub fn from_content_type(content_type: &str) -> Self {
|
||||
if content_type.contains("text/event-stream") {
|
||||
ResponseType::Stream
|
||||
} else {
|
||||
ResponseType::NonStream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式响应处理器
|
||||
#[allow(dead_code)]
|
||||
pub struct StreamHandler {
|
||||
/// 空闲超时时间
|
||||
idle_timeout: Duration,
|
||||
/// 收集的事件
|
||||
events: Arc<Mutex<Vec<Value>>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl StreamHandler {
|
||||
/// 创建新的流式处理器
|
||||
pub fn new(idle_timeout_secs: u64) -> Self {
|
||||
Self {
|
||||
idle_timeout: Duration::from_secs(idle_timeout_secs),
|
||||
events: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理流式响应,返回分流后的客户端流
|
||||
///
|
||||
/// 客户端流立即返回,内部流在后台收集事件
|
||||
pub fn handle_stream<S>(
|
||||
&self,
|
||||
stream: S,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send
|
||||
where
|
||||
S: Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
|
||||
{
|
||||
let events = self.events.clone();
|
||||
let idle_timeout = self.idle_timeout;
|
||||
|
||||
async_stream::stream! {
|
||||
let mut _last_activity = Instant::now();
|
||||
let mut buffer = String::new();
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
loop {
|
||||
let chunk_result = timeout(idle_timeout, stream.next()).await;
|
||||
|
||||
match chunk_result {
|
||||
Ok(Some(Ok(bytes))) => {
|
||||
_last_activity = Instant::now();
|
||||
|
||||
// 解析 SSE 事件
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
|
||||
// 提取完整事件
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let event_text = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
for line in event_text.lines() {
|
||||
if let Some(data) = line.strip_prefix("data: ") {
|
||||
if data.trim() != "[DONE]" {
|
||||
if let Ok(json) = serde_json::from_str::<Value>(data) {
|
||||
let mut guard = events.lock().await;
|
||||
guard.push(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield Ok(bytes);
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
log::error!("流错误: {e}");
|
||||
yield Err(std::io::Error::other(e.to_string()));
|
||||
break;
|
||||
}
|
||||
Ok(None) => {
|
||||
// 流结束
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
// 空闲超时
|
||||
log::warn!("流式响应空闲超时: {idle_timeout:?} 无数据");
|
||||
yield Err(std::io::Error::other("Stream idle timeout"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取收集的事件
|
||||
pub async fn get_events(&self) -> Vec<Value> {
|
||||
let guard = self.events.lock().await;
|
||||
guard.clone()
|
||||
}
|
||||
|
||||
/// 从收集的事件中提取 Token 使用量
|
||||
pub async fn extract_usage(&self, session: &ProxySession) -> Option<TokenUsage> {
|
||||
let events = self.get_events().await;
|
||||
|
||||
match session.client_format {
|
||||
super::session::ClientFormat::Claude => TokenUsage::from_claude_stream_events(&events),
|
||||
super::session::ClientFormat::Codex => TokenUsage::from_codex_stream_events(&events),
|
||||
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
|
||||
TokenUsage::from_gemini_stream_chunks(&events)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 非流式响应处理器
|
||||
#[allow(dead_code)]
|
||||
pub struct NonStreamHandler;
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl NonStreamHandler {
|
||||
/// 处理非流式响应
|
||||
///
|
||||
/// 克隆响应体用于后台解析,原始响应立即返回
|
||||
pub async fn handle_response(
|
||||
body: &[u8],
|
||||
session: &ProxySession,
|
||||
) -> Result<Option<TokenUsage>, ProxyError> {
|
||||
let json: Value = serde_json::from_slice(body)
|
||||
.map_err(|e| ProxyError::TransformError(format!("Failed to parse response: {e}")))?;
|
||||
|
||||
let usage = match session.client_format {
|
||||
super::session::ClientFormat::Claude => TokenUsage::from_claude_response(&json),
|
||||
super::session::ClientFormat::Codex => TokenUsage::from_codex_response_adjusted(&json),
|
||||
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
|
||||
TokenUsage::from_gemini_response(&json)
|
||||
}
|
||||
super::session::ClientFormat::OpenAI => TokenUsage::from_openrouter_response(&json),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(usage)
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一响应分发器
|
||||
#[allow(dead_code)]
|
||||
pub struct ResponseDispatcher;
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ResponseDispatcher {
|
||||
/// 判断响应类型
|
||||
pub fn detect_type(content_type: &str) -> ResponseType {
|
||||
ResponseType::from_content_type(content_type)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_response_type_detection() {
|
||||
assert_eq!(
|
||||
ResponseType::from_content_type("text/event-stream"),
|
||||
ResponseType::Stream
|
||||
);
|
||||
assert_eq!(
|
||||
ResponseType::from_content_type("text/event-stream; charset=utf-8"),
|
||||
ResponseType::Stream
|
||||
);
|
||||
assert_eq!(
|
||||
ResponseType::from_content_type("application/json"),
|
||||
ResponseType::NonStream
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stream_handler_creation() {
|
||||
let handler = StreamHandler::new(30);
|
||||
assert_eq!(handler.idle_timeout, Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
//! 响应处理器模块
|
||||
//!
|
||||
//! 统一处理流式和非流式 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! HTTP代理服务器
|
||||
//!
|
||||
//! 基于Axum的HTTP服务器,处理代理请求
|
||||
|
||||
use super::{
|
||||
failover_switch::FailoverSwitchManager, handlers, provider_router::ProviderRouter, types::*,
|
||||
ProxyError,
|
||||
};
|
||||
use crate::database::Database;
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{oneshot, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
/// 代理服务器状态(共享)
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyState {
|
||||
pub db: Arc<Database>,
|
||||
pub config: Arc<RwLock<ProxyConfig>>,
|
||||
pub status: Arc<RwLock<ProxyStatus>>,
|
||||
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服务器
|
||||
pub struct ProxyServer {
|
||||
config: ProxyConfig,
|
||||
state: ProxyState,
|
||||
shutdown_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
|
||||
/// 服务器任务句柄,用于等待服务器实际关闭
|
||||
server_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
impl ProxyServer {
|
||||
pub fn new(
|
||||
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 {
|
||||
db,
|
||||
config: Arc::new(RwLock::new(config.clone())),
|
||||
status: Arc::new(RwLock::new(ProxyStatus::default())),
|
||||
start_time: Arc::new(RwLock::new(None)),
|
||||
current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
provider_router,
|
||||
app_handle,
|
||||
failover_manager,
|
||||
};
|
||||
|
||||
Self {
|
||||
config,
|
||||
state,
|
||||
shutdown_tx: Arc::new(RwLock::new(None)),
|
||||
server_handle: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> Result<ProxyServerInfo, ProxyError> {
|
||||
// 检查是否已在运行
|
||||
if self.shutdown_tx.read().await.is_some() {
|
||||
return Err(ProxyError::AlreadyRunning);
|
||||
}
|
||||
|
||||
let addr: SocketAddr =
|
||||
format!("{}:{}", self.config.listen_address, self.config.listen_port)
|
||||
.parse()
|
||||
.map_err(|e| ProxyError::BindFailed(format!("无效的地址: {e}")))?;
|
||||
|
||||
// 创建关闭通道
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
|
||||
// 构建路由
|
||||
let app = self.build_router();
|
||||
|
||||
// 绑定监听器
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.map_err(|e| ProxyError::BindFailed(e.to_string()))?;
|
||||
|
||||
log::info!("代理服务器启动于 {addr}");
|
||||
|
||||
// 保存关闭句柄
|
||||
*self.shutdown_tx.write().await = Some(shutdown_tx);
|
||||
|
||||
// 更新状态
|
||||
let mut status = self.state.status.write().await;
|
||||
status.running = true;
|
||||
status.address = self.config.listen_address.clone();
|
||||
status.port = self.config.listen_port;
|
||||
drop(status);
|
||||
|
||||
// 记录启动时间
|
||||
*self.state.start_time.write().await = Some(std::time::Instant::now());
|
||||
|
||||
// 启动服务器
|
||||
let state = self.state.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
shutdown_rx.await.ok();
|
||||
})
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// 服务器停止后更新状态
|
||||
state.status.write().await.running = false;
|
||||
*state.start_time.write().await = None;
|
||||
});
|
||||
|
||||
// 保存服务器任务句柄
|
||||
*self.server_handle.write().await = Some(handle);
|
||||
|
||||
Ok(ProxyServerInfo {
|
||||
address: self.config.listen_address.clone(),
|
||||
port: self.config.listen_port,
|
||||
started_at: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop(&self) -> Result<(), ProxyError> {
|
||||
// 1. 发送关闭信号
|
||||
if let Some(tx) = self.shutdown_tx.write().await.take() {
|
||||
let _ = tx.send(());
|
||||
} else {
|
||||
return 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 {
|
||||
let mut status = self.state.status.read().await.clone();
|
||||
|
||||
// 计算运行时间
|
||||
if let Some(start) = *self.state.start_time.read().await {
|
||||
status.uptime_seconds = start.elapsed().as_secs();
|
||||
}
|
||||
|
||||
// 从 current_providers HashMap 获取每个应用类型当前正在使用的 provider
|
||||
let current_providers = self.state.current_providers.read().await;
|
||||
status.active_targets = current_providers
|
||||
.iter()
|
||||
.map(|(app_type, (provider_id, provider_name))| ActiveTarget {
|
||||
app_type: app_type.clone(),
|
||||
provider_id: provider_id.clone(),
|
||||
provider_name: provider_name.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
status
|
||||
}
|
||||
|
||||
fn build_router(&self) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
Router::new()
|
||||
// 健康检查
|
||||
.route("/health", get(handlers::health_check))
|
||||
.route("/status", get(handlers::get_status))
|
||||
// Claude API (支持带前缀和不带前缀两种格式)
|
||||
.route("/v1/messages", post(handlers::handle_messages))
|
||||
.route("/claude/v1/messages", post(handlers::handle_messages))
|
||||
// OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀)
|
||||
.route("/chat/completions", post(handlers::handle_chat_completions))
|
||||
.route(
|
||||
"/v1/chat/completions",
|
||||
post(handlers::handle_chat_completions),
|
||||
)
|
||||
.route(
|
||||
"/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/v1/responses", post(handlers::handle_responses))
|
||||
.route("/codex/v1/responses", post(handlers::handle_responses))
|
||||
// Gemini API (支持带前缀和不带前缀)
|
||||
.route("/v1beta/*path", post(handlers::handle_gemini))
|
||||
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
|
||||
.layer(cors)
|
||||
.with_state(self.state.clone())
|
||||
}
|
||||
|
||||
/// 在不重启服务的情况下更新运行时配置
|
||||
pub async fn apply_runtime_config(&self, config: &ProxyConfig) {
|
||||
*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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
//! 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 uuid::Uuid;
|
||||
|
||||
/// 客户端请求格式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ClientFormat {
|
||||
/// Claude Messages API (/v1/messages)
|
||||
Claude,
|
||||
/// Codex Response API (/v1/responses)
|
||||
Codex,
|
||||
/// OpenAI Chat Completions API (/v1/chat/completions)
|
||||
OpenAI,
|
||||
/// Gemini API (/v1beta/models/*/generateContent)
|
||||
Gemini,
|
||||
/// Gemini CLI API (/v1internal/models/*/generateContent)
|
||||
GeminiCli,
|
||||
/// 未知格式
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ClientFormat {
|
||||
/// 从请求路径检测格式
|
||||
pub fn from_path(path: &str) -> Self {
|
||||
if path.contains("/v1/messages") {
|
||||
ClientFormat::Claude
|
||||
} else if path.contains("/v1/responses") {
|
||||
ClientFormat::Codex
|
||||
} else if path.contains("/v1/chat/completions") {
|
||||
ClientFormat::OpenAI
|
||||
} else if path.contains("/v1internal/") && path.contains("generateContent") {
|
||||
// Gemini CLI 使用 /v1internal/ 路径
|
||||
ClientFormat::GeminiCli
|
||||
} else if (path.contains("/v1beta/") || path.contains("/v1/"))
|
||||
&& path.contains("generateContent")
|
||||
{
|
||||
// Gemini API 使用 /v1beta/ 或 /v1/ 路径
|
||||
ClientFormat::Gemini
|
||||
} else if path.contains("generateContent") {
|
||||
// 通用 Gemini 端点
|
||||
ClientFormat::Gemini
|
||||
} else {
|
||||
ClientFormat::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求体内容检测格式(回退方案)
|
||||
pub fn from_body(body: &serde_json::Value) -> Self {
|
||||
// Claude 格式特征: messages 数组 + model 字段 + 无 response_format
|
||||
if body.get("messages").is_some()
|
||||
&& body.get("model").is_some()
|
||||
&& body.get("response_format").is_none()
|
||||
&& body.get("contents").is_none()
|
||||
{
|
||||
// 区分 Claude 和 OpenAI
|
||||
if body.get("max_tokens").is_some() {
|
||||
return ClientFormat::Claude;
|
||||
}
|
||||
return ClientFormat::OpenAI;
|
||||
}
|
||||
|
||||
// Codex 格式特征: input 字段
|
||||
if body.get("input").is_some() {
|
||||
return ClientFormat::Codex;
|
||||
}
|
||||
|
||||
// Gemini 格式特征: contents 数组
|
||||
if body.get("contents").is_some() {
|
||||
return ClientFormat::Gemini;
|
||||
}
|
||||
|
||||
ClientFormat::Unknown
|
||||
}
|
||||
|
||||
/// 转换为字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ClientFormat::Claude => "claude",
|
||||
ClientFormat::Codex => "codex",
|
||||
ClientFormat::OpenAI => "openai",
|
||||
ClientFormat::Gemini => "gemini",
|
||||
ClientFormat::GeminiCli => "gemini_cli",
|
||||
ClientFormat::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ClientFormat {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// 代理会话
|
||||
///
|
||||
/// 包含请求全生命周期的上下文数据
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ProxySession {
|
||||
/// 唯一会话 ID
|
||||
pub session_id: String,
|
||||
/// 请求开始时间
|
||||
pub start_time: Instant,
|
||||
/// HTTP 方法
|
||||
pub method: String,
|
||||
/// 请求 URL
|
||||
pub request_url: String,
|
||||
/// User-Agent
|
||||
pub user_agent: Option<String>,
|
||||
/// 客户端请求格式
|
||||
pub client_format: ClientFormat,
|
||||
/// 选定的供应商 ID
|
||||
pub provider_id: Option<String>,
|
||||
/// 模型名称
|
||||
pub model: Option<String>,
|
||||
/// 是否为流式请求
|
||||
pub is_streaming: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ProxySession {
|
||||
/// 从请求创建会话
|
||||
pub fn from_request(
|
||||
method: &str,
|
||||
request_url: &str,
|
||||
user_agent: Option<&str>,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> Self {
|
||||
// 检测客户端格式
|
||||
let mut client_format = ClientFormat::from_path(request_url);
|
||||
if client_format == ClientFormat::Unknown {
|
||||
if let Some(body) = body {
|
||||
client_format = ClientFormat::from_body(body);
|
||||
}
|
||||
}
|
||||
|
||||
// 检测是否为流式请求
|
||||
let is_streaming = body
|
||||
.and_then(|b| b.get("stream"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// 提取模型名称
|
||||
let model = body
|
||||
.and_then(|b| b.get("model"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Self {
|
||||
session_id: Uuid::new_v4().to_string(),
|
||||
start_time: Instant::now(),
|
||||
method: method.to_string(),
|
||||
request_url: request_url.to_string(),
|
||||
user_agent: user_agent.map(|s| s.to_string()),
|
||||
client_format,
|
||||
provider_id: None,
|
||||
model,
|
||||
is_streaming,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置供应商 ID
|
||||
pub fn with_provider(mut self, provider_id: &str) -> Self {
|
||||
self.provider_id = Some(provider_id.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// 获取请求延迟(毫秒)
|
||||
pub fn latency_ms(&self) -> u64 {
|
||||
self.start_time.elapsed().as_millis() as u64
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_path_claude() {
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/v1/messages"),
|
||||
ClientFormat::Claude
|
||||
);
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/api/v1/messages"),
|
||||
ClientFormat::Claude
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_path_codex() {
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/v1/responses"),
|
||||
ClientFormat::Codex
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_path_openai() {
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/v1/chat/completions"),
|
||||
ClientFormat::OpenAI
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_path_gemini() {
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/v1beta/models/gemini-pro:generateContent"),
|
||||
ClientFormat::Gemini
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_path_gemini_cli() {
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/v1internal/models/gemini-pro:generateContent"),
|
||||
ClientFormat::GeminiCli
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_body_claude() {
|
||||
let body = json!({
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"max_tokens": 1024
|
||||
});
|
||||
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Claude);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_body_codex() {
|
||||
let body = json!({
|
||||
"input": "Write a function"
|
||||
});
|
||||
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Codex);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_body_gemini() {
|
||||
let body = json!({
|
||||
"contents": [{"parts": [{"text": "Hello"}]}]
|
||||
});
|
||||
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Gemini);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_id_uniqueness() {
|
||||
let session1 = ProxySession::from_request("POST", "/v1/messages", None, None);
|
||||
let session2 = ProxySession::from_request("POST", "/v1/messages", None, None);
|
||||
assert_ne!(session1.session_id, session2.session_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_from_request() {
|
||||
let body = json!({
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"max_tokens": 1024,
|
||||
"stream": true
|
||||
});
|
||||
|
||||
let session =
|
||||
ProxySession::from_request("POST", "/v1/messages", Some("Mozilla/5.0"), Some(&body));
|
||||
|
||||
assert_eq!(session.method, "POST");
|
||||
assert_eq!(session.request_url, "/v1/messages");
|
||||
assert_eq!(session.user_agent, Some("Mozilla/5.0".to_string()));
|
||||
assert_eq!(session.client_format, ClientFormat::Claude);
|
||||
assert_eq!(session.model, Some("claude-3-5-sonnet".to_string()));
|
||||
assert!(session.is_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_with_provider() {
|
||||
let session = ProxySession::from_request("POST", "/v1/messages", None, None)
|
||||
.with_provider("provider-123");
|
||||
|
||||
assert_eq!(session.provider_id, Some("provider-123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_as_str() {
|
||||
assert_eq!(ClientFormat::Claude.as_str(), "claude");
|
||||
assert_eq!(ClientFormat::Codex.as_str(), "codex");
|
||||
assert_eq!(ClientFormat::OpenAI.as_str(), "openai");
|
||||
assert_eq!(ClientFormat::Gemini.as_str(), "gemini");
|
||||
assert_eq!(ClientFormat::GeminiCli.as_str(), "gemini_cli");
|
||||
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);
|
||||
}
|
||||
}
|
||||