mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c847fff768 | |||
| 1eb0a0d7ac | |||
| 3bd3845ec0 | |||
| b0d0a2c466 | |||
| 73013c10af | |||
| 1a0872c153 | |||
| 255a7f570a | |||
| fb44fb136f | |||
| 58d3bb89d2 | |||
| 403227c901 | |||
| 5bcf5bf382 | |||
| ad6f5b388b | |||
| 2844f7c557 | |||
| 882c73234f | |||
| b70de25de4 | |||
| 88dbeb5335 | |||
| 966d7b5782 | |||
| 42a92c712a | |||
| 2f0998c6c8 | |||
| 938e2eb563 | |||
| 2cc36b3950 | |||
| e06c6176d9 | |||
| 9b4485e111 | |||
| f349d85e85 | |||
| 58a13cc69a | |||
| d765364a18 | |||
| 5c6956b6e2 | |||
| cb1b45ae4e | |||
| e4df1a32a5 | |||
| 2494eaaa32 | |||
| 45b9cf1df0 | |||
| de3a22535d | |||
| 36d6d48002 | |||
| 864884926a | |||
| 093ff0ba29 | |||
| 21754a7349 | |||
| 7997b2c7b3 | |||
| b8538b211d | |||
| 7ea2c3452b | |||
| a30d72bb68 | |||
| 58ecc44ee6 | |||
| 5658d93924 | |||
| e4d24f2df9 | |||
| 07d022ba9f | |||
| f3343992f2 | |||
| 53f40b2d7a | |||
| 1393f89797 | |||
| f9d80b8dc3 | |||
| 8b92982112 | |||
| 74b4d4ecbb | |||
| 99c910e58e | |||
| 8f7423f011 | |||
| c56523c9c0 | |||
| 6aef472fd2 | |||
| 4a8883ecc3 | |||
| 6dd809701b | |||
| 76fa830688 | |||
| c9a4938866 | |||
| 95ed6d6903 | |||
| 83db457b10 | |||
| 392756e373 | |||
| 6021274b82 | |||
| b7fd70075c | |||
| eeb6afef01 | |||
| d3074eadb5 | |||
| 31d34a4512 | |||
| 3ef86bdb99 | |||
| df3f8a05c4 | |||
| 8e6fad7af2 | |||
| f22000a4df | |||
| 412906fb09 | |||
| a268127f1f | |||
| 2923627b76 | |||
| 48db113b37 | |||
| e9fc56525d | |||
| 75d512b36c | |||
| 157ebaaad2 | |||
| 42d9afa3e2 | |||
| a0b5e3b808 | |||
| effb931a1b | |||
| 0456255625 | |||
| 847f1c5377 | |||
| 24a36df140 | |||
| 2c4c2ce83d | |||
| 0020889a8f | |||
| 671cda60d9 | |||
| efa653809b | |||
| 5aa35906d8 | |||
| 4777c99b38 | |||
| 8912216cb2 | |||
| 32149b1eeb | |||
| 8979d964d6 | |||
| 37396b9c70 | |||
| 2c35372ca0 | |||
| 63bb673bf2 | |||
| 058f86aff3 | |||
| 188c94f2e3 | |||
| c049c5f2bb | |||
| c71b030662 | |||
| f363bb1dd0 | |||
| 2105f2d05b | |||
| 0a9de282a3 | |||
| 0e085aa01a | |||
| 2c95f697cd | |||
| ba97a5f373 | |||
| 86e802bd4b | |||
| 6460c1d5dd | |||
| 6b73e55bfe | |||
| 47aa4c6bee | |||
| 4dc59dff21 | |||
| c8750f5550 | |||
| 22460de976 | |||
| e69c1bd8aa | |||
| a17fa8098b | |||
| ff03ca1e63 | |||
| cce6ae86a5 | |||
| 5376ea042b | |||
| a2becf0917 | |||
| d0431b66ae | |||
| eaddcbedd7 | |||
| 83a5597756 | |||
| debe4232bc | |||
| bcfc22514c | |||
| 443e23c77e | |||
| f26a01137d | |||
| 1be9c56ec5 | |||
| 2651b65b10 | |||
| 49a2e52b20 | |||
| ed9d9b5436 | |||
| 390839a8d5 | |||
| 3dcbe313be |
@@ -53,7 +53,11 @@ jobs:
|
||||
wget \
|
||||
file \
|
||||
patchelf \
|
||||
libssl-dev
|
||||
libssl-dev \
|
||||
rpm \
|
||||
flatpak \
|
||||
flatpak-builder \
|
||||
elfutils
|
||||
# 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 +157,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 +275,15 @@ 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
|
||||
|
||||
- name: List prepared assets
|
||||
shell: bash
|
||||
@@ -299,7 +312,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)
|
||||
|
||||
---
|
||||
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
||||
|
||||
@@ -18,3 +18,8 @@ GEMINI.md
|
||||
/.vscode
|
||||
vitest-report.json
|
||||
nul
|
||||
|
||||
# Flatpak build artifacts
|
||||
flatpak/cc-switch.deb
|
||||
flatpak-build/
|
||||
flatpak-repo/
|
||||
|
||||
+173
-39
@@ -5,6 +5,177 @@ 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).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [3.9.1] - 2026-01-09
|
||||
|
||||
### Bug Fix Release
|
||||
|
||||
This release focuses on stability improvements and crash prevention.
|
||||
|
||||
### Added
|
||||
|
||||
- **Crash Logging** - Panic hook captures crash info to `~/.cc-switch/crash.log` with full stack traces (#562)
|
||||
- **Release Logging** - Enable logging for release builds with automatic rotation (keeps 2 most recent files)
|
||||
- **AIGoCode Icon** - Added colored icon for AIGoCode provider preset
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Proxy Panic Prevention** - Graceful degradation when HTTP client initialization fails due to invalid proxy settings; falls back to no_proxy mode (#560)
|
||||
- **UTF-8 Safety** - Fix potential panic when masking API keys or truncating logs containing multi-byte characters (Chinese, emoji, etc.) (#560)
|
||||
- **Default Proxy Port** - Change default port from 5000 to 15721 to avoid conflict with macOS AirPlay Receiver (#560)
|
||||
- **Windows Title** - Display "CC Switch" instead of default "Tauri app" in window title
|
||||
- **Windows/Linux Spacing** - Remove extra 28px blank space below native titlebar introduced in v3.9.0
|
||||
- **Flatpak Tray Icon** - Bundle libayatana-appindicator for tray icon support on Flatpak (#556)
|
||||
- **Provider Preset** - Correct casing from "AiGoCode" to "AIGoCode" to match official branding
|
||||
|
||||
---
|
||||
|
||||
## [3.9.0] - 2026-01-07
|
||||
|
||||
### Stable Release
|
||||
|
||||
This stable release includes all changes from `3.9.0-1`, `3.9.0-2`, and `3.9.0-3`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Local API Proxy** - High-performance local HTTP proxy for Claude Code, Codex, and Gemini CLI (Axum-based)
|
||||
- **Per-App Takeover** - Independently route each app through the proxy with automatic live-config backup/redirect
|
||||
- **Auto Failover** - Circuit breaker + smart failover with independent queues and health tracking per app
|
||||
- **Universal Provider** - Shared provider configurations that can sync to Claude/Codex/Gemini (ideal for API gateways like NewAPI)
|
||||
- **Provider Search Filter** - Quick filter to find providers by name (#435)
|
||||
- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436)
|
||||
- **Deeplink Usage Config** - Import usage query config via deeplink (#400)
|
||||
- **Provider Icon Colors** - Customize provider icon colors (#385)
|
||||
- **Skills Multi-App Support** - Skills now support both Claude Code and Codex (#365)
|
||||
- **Closable Toasts** - Close button for switch toast and all success toasts (#350)
|
||||
- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog
|
||||
- **MCP Import** - Import MCP servers from installed apps
|
||||
- **Common Config Snippet Extraction** - Extract reusable common config snippets from the current provider or editor content (Claude/Codex/Gemini)
|
||||
- **Usage Enhancements** - Model extraction, request logging improvements, cache hit/creation metrics, and auto-refresh (#455, #508)
|
||||
- **Error Request Logging** - Detailed logging for proxy requests (#401)
|
||||
- **Linux Packaging** - Added RPM and Flatpak packaging targets
|
||||
- **Provider Presets & Icons** - Added/updated partner presets and icons (e.g., MiMo, DMXAPI, Cubence)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Usage Terminology** - Rename "Cache Read/Write" to "Cache Hit/Creation" across all languages (#508)
|
||||
- **Model Pricing Data** - Refresh built-in model pricing table (Claude full version IDs, GPT-5 series, Gemini ID formats, and Chinese models) (#508)
|
||||
- **Proxy Header Forwarding** - Switch to a blacklist approach and improve header passthrough compatibility (#508)
|
||||
- **Failover Behavior** - Bypass timeout/retry configs when failover is disabled; update default failover timeout and circuit breaker values (#508, #521)
|
||||
- **Provider Presets** - Update default model versions and change the default Qwen base URL (#517)
|
||||
- **Skills Management** - Unify Skills management architecture with SSOT + React Query; improve caching for discoverable skills
|
||||
- **Settings UX** - Reorder items in the Advanced tab for better discoverability
|
||||
- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Security** - Security fixes for JavaScript executor and usage script (#151)
|
||||
- **Usage Timezone & Parsing** - Fix datetime picker timezone handling; improve token parsing/billing for Gemini and Codex formats (#508)
|
||||
- **Windows Compatibility** - Improve MCP export and version check behavior to avoid terminal popups
|
||||
- **Windows Startup** - Use system titlebar to prevent black screen on startup
|
||||
- **WebView Compatibility** - Add fallback for crypto.randomUUID() on older WebViews
|
||||
- **macOS Autostart** - Use `.app` bundle path to prevent terminal window popups
|
||||
- **Database** - Add missing schema migrations; show an error dialog on initialization failure with a retry option
|
||||
- **Import/Export** - Restrict SQL import to CC Switch exported backups only; refresh providers immediately after import
|
||||
- **Prompts** - Allow saving prompts with empty content
|
||||
- **MCP Sync** - Skip sync when the target CLI app is not installed
|
||||
- **Common Config (Codex)** - Preserve MCP server `base_url` during extraction and remove provider-specific `model_providers` blocks
|
||||
- **Proxy** - Improve takeover detection and stability; clean up model override env vars when switching providers in takeover mode (#508)
|
||||
- **Skills** - Skip hidden directories during discovery; fix wrong skill repo branch
|
||||
- **Settings Navigation** - Navigate to About tab when clicking update badge
|
||||
- **UI** - Fix dialogs not opening on first click and improve window dragging area in `FullScreenPanel`
|
||||
|
||||
---
|
||||
|
||||
## [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-2] - 2025-12-20
|
||||
|
||||
### Beta Release
|
||||
|
||||
Second beta release focusing on proxy stability, import safety, and provider preset polish.
|
||||
|
||||
### Added
|
||||
|
||||
- **DMXAPI Partner** - Added DMXAPI as an official partner provider preset
|
||||
- **Provider Icons** - Added provider icons for OpenRouter, LongCat, ModelScope, and AiHubMix
|
||||
|
||||
### Changed
|
||||
|
||||
- **Proxy (OpenRouter)** - Switched OpenRouter to passthrough mode for native Claude API
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Import/Export** - Restrict SQL import to CC Switch exported backups only; refresh providers immediately after import
|
||||
- **Proxy** - Respect existing Claude token when syncing; add fallback recovery for orphaned takeover state; remove global auto-start flag
|
||||
- **Windows** - Add minimum window size to Windows platform config
|
||||
- **UI** - Improve About section UI (#419) and unify header toolbar styling
|
||||
|
||||
### Stats
|
||||
|
||||
- 13 commits since v3.9.0-1
|
||||
|
||||
---
|
||||
|
||||
## [3.9.0-1] - 2025-12-18
|
||||
|
||||
### Beta Release
|
||||
@@ -497,8 +668,8 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
|
||||
|
||||
### ⚠ Breaking Changes
|
||||
|
||||
- Tauri 命令仅接受参数 `app`(取值:`claude`/`codex`);移除对 `app_type`/`appType` 的兼容。
|
||||
- 前端类型命名统一为 `AppId`(移除 `AppType` 导出),变量命名统一为 `appId`。
|
||||
- Tauri commands only accept the `app` parameter (`claude`/`codex`); removed `app_type`/`appType` compatibility.
|
||||
- Frontend types are standardized to `AppId` (removed `AppType` export); variable naming is standardized to `appId`.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
@@ -741,40 +912,3 @@ For users upgrading from v2.x (Electron version):
|
||||
- Basic provider management
|
||||
- Claude Code integration
|
||||
- Configuration file handling
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### ⚠️ Breaking Changes
|
||||
|
||||
- **Runtime auto-migration from v1 to v2 config format has been removed**
|
||||
- `MultiAppConfig::load()` no longer automatically migrates v1 configs
|
||||
- When a v1 config is detected, the app now returns a clear error with migration instructions
|
||||
- **Migration path**: Install v3.2.x to perform one-time auto-migration, OR manually edit `~/.cc-switch/config.json` to v2 format
|
||||
- **Rationale**: Separates concerns (load() should be read-only), fail-fast principle, simplifies maintenance
|
||||
- Related: `app_config.rs` (v1 detection improved with structural analysis), `app_config_load.rs` (comprehensive test coverage added)
|
||||
|
||||
- **Legacy v1 copy file migration logic has been removed**
|
||||
- Removed entire `migration.rs` module (435 lines) that handled one-time migration from v3.1.0 to v3.2.0
|
||||
- No longer scans/merges legacy copy files (`settings-*.json`, `auth-*.json`, `config-*.toml`)
|
||||
- No longer archives copy files or performs automatic deduplication
|
||||
- **Migration path**: Users upgrading from v3.1.0 must first upgrade to v3.2.x to automatically migrate their configurations
|
||||
- **Benefits**: Improved startup performance (no file scanning), reduced code complexity, cleaner codebase
|
||||
|
||||
- **Tauri commands now only accept `app` parameter**
|
||||
- Removed legacy `app_type`/`appType` compatibility paths
|
||||
- Explicit error with available values when unknown `app` is provided
|
||||
|
||||
### 🔧 Improvements
|
||||
|
||||
- Unified `AppType` parsing: centralized to `FromStr` implementation, command layer no longer implements separate `parse_app()`, reducing code duplication and drift
|
||||
- Localized and user-friendly error messages: returns bilingual (Chinese/English) hints for unsupported `app` values with a list of available options
|
||||
- Simplified startup logic: Only ensures config structure exists, no migration overhead
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- Added unit tests covering `AppType::from_str`: case sensitivity, whitespace trimming, unknown value error messages
|
||||
- Added comprehensive config loading tests:
|
||||
- `load_v1_config_returns_error_and_does_not_write`
|
||||
- `load_v1_with_extra_version_still_treated_as_v1`
|
||||
- `load_invalid_json_returns_parse_error_and_does_not_write`
|
||||
- `load_valid_v2_config_succeeds`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](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)
|
||||
@@ -37,6 +37,11 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
||||
<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href="https://www.dmxapi.cn/register?aff=bUHu">Register here</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more with flexible billing options including pay-as-you-go and monthly plans. Cubence provides special discounts for CC Switch users: register using <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">this link</a> and enter the "CCSWITCH" promo code during recharge to get 10% off every top-up!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## Screenshots
|
||||
@@ -47,7 +52,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
||||
|
||||
## Features
|
||||
|
||||
### Current Version: v3.8.3 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
|
||||
### Current Version: v3.9.1 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
|
||||
|
||||
**v3.8.0 Major Update (2025-11-28)**
|
||||
|
||||
@@ -186,7 +191,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
|
||||
|
||||
|
||||
+21
-4
@@ -2,14 +2,14 @@
|
||||
|
||||
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](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)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.8.0 リリースノート](docs/release-note-v3.8.0-en.md)
|
||||
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.9.0 リリースノート](docs/release-note-v3.9.0-ja.md)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:<a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">このリンク</a>で登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## スクリーンショット
|
||||
@@ -47,7 +52,7 @@
|
||||
|
||||
## 特長
|
||||
|
||||
### 現在のバージョン:v3.8.3 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
|
||||
### 現在のバージョン:v3.9.1 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
|
||||
|
||||
**v3.8.0 メジャーアップデート (2025-11-28)**
|
||||
|
||||
@@ -186,7 +191,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
|
||||
```
|
||||
|
||||
## クイックスタート
|
||||
|
||||
|
||||
+23
-6
@@ -2,14 +2,14 @@
|
||||
|
||||
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](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)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md)
|
||||
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.9.0 发布说明](docs/release-note-v3.9.0-zh.md)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠。</td>
|
||||
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -35,8 +35,13 @@
|
||||
<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>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中继服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">此链接</a> 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 界面预览
|
||||
@@ -47,7 +52,7 @@
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 当前版本:v3.8.3 | [完整更新日志](CHANGELOG.md)
|
||||
### 当前版本:v3.9.1 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
|
||||
|
||||
**v3.8.0 重大更新(2025-11-28)**
|
||||
|
||||
@@ -186,7 +191,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
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
+239
-776
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,485 @@
|
||||
# OpenCode 第四应用支持实现计划
|
||||
|
||||
> **范围说明**:本计划暂不包含统一供应商(UniversalProvider)对 OpenCode 的支持,以降低初期实现复杂度。
|
||||
|
||||
## 概述
|
||||
|
||||
为 CC Switch 添加 OpenCode 支持,这是第四个受管理的 CLI 应用。OpenCode 的核心差异在于采用**累加式**供应商管理(多供应商共存,应用内热切换),而非现有三应用的**替换式**管理。
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
| 特性 | Claude/Codex/Gemini | OpenCode |
|
||||
|------|---------------------|----------|
|
||||
| 供应商模式 | 替换式(单一活跃) | 累加式(多供应商共存) |
|
||||
| UI 按钮 | 启用/切换 | 添加/删除 |
|
||||
| is_current | 需要 | 不需要 |
|
||||
| 代理/故障转移 | 支持 | 不支持 |
|
||||
| API 格式字段 | 无 | 需要(npm 包名) |
|
||||
| 配置文件 | 各自独立 | `~/.config/opencode/opencode.json` |
|
||||
|
||||
## 配置文件格式
|
||||
|
||||
### 供应商配置
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"provider-id": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Provider Name",
|
||||
"options": {
|
||||
"baseURL": "https://api.example.com/v1",
|
||||
"apiKey": "{env:API_KEY}"
|
||||
},
|
||||
"models": {
|
||||
"model-id": { "name": "Model Name" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MCP 配置
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"remote-server": {
|
||||
"type": "remote",
|
||||
"url": "https://example.com/mcp",
|
||||
"enabled": true
|
||||
},
|
||||
"local-server": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "my-mcp-command"],
|
||||
"enabled": true,
|
||||
"environment": { "KEY": "value" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### Phase 1: 后端数据结构扩展
|
||||
|
||||
#### 1.1 AppType 枚举扩展
|
||||
**文件**: `src-tauri/src/app_config.rs`
|
||||
|
||||
```rust
|
||||
pub enum AppType {
|
||||
Claude,
|
||||
Codex,
|
||||
Gemini,
|
||||
OpenCode, // 新增
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 McpApps / SkillApps 扩展
|
||||
**文件**: `src-tauri/src/app_config.rs`
|
||||
|
||||
```rust
|
||||
pub struct McpApps {
|
||||
pub claude: bool,
|
||||
pub codex: bool,
|
||||
pub gemini: bool,
|
||||
pub opencode: bool, // 新增
|
||||
}
|
||||
|
||||
pub struct SkillApps {
|
||||
pub claude: bool,
|
||||
pub codex: bool,
|
||||
pub gemini: bool,
|
||||
pub opencode: bool, // 新增
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 数据库 Schema 迁移
|
||||
**文件**: `src-tauri/src/database/schema.rs`
|
||||
|
||||
- `SCHEMA_VERSION` 递增
|
||||
- 添加迁移:
|
||||
```sql
|
||||
ALTER TABLE mcp_servers ADD COLUMN enabled_opencode BOOLEAN NOT NULL DEFAULT 0;
|
||||
ALTER TABLE skills ADD COLUMN enabled_opencode BOOLEAN NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
### Phase 2: OpenCode 供应商数据结构
|
||||
|
||||
#### 2.1 OpenCode 专属配置结构
|
||||
**文件**: `src-tauri/src/provider.rs`(或新建 `opencode_provider.rs`)
|
||||
|
||||
```rust
|
||||
/// OpenCode 供应商的 settings_config 结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeProviderConfig {
|
||||
/// AI SDK 包名,如 "@ai-sdk/openai-compatible"
|
||||
pub npm: String,
|
||||
/// 供应商选项
|
||||
pub options: OpenCodeProviderOptions,
|
||||
/// 模型定义
|
||||
pub models: HashMap<String, OpenCodeModel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeProviderOptions {
|
||||
#[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
#[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeModel {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<OpenCodeModelLimit>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeModelLimit {
|
||||
pub context: Option<u64>,
|
||||
pub output: Option<u64>,
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: OpenCode Live 配置读写
|
||||
|
||||
#### 3.1 新建 OpenCode 配置模块
|
||||
**文件**: `src-tauri/src/opencode_config.rs`
|
||||
|
||||
核心功能:
|
||||
- `get_opencode_config_path()` → `~/.config/opencode/opencode.json`
|
||||
- `read_opencode_config()` → 读取整个配置文件
|
||||
- `write_opencode_config()` → 原子写入配置文件
|
||||
- `get_providers()` → 获取 `provider` 对象
|
||||
- `set_provider(id, config)` → 添加/更新供应商
|
||||
- `remove_provider(id)` → 删除供应商
|
||||
- `get_mcp_servers()` → 获取 `mcp` 对象
|
||||
- `set_mcp_server(id, config)` → 添加/更新 MCP 服务器
|
||||
- `remove_mcp_server(id)` → 删除 MCP 服务器
|
||||
|
||||
### Phase 4: MCP 同步模块
|
||||
|
||||
#### 4.1 新建 OpenCode MCP 同步
|
||||
**文件**: `src-tauri/src/mcp/opencode.rs`
|
||||
|
||||
```rust
|
||||
/// 同步所有 enabled_opencode=true 的服务器到 OpenCode 配置
|
||||
pub fn sync_enabled_to_opencode(config: &MultiAppConfig) -> Result<(), AppError>
|
||||
|
||||
/// 同步单个服务器
|
||||
pub fn sync_single_server_to_opencode(
|
||||
config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value
|
||||
) -> Result<(), AppError>
|
||||
|
||||
/// 从 OpenCode 配置移除服务器
|
||||
pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError>
|
||||
|
||||
/// 从 OpenCode 配置导入服务器
|
||||
pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||
```
|
||||
|
||||
**格式转换**:
|
||||
| CC Switch 统一格式 | OpenCode 格式 |
|
||||
|-------------------|---------------|
|
||||
| `type: "stdio"` | `type: "local"` |
|
||||
| `command` + `args` | `command: [cmd, ...args]` |
|
||||
| `env` | `environment` |
|
||||
| `type: "sse"/"http"` | `type: "remote"` |
|
||||
| `url` | `url` |
|
||||
|
||||
### Phase 5: 供应商服务层
|
||||
|
||||
#### 5.1 OpenCode 供应商服务
|
||||
**文件**: `src-tauri/src/services/provider/opencode.rs`
|
||||
|
||||
核心方法:
|
||||
```rust
|
||||
/// 获取所有 OpenCode 供应商
|
||||
pub fn list(state: &AppState) -> Result<IndexMap<String, Provider>, AppError>
|
||||
|
||||
/// 添加供应商(同时写入 live 配置)
|
||||
pub fn add(state: &AppState, provider: Provider) -> Result<bool, AppError>
|
||||
|
||||
/// 更新供应商
|
||||
pub fn update(state: &AppState, provider: Provider) -> Result<bool, AppError>
|
||||
|
||||
/// 删除供应商(同时从 live 配置移除)
|
||||
pub fn delete(state: &AppState, id: &str) -> Result<(), AppError>
|
||||
|
||||
/// 从 live 配置导入供应商到数据库
|
||||
pub fn import_from_live(state: &AppState) -> Result<usize, AppError>
|
||||
```
|
||||
|
||||
**关键差异**:
|
||||
- 不需要 `switch()` 方法
|
||||
- 不需要 `is_current` 管理
|
||||
- `add()` 自动写入 live
|
||||
- `delete()` 自动从 live 移除
|
||||
|
||||
### Phase 6: Tauri 命令扩展
|
||||
|
||||
#### 6.1 更新现有命令
|
||||
**文件**: `src-tauri/src/commands/providers.rs`
|
||||
|
||||
- 所有命令支持 `app_type = "opencode"`
|
||||
- OpenCode 特定逻辑分支
|
||||
|
||||
#### 6.2 新增 OpenCode 专属命令(如需要)
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn opencode_sync_all_providers(state: State<'_, AppState>) -> Result<(), AppError>
|
||||
```
|
||||
|
||||
### Phase 7: 前端类型定义
|
||||
|
||||
#### 7.1 TypeScript 类型扩展
|
||||
**文件**: `src/types.ts`
|
||||
|
||||
```typescript
|
||||
// AppId 扩展
|
||||
type AppId = "claude" | "codex" | "gemini" | "opencode";
|
||||
|
||||
// OpenCode 专属配置
|
||||
interface OpenCodeProviderConfig {
|
||||
npm: string; // AI SDK 包名
|
||||
options: {
|
||||
baseURL?: string;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
models: Record<string, OpenCodeModel>;
|
||||
}
|
||||
|
||||
interface OpenCodeModel {
|
||||
name: string;
|
||||
limit?: {
|
||||
context?: number;
|
||||
output?: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 7.2 MCP 应用状态扩展
|
||||
**文件**: `src/types.ts`
|
||||
|
||||
```typescript
|
||||
interface McpApps {
|
||||
claude: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean; // 新增
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 8: 前端预设配置
|
||||
|
||||
#### 8.1 新建 OpenCode 供应商预设
|
||||
**文件**: `src/config/opencodeProviderPresets.ts`
|
||||
|
||||
```typescript
|
||||
export const opencodeProviderPresets: ProviderPreset[] = [
|
||||
{
|
||||
name: "OpenAI",
|
||||
npmPackage: "@ai-sdk/openai",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai",
|
||||
options: { apiKey: "{env:OPENAI_API_KEY}" },
|
||||
models: {
|
||||
"gpt-4o": { name: "GPT-4o" },
|
||||
"gpt-4o-mini": { name: "GPT-4o Mini" },
|
||||
},
|
||||
},
|
||||
theme: { icon: "openai", iconColor: "#00A67E" },
|
||||
},
|
||||
{
|
||||
name: "Anthropic",
|
||||
npmPackage: "@ai-sdk/anthropic",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
options: { apiKey: "{env:ANTHROPIC_API_KEY}" },
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": { name: "Claude Sonnet 4" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OpenAI Compatible",
|
||||
npmPackage: "@ai-sdk/openai-compatible",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
baseURL: "",
|
||||
apiKey: "{env:API_KEY}",
|
||||
},
|
||||
models: {},
|
||||
},
|
||||
isCustomTemplate: true,
|
||||
},
|
||||
// ... 更多预设
|
||||
];
|
||||
|
||||
// npm 包选项
|
||||
export const opencodeNpmPackages = [
|
||||
{ value: "@ai-sdk/openai", label: "OpenAI" },
|
||||
{ value: "@ai-sdk/anthropic", label: "Anthropic" },
|
||||
{ value: "@ai-sdk/openai-compatible", label: "OpenAI Compatible" },
|
||||
{ value: "@ai-sdk/google", label: "Google" },
|
||||
{ value: "@ai-sdk/azure", label: "Azure OpenAI" },
|
||||
{ value: "@ai-sdk/amazon-bedrock", label: "Amazon Bedrock" },
|
||||
// ... 更多选项
|
||||
];
|
||||
```
|
||||
|
||||
### Phase 9: 前端 UI 组件
|
||||
|
||||
#### 9.1 OpenCode 供应商表单
|
||||
**文件**: `src/components/providers/forms/OpenCodeFormFields.tsx`
|
||||
|
||||
新增字段:
|
||||
- npm 包选择器(下拉框 + 自定义输入)
|
||||
- options 编辑器(baseURL, apiKey, headers)
|
||||
- models 编辑器(动态添加/删除模型)
|
||||
|
||||
#### 9.2 供应商卡片按钮适配
|
||||
**文件**: `src/components/providers/ProviderActions.tsx`
|
||||
|
||||
```tsx
|
||||
// OpenCode 使用不同的主按钮
|
||||
if (appId === "opencode") {
|
||||
return (
|
||||
<Button onClick={onAdd}>
|
||||
{isInConfig ? t("provider.removeFromConfig") : t("provider.addToConfig")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 9.3 隐藏 OpenCode 不需要的功能
|
||||
|
||||
在以下组件中检查 `appId !== "opencode"`:
|
||||
- 代理设置面板
|
||||
- 故障转移队列
|
||||
- 供应商切换逻辑
|
||||
|
||||
### Phase 10: 国际化
|
||||
|
||||
#### 10.1 新增翻译 Key
|
||||
**文件**: `src/locales/zh/translation.json` & `en/translation.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"app.opencode": "OpenCode",
|
||||
"provider.addToConfig": "添加到配置",
|
||||
"provider.removeFromConfig": "从配置移除",
|
||||
"provider.inConfig": "已添加",
|
||||
"provider.npmPackage": "AI SDK 包",
|
||||
"provider.models": "模型配置",
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键文件清单
|
||||
|
||||
### 后端(Rust)
|
||||
| 操作 | 文件路径 |
|
||||
|------|---------|
|
||||
| 修改 | `src-tauri/src/app_config.rs` |
|
||||
| 修改 | `src-tauri/src/database/schema.rs` |
|
||||
| 修改 | `src-tauri/src/database/dao/mcp.rs` |
|
||||
| 修改 | `src-tauri/src/database/dao/providers.rs` |
|
||||
| 修改 | `src-tauri/src/services/provider/mod.rs` |
|
||||
| 修改 | `src-tauri/src/services/mcp.rs` |
|
||||
| 修改 | `src-tauri/src/commands/providers.rs` |
|
||||
| 修改 | `src-tauri/src/commands/mcp.rs` |
|
||||
| 修改 | `src-tauri/src/mcp/mod.rs` |
|
||||
| 新建 | `src-tauri/src/opencode_config.rs` |
|
||||
| 新建 | `src-tauri/src/mcp/opencode.rs` |
|
||||
| 新建 | `src-tauri/src/services/provider/opencode.rs` |
|
||||
|
||||
### 前端(TypeScript/React)
|
||||
| 操作 | 文件路径 |
|
||||
|------|---------|
|
||||
| 修改 | `src/types.ts` |
|
||||
| 修改 | `src/lib/api/types.ts` |
|
||||
| 修改 | `src/lib/api/providers.ts` |
|
||||
| 修改 | `src/components/providers/ProviderActions.tsx` |
|
||||
| 修改 | `src/components/providers/ProviderCard.tsx` |
|
||||
| 修改 | `src/components/providers/AddProviderDialog.tsx` |
|
||||
| 修改 | `src/components/providers/forms/ProviderForm.tsx` |
|
||||
| 修改 | `src/App.tsx` |
|
||||
| 新建 | `src/config/opencodeProviderPresets.ts` |
|
||||
| 新建 | `src/components/providers/forms/OpenCodeFormFields.tsx` |
|
||||
|
||||
### 国际化
|
||||
| 操作 | 文件路径 |
|
||||
|------|---------|
|
||||
| 修改 | `src/locales/zh/translation.json` |
|
||||
| 修改 | `src/locales/en/translation.json` |
|
||||
| 修改 | `src/locales/ja/translation.json` |
|
||||
|
||||
---
|
||||
|
||||
## 验证计划
|
||||
|
||||
### 单元测试
|
||||
1. OpenCode 配置读写测试
|
||||
2. MCP 格式转换测试(stdio ↔ local, sse ↔ remote)
|
||||
3. 供应商 CRUD 操作测试
|
||||
|
||||
### 集成测试
|
||||
1. 添加 OpenCode 供应商 → 验证写入 `~/.config/opencode/opencode.json`
|
||||
2. 删除供应商 → 验证从配置文件移除
|
||||
3. MCP 同步测试 → 验证格式正确转换
|
||||
4. 从 live 配置导入 → 验证正确解析
|
||||
|
||||
### 手动测试
|
||||
1. UI 流程:添加预设 → 编辑 → 删除
|
||||
2. 切换应用 Tab → OpenCode 显示正确的 UI(无代理/故障转移)
|
||||
3. 托盘菜单正确显示 OpenCode 供应商
|
||||
4. 深链接导入 OpenCode 供应商
|
||||
|
||||
---
|
||||
|
||||
## 风险评估
|
||||
|
||||
1. **数据库迁移**:需要在升级时自动执行 `ALTER TABLE` 语句
|
||||
2. **配置文件冲突**:OpenCode 可能有自己的配置,需要合并而非覆盖
|
||||
3. **MCP 格式差异**:`stdio` → `local` 转换需要处理边界情况
|
||||
4. **UI 一致性**:OpenCode 的"添加/删除"模式需要与其他应用的"启用/切换"清晰区分
|
||||
|
||||
---
|
||||
|
||||
## 补充说明
|
||||
|
||||
### 托盘菜单特殊处理
|
||||
|
||||
由于 OpenCode 采用累加式管理,托盘菜单行为需要调整:
|
||||
|
||||
- **现有三应用**:托盘菜单显示 `CheckMenuItem`(单选,切换当前供应商)
|
||||
- **OpenCode**:显示当前所有启用的供应商(普通 MenuItem,无勾选逻辑),点击打开主界面
|
||||
|
||||
**修改文件**:`src-tauri/src/tray.rs`(`TRAY_SECTIONS` 常量)
|
||||
|
||||
### 数据库约束更新
|
||||
|
||||
`proxy_config` 表的 CHECK 约束需要扩展:
|
||||
```sql
|
||||
CHECK (app_type IN ('claude','codex','gemini','opencode'))
|
||||
```
|
||||
|
||||
### Settings 结构体扩展
|
||||
|
||||
**文件**:`src-tauri/src/settings.rs`
|
||||
|
||||
需要添加:
|
||||
- `current_provider_opencode: Option<String>` - 对 OpenCode 可能无意义,但保持结构一致
|
||||
- `opencode_config_dir: Option<String>` - 自定义配置目录
|
||||
@@ -0,0 +1,188 @@
|
||||
# CC Switch v3.9.0
|
||||
|
||||
> Local API Proxy, Auto Failover, Universal Provider, and a more complete multi-app workflow
|
||||
|
||||
**[中文版 →](release-note-v3.9.0-zh.md) | [日本語版 →](release-note-v3.9.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.9.0 is the stable release of the v3.9 beta series (`3.9.0-1`, `3.9.0-2`, `3.9.0-3`).
|
||||
It introduces a local API proxy with per-app takeover, automatic failover, universal providers, and many stability and UX improvements across Claude Code, Codex, and Gemini CLI.
|
||||
|
||||
**Release Date**: 2026-01-07
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- Local API Proxy for Claude Code / Codex / Gemini CLI
|
||||
- Auto Failover with circuit breaker and per-app failover queues
|
||||
- Universal Provider: one shared config synced across apps (ideal for API gateways like NewAPI)
|
||||
- Skills improvements: multi-app support, unified management with SSOT + React Query
|
||||
- Common config snippets: extract reusable snippets from the editor or the current provider
|
||||
- MCP import: import MCP servers from installed apps
|
||||
- Usage improvements: auto-refresh, cache hit/creation metrics, and timezone fixes
|
||||
- Linux packaging: RPM and Flatpak artifacts
|
||||
|
||||
---
|
||||
|
||||
## Major Features
|
||||
|
||||
### Local API Proxy
|
||||
|
||||
- Runs a local high-performance HTTP proxy server (Axum-based)
|
||||
- Supports Claude Code, Codex, and Gemini CLI with a unified proxy
|
||||
- Per-app takeover: you can independently decide which app routes through the proxy
|
||||
- Live config takeover: backs up and redirects the CLI live config to the local proxy when takeover is enabled
|
||||
- Monitoring: request logging and usage statistics for easier debugging and cost tracking
|
||||
- Error request logging: keep detailed logs for failed proxy requests to simplify debugging (#401, thanks @yovinchen)
|
||||
|
||||
### Auto Failover (Circuit Breaker)
|
||||
|
||||
- Automatically detects provider failures and triggers protection (circuit breaker)
|
||||
- Automatically switches to a backup provider when the current one is unhealthy
|
||||
- Tracks provider health in real time, and keeps independent failover queues per app
|
||||
- When failover is disabled, timeout/retry related settings no longer affect normal request flow
|
||||
|
||||
### Skills Management
|
||||
|
||||
- Multi-app Skills support for Claude Code and Codex, with smoother migration from older skill layouts (#365, #378, thanks @yovinchen)
|
||||
- Unified Skills management architecture (SSOT + React Query) for more consistent state and refresh behavior
|
||||
- Better discovery UX and performance:
|
||||
- Skip hidden directories during discovery
|
||||
- Faster discovery with long-lived caching for discoverable skills
|
||||
- Clear loading indicators and more discoverable header actions (import/refresh)
|
||||
- Fix wrong skill repo branch (#505, thanks @kjasn)
|
||||
|
||||
### Universal Provider
|
||||
|
||||
- Add a shared provider configuration that can sync to Claude/Codex/Gemini (#348, thanks @Calcium-Ion)
|
||||
- Designed for API gateways that support multiple protocols (e.g., NewAPI)
|
||||
- Allows per-app default model mapping under a single provider
|
||||
|
||||
### Common Config Snippets (Claude/Codex/Gemini)
|
||||
|
||||
- Maintain a reusable "common config" snippet and merge/append it into providers that enable it
|
||||
- New extraction workflow:
|
||||
- Extract from the editor content (what you are currently editing)
|
||||
- Or extract from the current active provider when the editor content is not provided
|
||||
- Codex extraction is safer:
|
||||
- Removes provider-specific sections like `model_provider`, `model`, and the entire `model_providers` table
|
||||
- Preserves `base_url` under `[mcp_servers.*]` so MCP configs are not accidentally broken
|
||||
|
||||
### MCP Management
|
||||
|
||||
- Import MCP servers from installed apps
|
||||
- Improve robustness: skip sync when the target CLI app is not installed; handle invalid Codex `config.toml` gracefully (#461, thanks @majiayu000)
|
||||
- Windows compatibility: wrap npx/npm commands with `cmd /c` for MCP export
|
||||
|
||||
### Usage & Pricing
|
||||
|
||||
- Usage & pricing improvements: auto-refresh, cache hit/creation metrics, timezone handling fixes, and refreshed built-in pricing table (#508, thanks @yovinchen)
|
||||
- DeepLink support: import usage query configuration via deeplink (#400, thanks @qyinter)
|
||||
- Model extraction for usage statistics (#455, thanks @yovinchen)
|
||||
- Usage query credentials can fall back to provider config (#360, thanks @Sirhexs)
|
||||
|
||||
---
|
||||
|
||||
## UX Improvements
|
||||
|
||||
- Provider search filter: quickly find providers by name (#435, thanks @TinsFox)
|
||||
- Provider icon colors: customize provider icon colors for quicker visual identification (#385, thanks @yovinchen)
|
||||
- Keyboard shortcut: `Cmd/Ctrl + ,` opens Settings (#436, thanks @TinsFox)
|
||||
- Skip Claude Code first-run confirmation dialog (optional)
|
||||
- Closable toasts: close buttons for switch toast and all success toasts (#350, thanks @ForteScarlet)
|
||||
- Update badge navigation: clicking the update badge opens the About tab
|
||||
- Settings page tab style improvements (#342, thanks @wenyuanw)
|
||||
- Smoother transitions: fade transitions for app/view switching and exit animations for panels
|
||||
- Proxy takeover active theme: apply an emerald theme while takeover is active
|
||||
- Dark mode readability improvements for forms and labels
|
||||
- Better window dragging area for full-screen panels (#525, thanks @zerob13)
|
||||
|
||||
---
|
||||
|
||||
## Platform Notes
|
||||
|
||||
### Windows
|
||||
|
||||
- Prevent terminal windows from appearing during version checks
|
||||
- Improve window sizing defaults (minimum width/height)
|
||||
- Fix black screen on startup by using the system titlebar
|
||||
- Add a fallback for `crypto.randomUUID()` on older WebViews
|
||||
|
||||
### macOS
|
||||
|
||||
- Use `.app` bundle path for autostart to avoid terminal window popups (#462, thanks @majiayu000)
|
||||
- Improve tray/icon behavior and header alignment
|
||||
|
||||
---
|
||||
|
||||
## Packaging
|
||||
|
||||
- Linux: RPM and Flatpak packaging targets are now available for building release artifacts
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Security improvements for the JavaScript executor and usage script execution (#151, thanks @luojiyin1987).
|
||||
- SQL import is restricted to CC Switch exported backups to reduce the risk of importing unsafe or incompatible SQL dumps.
|
||||
- Proxy takeover modifies CLI live configs; CC Switch will back up the live config before redirecting it to the local proxy. If you want to revert, disable takeover/stop the proxy and restore from the backup when needed.
|
||||
|
||||
## Special Thanks
|
||||
|
||||
Special thanks to @xunyu @deijing @su-fen for their support and contributions. This release wouldn't be possible without you!
|
||||
|
||||
## Download & Installation
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| System | Minimum Version | Architecture |
|
||||
| ------- | ------------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 or later | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| --------------------------------------- | -------------------------------------------------- |
|
||||
| `CC-Switch-v3.9.0-Windows.msi` | **Recommended** - MSI installer with auto-update support |
|
||||
| `CC-Switch-v3.9.0-Windows-Portable.zip` | Portable version, no installation required |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| ------------------------------- | ----------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.9.0-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.9.0-macOS.tar.gz` | For Homebrew installation and auto-update |
|
||||
|
||||
> **Note**: Since the author does not have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Close the app, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and it will open normally afterwards.
|
||||
|
||||
### Homebrew (MacOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Update:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| Distribution | Recommended Format | Installation |
|
||||
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
|
||||
| Other distros / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
| Sandboxed installation | `.flatpak` | `flatpak install CC-Switch-*.flatpak` |
|
||||
@@ -0,0 +1,188 @@
|
||||
# CC Switch v3.9.0
|
||||
|
||||
> ローカル API プロキシ、自動フェイルオーバー、Universal Provider、多アプリ対応の強化
|
||||
|
||||
**[English →](release-note-v3.9.0-en.md) | [中文版 →](release-note-v3.9.0-zh.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.9.0 は v3.9 ベータ(`3.9.0-1`、`3.9.0-2`、`3.9.0-3`)の安定版です。
|
||||
ローカル API プロキシ(アプリ別テイクオーバー対応)、自動フェイルオーバー、Universal Provider を追加し、Claude Code / Codex / Gemini CLI の安定性と操作性を大きく改善しました。
|
||||
|
||||
**リリース日**:2026-01-07
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- ローカル API プロキシ:Claude Code / Codex / Gemini CLI を統一的にプロキシ
|
||||
- 自動フェイルオーバー:サーキットブレーカーとアプリ別のフェイルオーバーキュー
|
||||
- Universal Provider:1つの設定を複数アプリへ同期(NewAPI などのゲートウェイ向け)
|
||||
- Skills の改善:マルチアプリ対応、SSOT + React Query による管理の統一
|
||||
- 共通設定スニペット:エディタ内容または現在のプロバイダから抽出
|
||||
- MCP インポート:インストール済みアプリから MCP servers を取り込み
|
||||
- 使用量の改善:自動更新、キャッシュ指標、タイムゾーン修正
|
||||
- Linux パッケージ:RPM / Flatpak の成果物を追加
|
||||
|
||||
---
|
||||
|
||||
## 主要機能
|
||||
|
||||
### ローカル API プロキシ(Local API Proxy)
|
||||
|
||||
- ローカルで高性能な HTTP プロキシサーバーを起動(Axum ベース)
|
||||
- Claude Code / Codex / Gemini CLI の API リクエストを統一的に扱う
|
||||
- アプリ別テイクオーバー:アプリごとにプロキシ経由にするかを個別に切り替え可能
|
||||
- Live 設定テイクオーバー:有効化時に CLI の live 設定をバックアップし、ローカルプロキシへリダイレクト
|
||||
- 監視:リクエストログと使用量統計でデバッグとコスト把握を支援
|
||||
- エラーリクエストのログ:失敗したプロキシリクエストも詳細に記録してデバッグを容易に(#401、@yovinchen に感謝)
|
||||
|
||||
### 自動フェイルオーバー(Auto Failover / サーキットブレーカー)
|
||||
|
||||
- 障害を検知して保護(サーキットブレーカー)を自動で発動
|
||||
- 現在のプロバイダが不調な場合、バックアッププロバイダへ自動切り替え
|
||||
- アプリごとに独立したフェイルオーバーキューとヘルス状態を管理
|
||||
- フェイルオーバーを無効化している場合、タイムアウト/リトライ関連の設定は通常フローに影響しません
|
||||
|
||||
### Skills 管理
|
||||
|
||||
- Claude Code と Codex の Skills をマルチアプリで利用可能にし、旧レイアウトからの移行もよりスムーズに(#365、#378、@yovinchen に感謝)
|
||||
- SSOT + React Query による Skills 管理の統一で、状態の一貫性と更新挙動を改善
|
||||
- Discovery の体験と性能を改善:
|
||||
- スキャン時に隠しディレクトリをスキップ
|
||||
- Discoverable skills に長寿命キャッシュを適用して高速化
|
||||
- ローディング表示の改善と、インポート/更新などの操作導線を整理
|
||||
- Skills リポジトリのブランチ設定を修正(#505、@kjasn に感謝)
|
||||
|
||||
### Universal Provider
|
||||
|
||||
- 複数アプリで共有できるプロバイダ設定を追加(Claude/Codex/Gemini へ同期)(#348、@Calcium-Ion に感謝)
|
||||
- NewAPI のような複数プロトコル対応の API ゲートウェイを想定
|
||||
- 1つのプロバイダ内でアプリ別にデフォルトモデルを割り当て可能
|
||||
|
||||
### 共通設定スニペット(Claude/Codex/Gemini)
|
||||
|
||||
- 「共通設定スニペット」を保持し、有効化したプロバイダへマージ/追記
|
||||
- 新しい抽出フロー:
|
||||
- エディタの現在内容から抽出(編集している内容)
|
||||
- エディタ内容がない場合は、現在アクティブなプロバイダから抽出
|
||||
- Codex の抽出はより安全:
|
||||
- `model_provider`、`model`、および `model_providers` テーブル全体など、プロバイダ固有の設定を除去
|
||||
- `[mcp_servers.*]` 配下の `base_url` は保持し、MCP 設定を壊しにくくしています
|
||||
|
||||
### MCP 管理
|
||||
|
||||
- インストール済みアプリから MCP servers をインポート
|
||||
- 安定性向上:対象 CLI が未インストールなら同期をスキップし、無効な Codex `config.toml` も適切に扱います(#461、@majiayu000 に感謝)
|
||||
- Windows 互換性:MCP エクスポート時の npx/npm 呼び出しを `cmd /c` でラップ
|
||||
|
||||
### 使用量と価格データ
|
||||
|
||||
- 使用量/価格の改善:自動更新、キャッシュ指標、タイムゾーン修正、内蔵価格テーブル更新(#508、@yovinchen に感謝)
|
||||
- DeepLink 対応:deeplink から使用量クエリ設定をインポート(#400、@qyinter に感謝)
|
||||
- 使用量統計からモデル情報を抽出(#455、@yovinchen に感謝)
|
||||
- 使用量クエリ資格情報はプロバイダ設定へフォールバック可能(#360、@Sirhexs に感謝)
|
||||
|
||||
---
|
||||
|
||||
## 使い勝手の改善
|
||||
|
||||
- プロバイダ検索フィルター(名前で素早く検索)(#435、@TinsFox に感謝)
|
||||
- プロバイダのアイコン色:アイコンに任意の色を設定して見分けやすく(#385、@yovinchen に感謝)
|
||||
- ショートカット:`Cmd/Ctrl + ,` で設定を開く(#436、@TinsFox に感謝)
|
||||
- Claude Code の初回確認ダイアログをスキップ可能(任意)
|
||||
- トースト通知のクローズボタン:切り替え通知と成功通知を閉じられるように(#350、@ForteScarlet に感謝)
|
||||
- 更新バッジをクリックすると About タブへ移動
|
||||
- 設定ページのタブスタイル改善(#342、@wenyuanw に感謝)
|
||||
- アプリ/ビュー切り替えのフェードとパネル終了アニメーション
|
||||
- プロキシテイクオーバー中はエメラルド系テーマを適用して状態を分かりやすく
|
||||
- ダークモードの視認性改善
|
||||
- FullScreenPanel のウィンドウドラッグ領域を改善(#525、@zerob13 に感謝)
|
||||
|
||||
---
|
||||
|
||||
## プラットフォーム別メモ
|
||||
|
||||
### Windows
|
||||
|
||||
- バージョンチェック時にターミナルが表示されないよう改善
|
||||
- ウィンドウ最小サイズのデフォルトを調整
|
||||
- 起動時の黒画面を避けるため、システムタイトルバー方式を採用
|
||||
- 古い WebView 向けに `crypto.randomUUID()` のフォールバックを追加
|
||||
|
||||
### macOS
|
||||
|
||||
- 自動起動で `.app` バンドルパスを使用し、ターミナル表示を回避(#462、@majiayu000 に感謝)
|
||||
- トレイとヘッダー周りの体験を改善
|
||||
|
||||
---
|
||||
|
||||
## パッケージ
|
||||
|
||||
- Linux:RPM と Flatpak のパッケージングを追加し、リリース成果物の生成に対応
|
||||
|
||||
---
|
||||
|
||||
## 注意事項
|
||||
|
||||
- セキュリティ強化:JavaScript 実行器と使用量スクリプト実行に関するセキュリティ問題を修正(#151、@luojiyin1987 に感謝)。
|
||||
- SQL インポートは CC Switch がエクスポートしたバックアップのみに制限されます(安全性のため)。
|
||||
- プロキシのテイクオーバーは CLI の live 設定を変更します。CC Switch はリダイレクト前に live 設定をバックアップします。元に戻す場合はテイクオーバー無効化/プロキシ停止を行い、必要に応じてバックアップから復元してください。
|
||||
|
||||
## 特別な謝辞
|
||||
|
||||
@xunyu @deijing @su-fen の皆様のサポートと貢献に特別な感謝を申し上げます。皆様なしではこのリリースは実現しませんでした!
|
||||
|
||||
## ダウンロード & インストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から該当するバージョンをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| システム | 最低バージョン | アーキテクチャ |
|
||||
| -------- | ----------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表参照 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| --------------------------------------- | -------------------------------------------- |
|
||||
| `CC-Switch-v3.9.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||
| `CC-Switch-v3.9.0-Windows-Portable.zip` | ポータブル版、インストール不要 |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ------------------------------- | ----------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.9.0-macOS.zip` | **推奨** - 解凍して Applications へドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.9.0-macOS.tar.gz` | Homebrew インストールおよび自動更新用 |
|
||||
|
||||
> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元が未確認」という警告が表示される場合があります。アプリを閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、正常に開けるようになります。
|
||||
|
||||
### Homebrew (MacOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
アップデート:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------ |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接実行、または AUR を使用 |
|
||||
| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
| サンドボックスで実行したい場合 | `.flatpak` | `flatpak install CC-Switch-*.flatpak` |
|
||||
@@ -0,0 +1,188 @@
|
||||
# CC Switch v3.9.0
|
||||
|
||||
> 本地 API 代理、自动故障切换、统一供应商与多应用工作流增强
|
||||
|
||||
**[English →](release-note-v3.9.0-en.md) | [日本語版 →](release-note-v3.9.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.9.0 是 v3.9 测试版序列(`3.9.0-1`、`3.9.0-2`、`3.9.0-3`)的稳定版。
|
||||
本次更新带来本地 API 代理(支持按应用接管)、自动故障切换、统一供应商(Universal Provider),并对 Claude Code / Codex / Gemini CLI 的稳定性与使用体验做了大量改进。
|
||||
|
||||
**发布日期**:2026-01-07
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- 本地 API 代理:Claude Code / Codex / Gemini CLI 统一接入
|
||||
- 自动故障切换:熔断保护 + 每个应用独立的 failover 队列
|
||||
- 统一供应商:一份配置可同步到多个应用(适合 NewAPI 等网关)
|
||||
- Skills 相关增强:支持多应用、管理架构统一(SSOT + React Query)
|
||||
- 通用配置片段:支持从编辑器内容或当前供应商提取可复用片段
|
||||
- MCP 导入:支持从已安装应用导入 MCP servers
|
||||
- 用量增强:自动刷新、缓存命中/创建指标、时区修复
|
||||
- Linux 打包:新增 RPM 与 Flatpak 制品
|
||||
|
||||
---
|
||||
|
||||
## 主要功能
|
||||
|
||||
### 本地 API 代理(Local API Proxy)
|
||||
|
||||
- 运行一个本地高性能 HTTP 代理服务(基于 Axum)
|
||||
- 统一代理 Claude Code、Codex、Gemini CLI 的 API 请求
|
||||
- 按应用接管:你可以分别控制每个应用是否走本地代理
|
||||
- Live 配置接管:启用接管时,会备份并重定向 CLI 的 live 配置到本地代理
|
||||
- 监控能力:记录请求日志与用量统计,便于排错与成本分析
|
||||
- 错误请求日志:代理会记录失败请求的详细信息,便于定位问题(#401,感谢 @yovinchen)
|
||||
|
||||
### 自动故障切换(Auto Failover / 熔断)
|
||||
|
||||
- 自动检测供应商异常并触发熔断保护
|
||||
- 当前供应商不可用时自动切换到备用供应商
|
||||
- 每个应用维护独立的 failover 队列,并实时追踪健康状态
|
||||
- 当关闭故障切换时,超时/重试相关配置不会影响正常请求流程
|
||||
|
||||
### Skills 管理
|
||||
|
||||
- Skills 支持 Claude Code 与 Codex 多应用使用,并提供旧结构到新结构的平滑迁移(#365、#378,感谢 @yovinchen)
|
||||
- Skills 管理架构统一(SSOT + React Query),状态刷新与数据一致性更稳定
|
||||
- 发现(Discovery)体验与性能改进:
|
||||
- 扫描时跳过隐藏目录
|
||||
- Discoverable skills 使用长生命周期缓存提升性能
|
||||
- 增加加载状态提示,导入/刷新等操作入口更显眼
|
||||
- 修复 Skills 仓库分支配置错误(#505,感谢 @kjasn)
|
||||
|
||||
### 统一供应商(Universal Provider)
|
||||
|
||||
- 新增“跨应用共享”的供应商配置,可同步到 Claude/Codex/Gemini(#348,感谢 @Calcium-Ion)
|
||||
- 适配支持多协议的 API 网关(例如 NewAPI)
|
||||
- 同一个供应商下可按应用分别设置默认模型映射
|
||||
|
||||
### 通用配置片段(Claude/Codex/Gemini)
|
||||
|
||||
- 维护一段“通用配置片段”,并将其合并/追加到启用该功能的供应商配置中
|
||||
- 新增“提取通用配置片段”工作流:
|
||||
- 优先从编辑器当前内容提取(你正在编辑的内容)
|
||||
- 若未提供编辑器内容,则从当前激活的供应商提取
|
||||
- Codex 场景提取更安全:
|
||||
- 自动移除 `model_provider`、`model` 以及整个 `model_providers` 表等供应商相关内容
|
||||
- 会保留 `[mcp_servers.*]` 下的 `base_url`,避免误伤 MCP 配置
|
||||
|
||||
### MCP 管理
|
||||
|
||||
- 支持从已安装应用导入 MCP servers
|
||||
- 同步更稳健:目标 CLI 未安装则跳过;无效的 Codex `config.toml` 可更优雅处理(#461,感谢 @majiayu000)
|
||||
- Windows 兼容性:MCP 导出相关的 npx/npm 调用使用 `cmd /c` 包裹
|
||||
|
||||
### 用量与计费数据
|
||||
|
||||
- 用量与计费增强:自动刷新、缓存命中/创建指标、时区修复,以及内置价格表更新(#508,感谢 @yovinchen)
|
||||
- 深链支持:可通过 deeplink 导入用量查询配置(#400,感谢 @qyinter)
|
||||
- 用量统计支持提取模型信息(#455,感谢 @yovinchen)
|
||||
- 用量查询凭证支持从供应商配置回退(#360,感谢 @Sirhexs)
|
||||
|
||||
---
|
||||
|
||||
## 体验优化
|
||||
|
||||
- 供应商搜索过滤:按名称快速查找(#435,感谢 @TinsFox)
|
||||
- 供应商图标颜色:支持为供应商图标设置自定义颜色,便于快速区分(#385,感谢 @yovinchen)
|
||||
- 快捷键:`Cmd/Ctrl + ,` 打开设置(#436,感谢 @TinsFox)
|
||||
- 可跳过 Claude Code 首次确认弹窗(可选)
|
||||
- Toast 通知可关闭:切换提示与成功提示都支持关闭按钮(#350,感谢 @ForteScarlet)
|
||||
- 点击更新徽章会自动跳转到 About 标签页
|
||||
- 设置页 Tab 样式改进(#342,感谢 @wenyuanw)
|
||||
- 更顺滑的切换动效:应用/视图淡入淡出与面板退出动画
|
||||
- 代理接管激活时应用翡翠绿主题,便于一眼识别当前状态
|
||||
- 深色模式可读性增强(表单与标签对比度等)
|
||||
- FullScreenPanel 的窗口拖拽区域优化(#525,感谢 @zerob13)
|
||||
|
||||
---
|
||||
|
||||
## 平台说明
|
||||
|
||||
### Windows
|
||||
|
||||
- 版本检查不再弹出终端窗口
|
||||
- 改进窗口尺寸默认值(最小宽高)
|
||||
- 修复部分设备启动黑屏问题(使用系统标题栏方案)
|
||||
- 兼容旧 WebView:为 `crypto.randomUUID()` 增加降级方案
|
||||
|
||||
### macOS
|
||||
|
||||
- 自启动使用 `.app bundle` 路径,避免弹出终端窗口(#462,感谢 @majiayu000)
|
||||
- 托盘与标题栏相关体验优化
|
||||
|
||||
---
|
||||
|
||||
## 打包
|
||||
|
||||
- Linux:新增 RPM 与 Flatpak 打包目标,用于生成发布制品
|
||||
|
||||
---
|
||||
|
||||
## 说明与注意事项
|
||||
|
||||
- 安全增强:修复 JavaScript 执行器与用量脚本相关的安全问题(#151,感谢 @luojiyin1987)。
|
||||
- 为降低导入风险,SQL 导入被限制为仅允许导入 CC Switch 自己导出的备份。
|
||||
- Proxy 接管会修改 CLI 的 live 配置;CC Switch 会在重定向前自动备份 live 配置。如需回退,可关闭接管/停止代理,并在必要时从备份恢复。
|
||||
|
||||
## 特别感谢
|
||||
|
||||
特别感谢 @xunyu @deijing @su-fen 做出的支持和贡献,没有你们就没有这个版本!
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | ----------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| --------------------------------------- | ----------------------------------- |
|
||||
| `CC-Switch-v3.9.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.9.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ------------------------------- | --------------------------------------------------------- |
|
||||
| `CC-Switch-v3.9.0-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
|
||||
| `CC-Switch-v3.9.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||
|
||||
### Homebrew(MacOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
| 沙箱隔离需求 | `.flatpak` | `flatpak install CC-Switch-*.flatpak` |
|
||||
@@ -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,98 @@
|
||||
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:
|
||||
# Required for libdbusmenu build (intltool was removed from GNOME SDK since 2019)
|
||||
- name: intltool
|
||||
cleanup:
|
||||
- "*"
|
||||
sources:
|
||||
- type: archive
|
||||
url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz
|
||||
sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd
|
||||
|
||||
# Required for tray icon support
|
||||
- name: libayatana-ido
|
||||
buildsystem: cmake-ninja
|
||||
config-opts:
|
||||
- -DENABLE_TESTS=NO
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/AyatanaIndicators/ayatana-ido.git
|
||||
tag: 0.10.4
|
||||
|
||||
- name: libdbusmenu-gtk3
|
||||
buildsystem: autotools
|
||||
build-options:
|
||||
cflags: -Wno-error
|
||||
config-opts:
|
||||
- --with-gtk=3
|
||||
- --disable-dumper
|
||||
- --disable-static
|
||||
- --disable-nls
|
||||
sources:
|
||||
- type: archive
|
||||
url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz
|
||||
sha256: b9cc4a2acd74509435892823607d966d424bd9ad5d0b00938f27240a1bfa878a
|
||||
|
||||
- name: libayatana-indicator
|
||||
buildsystem: cmake-ninja
|
||||
config-opts:
|
||||
- -DENABLE_TESTS=NO
|
||||
- -DENABLE_IDO=YES
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/AyatanaIndicators/libayatana-indicator.git
|
||||
tag: 0.9.4
|
||||
|
||||
- name: libayatana-appindicator
|
||||
buildsystem: cmake-ninja
|
||||
config-opts:
|
||||
- -DENABLE_BINDINGS_MONO=NO
|
||||
- -DENABLE_BINDINGS_VALA=NO
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/AyatanaIndicators/libayatana-appindicator.git
|
||||
tag: 0.5.93
|
||||
|
||||
- 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
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.9.0-2",
|
||||
"version": "3.9.1",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -87,4 +87,4 @@
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -701,7 +701,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.9.0-2"
|
||||
version = "3.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.9.0-2"
|
||||
version = "3.9.1"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -37,7 +37,7 @@ tauri-plugin-deep-link = "2"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||
futures = "0.3"
|
||||
async-stream = "0.3"
|
||||
@@ -77,7 +77,8 @@ objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
||||
codegen-units = 1
|
||||
lto = "thin"
|
||||
opt-level = "s"
|
||||
panic = "abort"
|
||||
# 使用 unwind 以便 panic hook 能捕获 backtrace(abort 会直接终止无法捕获)
|
||||
panic = "unwind"
|
||||
strip = "symbols"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
+152
-8
@@ -13,6 +13,8 @@ pub struct McpApps {
|
||||
pub codex: bool,
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
#[serde(default)]
|
||||
pub opencode: bool,
|
||||
}
|
||||
|
||||
impl McpApps {
|
||||
@@ -22,6 +24,7 @@ impl McpApps {
|
||||
AppType::Claude => self.claude,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +34,7 @@ impl McpApps {
|
||||
AppType::Claude => self.claude = enabled,
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,15 +50,129 @@ impl McpApps {
|
||||
if self.gemini {
|
||||
apps.push(AppType::Gemini);
|
||||
}
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
#[serde(default)]
|
||||
pub opencode: 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,
|
||||
AppType::OpenCode => self.opencode,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置指定应用的启用状态
|
||||
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,
|
||||
AppType::OpenCode => self.opencode = 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);
|
||||
}
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode
|
||||
}
|
||||
|
||||
/// 仅启用指定应用(其他应用设为禁用)
|
||||
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 {
|
||||
@@ -101,6 +219,9 @@ pub struct McpRoot {
|
||||
pub codex: McpConfig,
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub gemini: McpConfig,
|
||||
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub opencode: McpConfig,
|
||||
}
|
||||
|
||||
impl Default for McpRoot {
|
||||
@@ -112,6 +233,7 @@ impl Default for McpRoot {
|
||||
claude: McpConfig::default(),
|
||||
codex: McpConfig::default(),
|
||||
gemini: McpConfig::default(),
|
||||
opencode: McpConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +254,8 @@ pub struct PromptRoot {
|
||||
pub codex: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub gemini: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub opencode: PromptConfig,
|
||||
}
|
||||
|
||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||
@@ -145,7 +269,8 @@ use crate::provider::ProviderManager;
|
||||
pub enum AppType {
|
||||
Claude,
|
||||
Codex,
|
||||
Gemini, // 新增
|
||||
Gemini,
|
||||
OpenCode,
|
||||
}
|
||||
|
||||
impl AppType {
|
||||
@@ -153,7 +278,8 @@ impl AppType {
|
||||
match self {
|
||||
AppType::Claude => "claude",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini", // 新增
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,11 +292,12 @@ impl FromStr for AppType {
|
||||
match normalized.as_str() {
|
||||
"claude" => Ok(AppType::Claude),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini), // 新增
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
other => Err(AppError::localized(
|
||||
"unsupported_app",
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -187,6 +314,9 @@ pub struct CommonConfigSnippets {
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gemini: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode: Option<String>,
|
||||
}
|
||||
|
||||
impl CommonConfigSnippets {
|
||||
@@ -196,6 +326,7 @@ impl CommonConfigSnippets {
|
||||
AppType::Claude => self.claude.as_ref(),
|
||||
AppType::Codex => self.codex.as_ref(),
|
||||
AppType::Gemini => self.gemini.as_ref(),
|
||||
AppType::OpenCode => self.opencode.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +336,7 @@ impl CommonConfigSnippets {
|
||||
AppType::Claude => self.claude = snippet,
|
||||
AppType::Codex => self.codex = snippet,
|
||||
AppType::Gemini => self.gemini = snippet,
|
||||
AppType::OpenCode => self.opencode = snippet,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,7 +375,8 @@ impl Default for MultiAppConfig {
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), ProviderManager::default());
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
|
||||
apps.insert("gemini".to_string(), ProviderManager::default());
|
||||
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||
|
||||
Self {
|
||||
version: 2,
|
||||
@@ -402,6 +535,7 @@ impl MultiAppConfig {
|
||||
AppType::Claude => &self.mcp.claude,
|
||||
AppType::Codex => &self.mcp.codex,
|
||||
AppType::Gemini => &self.mcp.gemini,
|
||||
AppType::OpenCode => &self.mcp.opencode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +545,7 @@ impl MultiAppConfig {
|
||||
AppType::Claude => &mut self.mcp.claude,
|
||||
AppType::Codex => &mut self.mcp.codex,
|
||||
AppType::Gemini => &mut self.mcp.gemini,
|
||||
AppType::OpenCode => &mut self.mcp.opencode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,6 +559,7 @@ impl MultiAppConfig {
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
@@ -443,6 +579,7 @@ impl MultiAppConfig {
|
||||
if !self.prompts.claude.prompts.is_empty()
|
||||
|| !self.prompts.codex.prompts.is_empty()
|
||||
|| !self.prompts.gemini.prompts.is_empty()
|
||||
|| !self.prompts.opencode.prompts.is_empty()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -450,7 +587,12 @@ impl MultiAppConfig {
|
||||
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
|
||||
|
||||
let mut imported = false;
|
||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
for app in [
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
] {
|
||||
// 复用已有的单应用导入逻辑
|
||||
if Self::auto_import_prompt_if_exists(self, app)? {
|
||||
imported = true;
|
||||
@@ -519,6 +661,7 @@ impl MultiAppConfig {
|
||||
AppType::Claude => &mut config.prompts.claude.prompts,
|
||||
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||
};
|
||||
|
||||
prompts.insert(id, prompt);
|
||||
@@ -552,6 +695,7 @@ impl MultiAppConfig {
|
||||
AppType::Claude => &self.mcp.claude.servers,
|
||||
AppType::Codex => &self.mcp.codex.servers,
|
||||
AppType::Gemini => &self.mcp.gemini.servers,
|
||||
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||
};
|
||||
|
||||
for (id, entry) in old_servers {
|
||||
|
||||
@@ -7,6 +7,88 @@ 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 平台不做任何处理
|
||||
}
|
||||
|
||||
/// 检测路径是否为 WSL 网络路径(如 \\wsl$\Ubuntu\... 或 \\wsl.localhost\Ubuntu\...)
|
||||
/// WSL 环境运行的是 Linux,不需要 cmd /c 包装
|
||||
/// 注意:仅检测直接 UNC 路径,映射磁盘符(如 Z: -> \\wsl$\...)无法检测
|
||||
#[cfg(windows)]
|
||||
fn is_wsl_path(path: &Path) -> bool {
|
||||
use std::path::{Component, Prefix};
|
||||
if let Some(Component::Prefix(prefix)) = path.components().next() {
|
||||
match prefix.kind() {
|
||||
Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => {
|
||||
let s = server.to_string_lossy();
|
||||
s.eq_ignore_ascii_case("wsl$") || s.eq_ignore_ascii_case("wsl.localhost")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn is_wsl_path(_path: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpStatus {
|
||||
@@ -313,6 +395,11 @@ pub fn set_mcp_servers_map(
|
||||
};
|
||||
|
||||
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
|
||||
// 检测目标路径是否为 WSL,若是则跳过 cmd /c 包装
|
||||
let is_wsl_target = is_wsl_path(&path);
|
||||
if is_wsl_target {
|
||||
log::info!("检测到 WSL 路径,跳过 cmd /c 包装: {}", path.display());
|
||||
}
|
||||
let mut out: Map<String, Value> = Map::new();
|
||||
for (id, spec) in servers.iter() {
|
||||
let mut obj = if let Some(map) = spec.as_object() {
|
||||
@@ -339,6 +426,11 @@ pub fn set_mcp_servers_map(
|
||||
obj.remove("homepage");
|
||||
obj.remove("docs");
|
||||
|
||||
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式(WSL 路径除外)
|
||||
if !is_wsl_target {
|
||||
wrap_command_for_windows(&mut obj);
|
||||
}
|
||||
|
||||
out.insert(id.clone(), Value::Object(obj));
|
||||
}
|
||||
|
||||
@@ -352,3 +444,200 @@ 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"]));
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 WSL 路径检测功能
|
||||
#[test]
|
||||
fn test_is_wsl_path_wsl_dollar() {
|
||||
// wsl$ 格式 - 各种发行版
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Debian\home\user\.claude")));
|
||||
assert!(is_wsl_path(Path::new(
|
||||
r"\\wsl$\openSUSE-Leap-15.2\home\user"
|
||||
)));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\kali-linux\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Arch\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Alpine\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Fedora\home\user")));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// 非 Windows 平台始终返回 false
|
||||
assert!(!is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_wsl_path_wsl_localhost() {
|
||||
// wsl.localhost 格式
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(is_wsl_path(Path::new(
|
||||
r"\\wsl.localhost\Ubuntu\home\user\.claude"
|
||||
)));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl.localhost\Debian\home\user")));
|
||||
assert!(is_wsl_path(Path::new(
|
||||
r"\\wsl.localhost\openSUSE-Leap-15.2\home\user"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_wsl_path_case_insensitive() {
|
||||
// 大小写不敏感
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(is_wsl_path(Path::new(r"\\WSL$\Ubuntu\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\Wsl$\Ubuntu\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\WSL.LOCALHOST\Ubuntu\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\Wsl.Localhost\Ubuntu\home\user")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_wsl_path_non_wsl() {
|
||||
// 非 WSL 路径
|
||||
assert!(!is_wsl_path(Path::new(r"C:\Users\user\.claude")));
|
||||
assert!(!is_wsl_path(Path::new(r"D:\Workspace\project")));
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(!is_wsl_path(Path::new(r"\\server\share\path")));
|
||||
assert!(!is_wsl_path(Path::new(r"\\localhost\c$\Users")));
|
||||
assert!(!is_wsl_path(Path::new(r"\\192.168.1.1\share")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,21 @@ use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
fn get_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 Codex 配置目录路径
|
||||
pub fn get_codex_config_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||||
return custom;
|
||||
}
|
||||
|
||||
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
|
||||
get_home_dir().join(".codex")
|
||||
}
|
||||
|
||||
/// 获取 Codex auth.json 路径
|
||||
|
||||
@@ -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())? {
|
||||
@@ -38,6 +51,15 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
let config_path = crate::opencode_config::get_opencode_config_path();
|
||||
let exists = config_path.exists();
|
||||
let path = crate::opencode_config::get_opencode_dir()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +76,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||
AppType::Claude => config::get_claude_config_dir(),
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
};
|
||||
|
||||
Ok(dir.to_string_lossy().to_string())
|
||||
@@ -66,6 +89,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
||||
AppType::Claude => config::get_claude_config_dir(),
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
};
|
||||
|
||||
if !config_dir.exists() {
|
||||
@@ -155,8 +179,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 +220,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 +242,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,247 @@
|
||||
//! 全局出站代理相关命令
|
||||
//!
|
||||
//! 提供获取、设置和测试全局代理的 Tauri 命令。
|
||||
|
||||
use crate::proxy::http_client;
|
||||
use crate::store::AppState;
|
||||
use serde::Serialize;
|
||||
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 获取全局代理 URL
|
||||
///
|
||||
/// 返回当前配置的代理 URL,null 表示直连。
|
||||
#[tauri::command]
|
||||
pub fn get_global_proxy_url(state: tauri::State<'_, AppState>) -> Result<Option<String>, String> {
|
||||
let result = state.db.get_global_proxy_url().map_err(|e| e.to_string())?;
|
||||
log::debug!(
|
||||
"[GlobalProxy] [GP-010] Read from database: {}",
|
||||
result
|
||||
.as_ref()
|
||||
.map(|u| http_client::mask_url(u))
|
||||
.unwrap_or_else(|| "None".to_string())
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 设置全局代理 URL
|
||||
///
|
||||
/// - 传入非空字符串:启用代理
|
||||
/// - 传入空字符串:清除代理(直连)
|
||||
///
|
||||
/// 执行顺序:先验证 → 写 DB → 再应用
|
||||
/// 这样确保 DB 写失败时不会出现运行态与持久化不一致的问题
|
||||
#[tauri::command]
|
||||
pub fn set_global_proxy_url(state: tauri::State<'_, AppState>, url: String) -> Result<(), String> {
|
||||
// 调试:显示接收到的 URL 信息(不包含敏感内容)
|
||||
let has_auth = url.contains('@') && (url.starts_with("http://") || url.starts_with("socks"));
|
||||
log::debug!(
|
||||
"[GlobalProxy] [GP-011] Received URL: length={}, has_auth={}",
|
||||
url.len(),
|
||||
has_auth
|
||||
);
|
||||
|
||||
let url_opt = if url.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(url.as_str())
|
||||
};
|
||||
|
||||
// 1. 先验证代理配置是否有效(不应用)
|
||||
http_client::validate_proxy(url_opt)?;
|
||||
|
||||
// 2. 验证成功后保存到数据库
|
||||
state
|
||||
.db
|
||||
.set_global_proxy_url(url_opt)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 3. DB 写入成功后再应用到运行态
|
||||
http_client::apply_proxy(url_opt)?;
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] [GP-009] Configuration updated: {}",
|
||||
url_opt
|
||||
.map(http_client::mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 代理测试结果
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyTestResult {
|
||||
/// 是否连接成功
|
||||
pub success: bool,
|
||||
/// 延迟(毫秒)
|
||||
pub latency_ms: u64,
|
||||
/// 错误信息
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 测试代理连接
|
||||
///
|
||||
/// 通过指定的代理 URL 发送测试请求,返回连接结果和延迟。
|
||||
/// 使用多个测试目标,任一成功即认为代理可用。
|
||||
#[tauri::command]
|
||||
pub async fn test_proxy_url(url: String) -> Result<ProxyTestResult, String> {
|
||||
if url.trim().is_empty() {
|
||||
return Err("Proxy URL is empty".to_string());
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
// 构建带代理的临时客户端
|
||||
let proxy = reqwest::Proxy::all(&url).map_err(|e| format!("Invalid proxy URL: {e}"))?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build client: {e}"))?;
|
||||
|
||||
// 使用多个测试目标,提高兼容性
|
||||
// 优先使用 httpbin(专门用于 HTTP 测试),回退到其他公共端点
|
||||
let test_urls = [
|
||||
"https://httpbin.org/get",
|
||||
"https://www.google.com",
|
||||
"https://api.anthropic.com",
|
||||
];
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for test_url in test_urls {
|
||||
match client.head(test_url).send().await {
|
||||
Ok(resp) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
log::debug!(
|
||||
"[GlobalProxy] Test successful: {} -> {} via {} ({}ms)",
|
||||
http_client::mask_url(&url),
|
||||
test_url,
|
||||
resp.status(),
|
||||
latency
|
||||
);
|
||||
return Ok(ProxyTestResult {
|
||||
success: true,
|
||||
latency_ms: latency,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("[GlobalProxy] Test to {test_url} failed: {e}");
|
||||
last_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有测试目标都失败
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
let error_msg = last_error
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_else(|| "All test targets failed".to_string());
|
||||
|
||||
log::debug!(
|
||||
"[GlobalProxy] Test failed: {} -> {} ({}ms)",
|
||||
http_client::mask_url(&url),
|
||||
error_msg,
|
||||
latency
|
||||
);
|
||||
|
||||
Ok(ProxyTestResult {
|
||||
success: false,
|
||||
latency_ms: latency,
|
||||
error: Some(error_msg),
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取当前出站代理状态
|
||||
///
|
||||
/// 返回当前是否启用了出站代理以及代理 URL。
|
||||
#[tauri::command]
|
||||
pub fn get_upstream_proxy_status() -> UpstreamProxyStatus {
|
||||
let url = http_client::get_current_proxy_url();
|
||||
UpstreamProxyStatus {
|
||||
enabled: url.is_some(),
|
||||
proxy_url: url,
|
||||
}
|
||||
}
|
||||
|
||||
/// 出站代理状态信息
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpstreamProxyStatus {
|
||||
/// 是否启用代理
|
||||
pub enabled: bool,
|
||||
/// 代理 URL
|
||||
pub proxy_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 检测到的代理信息
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DetectedProxy {
|
||||
/// 代理 URL
|
||||
pub url: String,
|
||||
/// 代理类型 (http/socks5)
|
||||
pub proxy_type: String,
|
||||
/// 端口
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// 常见代理端口配置
|
||||
/// 格式:(端口, 主要类型, 是否同时支持 http 和 socks5)
|
||||
/// 对于 mixed 端口,会同时返回两种协议供用户选择
|
||||
const PROXY_PORTS: &[(u16, &str, bool)] = &[
|
||||
(7890, "http", true), // Clash (mixed mode)
|
||||
(7891, "socks5", false), // Clash SOCKS only
|
||||
(1080, "socks5", false), // 通用 SOCKS5
|
||||
(8080, "http", false), // 通用 HTTP
|
||||
(8888, "http", false), // Charles/Fiddler
|
||||
(3128, "http", false), // Squid
|
||||
(10808, "socks5", false), // V2Ray SOCKS
|
||||
(10809, "http", false), // V2Ray HTTP
|
||||
];
|
||||
|
||||
/// 扫描本地代理
|
||||
///
|
||||
/// 检测常见端口是否有代理服务在运行。
|
||||
/// 使用异步任务避免阻塞 UI 线程。
|
||||
#[tauri::command]
|
||||
pub async fn scan_local_proxies() -> Vec<DetectedProxy> {
|
||||
// 使用 spawn_blocking 避免阻塞主线程
|
||||
tokio::task::spawn_blocking(|| {
|
||||
let mut found = Vec::new();
|
||||
|
||||
for &(port, primary_type, is_mixed) in PROXY_PORTS {
|
||||
let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port);
|
||||
if TcpStream::connect_timeout(&addr.into(), Duration::from_millis(100)).is_ok() {
|
||||
// 添加主要类型
|
||||
found.push(DetectedProxy {
|
||||
url: format!("{primary_type}://127.0.0.1:{port}"),
|
||||
proxy_type: primary_type.to_string(),
|
||||
port,
|
||||
});
|
||||
// 对于 mixed 端口,同时添加另一种协议
|
||||
if is_mixed {
|
||||
let alt_type = if primary_type == "http" {
|
||||
"socks5"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
found.push(DetectedProxy {
|
||||
url: format!("{alt_type}://127.0.0.1:{port}"),
|
||||
proxy_type: alt_type.to_string(),
|
||||
port,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
found
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -122,6 +122,7 @@ pub async fn upsert_mcp_server_in_config(
|
||||
new_server.apps.claude = true;
|
||||
new_server.apps.codex = true;
|
||||
new_server.apps.gemini = true;
|
||||
new_server.apps.opencode = true;
|
||||
}
|
||||
|
||||
McpService::upsert_server(&state, new_server)
|
||||
@@ -192,3 +193,14 @@ 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);
|
||||
total += McpService::import_from_opencode(&state).unwrap_or(0);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
+442
-15
@@ -1,7 +1,14 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use crate::init_status::InitErrorPayload;
|
||||
use crate::app_config::AppType;
|
||||
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
||||
use crate::services::ProviderService;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use tauri::AppHandle;
|
||||
use tauri::State;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -65,6 +72,13 @@ 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,
|
||||
@@ -78,15 +92,14 @@ 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())?;
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
for tool in tools {
|
||||
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
||||
let (local_version, local_error) = {
|
||||
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
|
||||
try_get_version_wsl(tool, &distro)
|
||||
} else {
|
||||
// 先尝试直接执行
|
||||
let direct_result = try_get_version(tool);
|
||||
|
||||
@@ -135,11 +148,14 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
|
||||
}
|
||||
}
|
||||
|
||||
/// 预编译的版本号正则表达式
|
||||
static VERSION_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
|
||||
|
||||
/// 从版本输出中提取纯版本号
|
||||
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)
|
||||
VERSION_RE
|
||||
.find(raw)
|
||||
.map(|m| m.as_str().to_string())
|
||||
.unwrap_or_else(|| raw.to_string())
|
||||
}
|
||||
@@ -171,7 +187,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
if out.status.success() {
|
||||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||
if raw.is_empty() {
|
||||
(None, Some("未安装或无法执行".to_string()))
|
||||
(None, Some("not installed or not executable".to_string()))
|
||||
} else {
|
||||
(Some(extract_version(raw)), None)
|
||||
}
|
||||
@@ -180,7 +196,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
(
|
||||
None,
|
||||
Some(if err.is_empty() {
|
||||
"未安装或无法执行".to_string()
|
||||
"not installed or not executable".to_string()
|
||||
} else {
|
||||
err
|
||||
}),
|
||||
@@ -191,6 +207,88 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验 WSL 发行版名称是否合法
|
||||
/// WSL 发行版名称只允许字母、数字、连字符和下划线
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_valid_wsl_distro_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
|
||||
use std::process::Command;
|
||||
|
||||
// 防御性断言:tool 只能是预定义的值
|
||||
debug_assert!(
|
||||
["claude", "codex", "gemini"].contains(&tool),
|
||||
"unexpected tool name: {tool}"
|
||||
);
|
||||
|
||||
// 校验 distro 名称,防止命令注入
|
||||
if !is_valid_wsl_distro_name(distro) {
|
||||
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
|
||||
}
|
||||
|
||||
let output = Command::new("wsl.exe")
|
||||
.args([
|
||||
"-d",
|
||||
distro,
|
||||
"--",
|
||||
"sh",
|
||||
"-lc",
|
||||
&format!("{tool} --version"),
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.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(format!("[WSL:{distro}] not installed or not executable")),
|
||||
)
|
||||
} else {
|
||||
(Some(extract_version(raw)), None)
|
||||
}
|
||||
} else {
|
||||
let err = if stderr.is_empty() { stdout } else { stderr };
|
||||
(
|
||||
None,
|
||||
Some(format!(
|
||||
"[WSL:{distro}] {}",
|
||||
if err.is_empty() {
|
||||
"not installed or not executable".to_string()
|
||||
} else {
|
||||
err
|
||||
}
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(e) => (None, Some(format!("[WSL:{distro}] exec failed: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 非 Windows 平台的 WSL 版本检测存根
|
||||
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
|
||||
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
|
||||
(
|
||||
None,
|
||||
Some("WSL check not supported on this platform".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
/// 扫描常见路径查找 CLI
|
||||
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
use std::process::Command;
|
||||
@@ -248,12 +346,18 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
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 = {
|
||||
Command::new(&tool_path)
|
||||
.arg("--version")
|
||||
// 使用 cmd /C 包装执行,确保子进程也在隐藏的控制台中运行
|
||||
Command::new("cmd")
|
||||
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
|
||||
.env("PATH", &new_path)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
@@ -280,5 +384,328 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
(None, Some("未安装或无法执行".to_string()))
|
||||
(None, Some("not installed or not executable".to_string()))
|
||||
}
|
||||
|
||||
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
||||
let override_dir = match tool {
|
||||
"claude" => crate::settings::get_claude_override_dir(),
|
||||
"codex" => crate::settings::get_codex_override_dir(),
|
||||
"gemini" => crate::settings::get_gemini_override_dir(),
|
||||
_ => None,
|
||||
}?;
|
||||
|
||||
wsl_distro_from_path(&override_dir)
|
||||
}
|
||||
|
||||
/// 从 UNC 路径中提取 WSL 发行版名称
|
||||
/// 支持 `\\wsl$\Ubuntu\...` 和 `\\wsl.localhost\Ubuntu\...` 两种格式
|
||||
#[cfg(target_os = "windows")]
|
||||
fn wsl_distro_from_path(path: &Path) -> Option<String> {
|
||||
use std::path::{Component, Prefix};
|
||||
let Some(Component::Prefix(prefix)) = path.components().next() else {
|
||||
return None;
|
||||
};
|
||||
match prefix.kind() {
|
||||
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
|
||||
let server_name = server.to_string_lossy();
|
||||
if server_name.eq_ignore_ascii_case("wsl$")
|
||||
|| server_name.eq_ignore_ascii_case("wsl.localhost")
|
||||
{
|
||||
let distro = share.to_string_lossy().to_string();
|
||||
if !distro.is_empty() {
|
||||
return Some(distro);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 非 Windows 平台不支持 WSL 路径解析
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn wsl_distro_from_path(_path: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 打开指定提供商的终端
|
||||
///
|
||||
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
|
||||
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
|
||||
#[allow(non_snake_case)]
|
||||
#[tauri::command]
|
||||
pub async fn open_provider_terminal(
|
||||
state: State<'_, crate::store::AppState>,
|
||||
app: String,
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
|
||||
// 获取提供商配置
|
||||
let providers = ProviderService::list(state.inner(), app_type.clone())
|
||||
.map_err(|e| format!("获取提供商列表失败: {e}"))?;
|
||||
|
||||
let provider = providers
|
||||
.get(&providerId)
|
||||
.ok_or_else(|| format!("提供商 {providerId} 不存在"))?;
|
||||
|
||||
// 从提供商配置中提取环境变量
|
||||
let config = &provider.settings_config;
|
||||
let env_vars = extract_env_vars_from_config(config, &app_type);
|
||||
|
||||
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
|
||||
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 从提供商配置中提取环境变量
|
||||
fn extract_env_vars_from_config(
|
||||
config: &serde_json::Value,
|
||||
app_type: &AppType,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut env_vars = Vec::new();
|
||||
|
||||
let Some(obj) = config.as_object() else {
|
||||
return env_vars;
|
||||
};
|
||||
|
||||
// 处理 env 字段(Claude/Gemini 通用)
|
||||
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
|
||||
for (key, value) in env {
|
||||
if let Some(str_val) = value.as_str() {
|
||||
env_vars.push((key.clone(), str_val.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 base_url: 根据应用类型添加对应的环境变量
|
||||
let base_url_key = match app_type {
|
||||
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
|
||||
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(key) = base_url_key {
|
||||
if let Some(url_str) = env.get(key).and_then(|v| v.as_str()) {
|
||||
env_vars.push((key.to_string(), url_str.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Codex 使用 auth 字段转换为 OPENAI_API_KEY
|
||||
if *app_type == AppType::Codex {
|
||||
if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) {
|
||||
env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini 使用 api_key 字段转换为 GEMINI_API_KEY
|
||||
if *app_type == AppType::Gemini {
|
||||
if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) {
|
||||
env_vars.push(("GEMINI_API_KEY".to_string(), api_key.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
env_vars
|
||||
}
|
||||
|
||||
/// 创建临时配置文件并启动 claude 终端
|
||||
/// 使用 --settings 参数传入提供商特定的 API 配置
|
||||
fn launch_terminal_with_env(
|
||||
env_vars: Vec<(String, String)>,
|
||||
provider_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let config_file = temp_dir.join(format!(
|
||||
"claude_{}_{}.json",
|
||||
provider_id,
|
||||
std::process::id()
|
||||
));
|
||||
|
||||
// 创建并写入配置文件
|
||||
write_claude_config(&config_file, &env_vars)?;
|
||||
|
||||
// 转义配置文件路径用于 shell
|
||||
let config_path_escaped = escape_shell_path(&config_file);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
launch_macos_terminal(&config_file, &config_path_escaped)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
launch_linux_terminal(&config_file, &config_path_escaped)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
launch_windows_terminal(&temp_dir, &config_file)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
Err("不支持的操作系统".to_string())
|
||||
}
|
||||
|
||||
/// 写入 claude 配置文件
|
||||
fn write_claude_config(
|
||||
config_file: &std::path::Path,
|
||||
env_vars: &[(String, String)],
|
||||
) -> Result<(), String> {
|
||||
let mut config_obj = serde_json::Map::new();
|
||||
let mut env_obj = serde_json::Map::new();
|
||||
|
||||
for (key, value) in env_vars {
|
||||
env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));
|
||||
}
|
||||
|
||||
config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
|
||||
|
||||
let config_json =
|
||||
serde_json::to_string_pretty(&config_obj).map_err(|e| format!("序列化配置失败: {e}"))?;
|
||||
|
||||
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
|
||||
}
|
||||
|
||||
/// 转义 shell 路径
|
||||
fn escape_shell_path(path: &std::path::Path) -> String {
|
||||
path.to_string_lossy()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('$', "\\$")
|
||||
.replace(' ', "\\ ")
|
||||
}
|
||||
|
||||
/// 生成 bash 包装脚本,用于清理临时文件
|
||||
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
|
||||
format!(
|
||||
"bash -c 'trap \"rm -f \\\"{config_path}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{escaped_path}\"; claude --settings \"{escaped_path}\"; exec bash --norc --noprofile'"
|
||||
)
|
||||
}
|
||||
|
||||
/// macOS: 使用 Terminal.app 启动
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_terminal(
|
||||
config_file: &std::path::Path,
|
||||
config_path_escaped: &str,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let config_path_for_script = config_file.to_string_lossy().replace('\"', "\\\"");
|
||||
|
||||
let shell_script = generate_wrapper_script(&config_path_for_script, config_path_escaped);
|
||||
|
||||
let script = format!(
|
||||
r#"tell application "Terminal"
|
||||
activate
|
||||
do script "{}"
|
||||
end tell"#,
|
||||
shell_script.replace('\"', "\\\"")
|
||||
);
|
||||
|
||||
Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(&script)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Linux: 尝试使用常见终端启动
|
||||
#[cfg(target_os = "linux")]
|
||||
fn launch_linux_terminal(
|
||||
config_file: &std::path::Path,
|
||||
config_path_escaped: &str,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let terminals = [
|
||||
"gnome-terminal",
|
||||
"konsole",
|
||||
"xfce4-terminal",
|
||||
"mate-terminal",
|
||||
"lxterminal",
|
||||
"alacritty",
|
||||
"kitty",
|
||||
];
|
||||
|
||||
let config_path_for_bash = config_file.to_string_lossy();
|
||||
let shell_cmd = generate_wrapper_script(&config_path_for_bash, config_path_escaped);
|
||||
|
||||
let mut last_error = String::from("未找到可用的终端");
|
||||
|
||||
for terminal in terminals {
|
||||
// 检查终端是否存在
|
||||
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|
||||
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
|
||||
{
|
||||
let result = match terminal {
|
||||
"gnome-terminal" | "mate-terminal" => Command::new(terminal)
|
||||
.arg("--")
|
||||
.arg("bash")
|
||||
.arg("-c")
|
||||
.arg(&shell_cmd)
|
||||
.spawn(),
|
||||
_ => Command::new(terminal)
|
||||
.arg("-e")
|
||||
.arg("bash")
|
||||
.arg("-c")
|
||||
.arg(&shell_cmd)
|
||||
.spawn(),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => {
|
||||
last_error = format!("启动 {} 失败: {}", terminal, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理配置文件
|
||||
let _ = std::fs::remove_file(config_file);
|
||||
Err(last_error)
|
||||
}
|
||||
|
||||
/// Windows: 创建临时批处理文件启动
|
||||
#[cfg(target_os = "windows")]
|
||||
fn launch_windows_terminal(
|
||||
temp_dir: &std::path::Path,
|
||||
config_file: &std::path::Path,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
|
||||
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
|
||||
|
||||
let content = format!(
|
||||
"@echo off
|
||||
echo Using provider-specific claude config:
|
||||
echo {}
|
||||
claude --settings \"{}\"
|
||||
del \"{}\" >nul 2>&1
|
||||
del \"%~f0\" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo Press any key to close...
|
||||
pause >nul
|
||||
)",
|
||||
config_path_for_batch, config_path_for_batch, config_path_for_batch
|
||||
);
|
||||
|
||||
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy()])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ mod config;
|
||||
mod deeplink;
|
||||
mod env;
|
||||
mod failover;
|
||||
mod global_proxy;
|
||||
mod import_export;
|
||||
mod mcp;
|
||||
mod misc;
|
||||
@@ -20,6 +21,7 @@ pub use config::*;
|
||||
pub use deeplink::*;
|
||||
pub use env::*;
|
||||
pub use failover::*;
|
||||
pub use global_proxy::*;
|
||||
pub use import_export::*;
|
||||
pub use mcp::*;
|
||||
pub use misc::*;
|
||||
|
||||
@@ -60,6 +60,16 @@ pub fn delete_provider(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Remove provider from live config only (for additive mode apps like OpenCode)
|
||||
/// Does NOT delete from database - provider remains in the list
|
||||
#[tauri::command]
|
||||
pub fn remove_provider_from_live_config(app: String, id: String) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
ProviderService::remove_from_live_config(app_type, &id)
|
||||
.map(|_| true)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 切换供应商
|
||||
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
ProviderService::switch(state, app_type, id)
|
||||
@@ -133,6 +143,7 @@ pub async fn testUsageScript(
|
||||
#[allow(non_snake_case)] baseUrl: Option<String>,
|
||||
#[allow(non_snake_case)] accessToken: Option<String>,
|
||||
#[allow(non_snake_case)] userId: Option<String>,
|
||||
#[allow(non_snake_case)] templateType: Option<String>,
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
ProviderService::test_usage_script(
|
||||
@@ -145,6 +156,7 @@ pub async fn testUsageScript(
|
||||
baseUrl.as_deref(),
|
||||
accessToken.as_deref(),
|
||||
userId.as_deref(),
|
||||
templateType.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
@@ -323,3 +335,27 @@ pub fn sync_universal_provider(
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OpenCode 专属命令
|
||||
// ============================================================================
|
||||
|
||||
/// 从 OpenCode live 配置导入供应商到数据库
|
||||
///
|
||||
/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式,
|
||||
/// 用户可能已经在 opencode.json 中配置了供应商。
|
||||
#[tauri::command]
|
||||
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
|
||||
crate::services::provider::import_opencode_providers_from_live(state.inner())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取 OpenCode live 配置中的供应商 ID 列表
|
||||
///
|
||||
/// 用于前端判断供应商是否已添加到 opencode.json
|
||||
#[tauri::command]
|
||||
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
|
||||
crate::opencode_config::get_providers()
|
||||
.map(|providers| providers.keys().cloned().collect())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -184,17 +184,16 @@ pub async fn reset_circuit_breaker(
|
||||
.await?;
|
||||
|
||||
// 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取)
|
||||
let auto_failover_enabled = match db.get_proxy_config_for_app(&app_type).await {
|
||||
Ok(config) => config.auto_failover_enabled,
|
||||
// 只有当该应用已被代理接管(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 for auto_failover_enabled: {e}, defaulting to disabled"
|
||||
);
|
||||
false
|
||||
log::error!("[{app_type}] Failed to read proxy_config: {e}, defaulting to disabled");
|
||||
(false, false)
|
||||
}
|
||||
};
|
||||
|
||||
if auto_failover_enabled && state.proxy_service.is_running().await {
|
||||
if app_enabled && auto_failover_enabled && state.proxy_service.is_running().await {
|
||||
// 获取当前供应商 ID
|
||||
let current_id = db
|
||||
.get_current_provider(&app_type)
|
||||
|
||||
@@ -59,3 +59,24 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
|
||||
pub async fn get_auto_launch_status() -> Result<bool, String> {
|
||||
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
|
||||
}
|
||||
|
||||
/// 获取整流器配置
|
||||
#[tauri::command]
|
||||
pub async fn get_rectifier_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> Result<crate::proxy::types::RectifierConfig, String> {
|
||||
state.db.get_rectifier_config().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置整流器配置
|
||||
#[tauri::command]
|
||||
pub async fn set_rectifier_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
config: crate::proxy::types::RectifierConfig,
|
||||
) -> Result<bool, String> {
|
||||
state
|
||||
.db
|
||||
.set_rectifier_config(&config)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
+143
-127
@@ -1,12 +1,17 @@
|
||||
use crate::app_config::AppType;
|
||||
//! 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
|
||||
@@ -15,69 +20,122 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
|
||||
"claude" => Ok(AppType::Claude),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
_ => Err(format!("不支持的 app 类型: {app}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 app_type 生成带前缀的 skill key
|
||||
fn get_skill_key(app_type: &AppType, directory: &str) -> String {
|
||||
let prefix = match app_type {
|
||||
AppType::Claude => "claude",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
};
|
||||
format!("{prefix}:{directory}")
|
||||
// ========== 统一管理命令 ==========
|
||||
|
||||
/// 获取所有已安装的 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> {
|
||||
get_skills_for_app("claude".to_string(), service, app_state).await
|
||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||
service
|
||||
.0
|
||||
.list_skills(repos, &app_state.db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取指定应用的技能列表(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub async fn get_skills_for_app(
|
||||
app: String,
|
||||
_service: State<'_, SkillServiceState>,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<Skill>, String> {
|
||||
let app_type = parse_app_type(&app)?;
|
||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
||||
|
||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||
|
||||
let skills = service
|
||||
.list_skills(repos)
|
||||
.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 {
|
||||
let key = get_skill_key(&app_type, &skill.directory);
|
||||
if !existing_states.contains_key(&key) {
|
||||
// 本地有该 skill,但数据库中没有记录,自动添加
|
||||
if let Err(e) = app_state.db.update_skill_state(
|
||||
&key,
|
||||
&SkillState {
|
||||
installed: true,
|
||||
installed_at: Utc::now(),
|
||||
},
|
||||
) {
|
||||
log::warn!("同步本地 skill {key} 状态到数据库失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(skills)
|
||||
// 新版本不再区分应用,统一返回所有技能
|
||||
let _ = parse_app_type(&app)?; // 验证 app 参数有效
|
||||
get_skills(service, app_state).await
|
||||
}
|
||||
|
||||
/// 安装技能(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub async fn install_skill(
|
||||
directory: String,
|
||||
@@ -87,27 +145,34 @@ pub async fn install_skill(
|
||||
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>,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = parse_app_type(&app)?;
|
||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
||||
|
||||
// 先在不持有写锁的情况下收集仓库与技能信息
|
||||
// 先获取技能信息
|
||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||
|
||||
let skills = service
|
||||
.list_skills(repos)
|
||||
.0
|
||||
.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",
|
||||
@@ -116,103 +181,54 @@ pub async fn install_skill_for_app(
|
||||
)
|
||||
})?;
|
||||
|
||||
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
|
||||
.install_skill(directory.clone(), repo)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let key = get_skill_key(&app_type, &directory);
|
||||
app_state
|
||||
.db
|
||||
.update_skill_state(
|
||||
&key,
|
||||
&SkillState {
|
||||
installed: true,
|
||||
installed_at: Utc::now(),
|
||||
},
|
||||
)
|
||||
service
|
||||
.0
|
||||
.install(&app_state.db, &skill, &app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 卸载技能(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill(
|
||||
directory: String,
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
uninstall_skill_for_app("claude".to_string(), directory, service, app_state)
|
||||
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,
|
||||
_service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = parse_app_type(&app)?;
|
||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
||||
let _ = parse_app_type(&app)?; // 验证参数
|
||||
|
||||
service
|
||||
.uninstall_skill(directory.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
// 通过 directory 找到对应的 skill id
|
||||
let skills = SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())?;
|
||||
|
||||
// Remove from database by setting installed = false
|
||||
let key = get_skill_key(&app_type, &directory);
|
||||
app_state
|
||||
.db
|
||||
.update_skill_state(
|
||||
&key,
|
||||
&SkillState {
|
||||
installed: false,
|
||||
installed_at: Utc::now(),
|
||||
},
|
||||
)
|
||||
.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(
|
||||
_service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<SkillRepo>, String> {
|
||||
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)
|
||||
@@ -220,11 +236,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
|
||||
|
||||
@@ -19,9 +19,10 @@ pub fn get_usage_summary(
|
||||
#[tauri::command]
|
||||
pub fn get_usage_trends(
|
||||
state: State<'_, AppState>,
|
||||
days: u32,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
) -> Result<Vec<DailyStats>, AppError> {
|
||||
state.db.get_daily_trends(days)
|
||||
state.db.get_daily_trends(start_date, end_date)
|
||||
}
|
||||
|
||||
/// 获取 Provider 统计
|
||||
|
||||
+10
-6
@@ -5,22 +5,26 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
fn get_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 Claude Code 配置目录路径
|
||||
pub fn get_claude_config_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
||||
return custom;
|
||||
}
|
||||
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".claude")
|
||||
get_home_dir().join(".claude")
|
||||
}
|
||||
|
||||
/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
|
||||
pub fn get_default_claude_mcp_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".claude.json")
|
||||
get_home_dir().join(".claude.json")
|
||||
}
|
||||
|
||||
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
|
||||
|
||||
@@ -13,7 +13,7 @@ impl Database {
|
||||
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
|
||||
FROM mcp_servers
|
||||
ORDER BY name ASC, id ASC"
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -30,6 +30,7 @@ impl Database {
|
||||
let enabled_claude: bool = row.get(7)?;
|
||||
let enabled_codex: bool = row.get(8)?;
|
||||
let enabled_gemini: bool = row.get(9)?;
|
||||
let enabled_opencode: bool = row.get(10)?;
|
||||
|
||||
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
||||
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
@@ -44,6 +45,7 @@ impl Database {
|
||||
claude: enabled_claude,
|
||||
codex: enabled_codex,
|
||||
gemini: enabled_gemini,
|
||||
opencode: enabled_opencode,
|
||||
},
|
||||
description,
|
||||
homepage,
|
||||
@@ -68,19 +70,23 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO mcp_servers (
|
||||
id, name, server_config, description, homepage, docs, tags,
|
||||
enabled_claude, enabled_codex, enabled_gemini
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
server.id,
|
||||
server.name,
|
||||
serde_json::to_string(&server.server).unwrap(),
|
||||
serde_json::to_string(&server.server).map_err(|e| AppError::Database(format!(
|
||||
"Failed to serialize server config: {e}"
|
||||
)))?,
|
||||
server.description,
|
||||
server.homepage,
|
||||
server.docs,
|
||||
serde_json::to_string(&server.tags).unwrap(),
|
||||
serde_json::to_string(&server.tags)
|
||||
.map_err(|e| AppError::Database(format!("Failed to serialize tags: {e}")))?,
|
||||
server.apps.claude,
|
||||
server.apps.codex,
|
||||
server.apps.gemini,
|
||||
server.apps.opencode,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
@@ -220,7 +220,9 @@ impl Database {
|
||||
WHERE id = ?13 AND app_type = ?14",
|
||||
params![
|
||||
provider.name,
|
||||
serde_json::to_string(&provider.settings_config).unwrap(),
|
||||
serde_json::to_string(&provider.settings_config).map_err(|e| {
|
||||
AppError::Database(format!("Failed to serialize settings_config: {e}"))
|
||||
})?,
|
||||
provider.website_url,
|
||||
provider.category,
|
||||
provider.created_at,
|
||||
@@ -228,7 +230,9 @@ impl Database {
|
||||
provider.notes,
|
||||
provider.icon,
|
||||
provider.icon_color,
|
||||
serde_json::to_string(&meta_clone).unwrap(),
|
||||
serde_json::to_string(&meta_clone).map_err(|e| AppError::Database(format!(
|
||||
"Failed to serialize meta: {e}"
|
||||
)))?,
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
provider.id,
|
||||
@@ -247,7 +251,8 @@ impl Database {
|
||||
provider.id,
|
||||
app_type,
|
||||
provider.name,
|
||||
serde_json::to_string(&provider.settings_config).unwrap(),
|
||||
serde_json::to_string(&provider.settings_config)
|
||||
.map_err(|e| AppError::Database(format!("Failed to serialize settings_config: {e}")))?,
|
||||
provider.website_url,
|
||||
provider.category,
|
||||
provider.created_at,
|
||||
@@ -255,7 +260,8 @@ impl Database {
|
||||
provider.notes,
|
||||
provider.icon,
|
||||
provider.icon_color,
|
||||
serde_json::to_string(&meta_clone).unwrap(),
|
||||
serde_json::to_string(&meta_clone)
|
||||
.map_err(|e| AppError::Database(format!("Failed to serialize meta: {e}")))?,
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
],
|
||||
@@ -324,7 +330,9 @@ impl Database {
|
||||
conn.execute(
|
||||
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||
params![
|
||||
serde_json::to_string(settings_config).unwrap(),
|
||||
serde_json::to_string(settings_config).map_err(|e| AppError::Database(format!(
|
||||
"Failed to serialize settings_config: {e}"
|
||||
)))?,
|
||||
provider_id,
|
||||
app_type
|
||||
],
|
||||
|
||||
@@ -41,7 +41,7 @@ impl Database {
|
||||
Ok(GlobalProxyConfig {
|
||||
proxy_enabled: false,
|
||||
listen_address: "127.0.0.1".to_string(),
|
||||
listen_port: 5000,
|
||||
listen_port: 15721,
|
||||
enable_logging: true,
|
||||
})
|
||||
}
|
||||
@@ -121,13 +121,13 @@ impl Database {
|
||||
enabled: false,
|
||||
auto_failover_enabled: false,
|
||||
max_retries: 3,
|
||||
streaming_first_byte_timeout: 30,
|
||||
streaming_idle_timeout: 60,
|
||||
non_streaming_timeout: 300,
|
||||
circuit_failure_threshold: 5,
|
||||
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.5,
|
||||
circuit_error_rate_threshold: 0.6,
|
||||
circuit_min_requests: 10,
|
||||
})
|
||||
}
|
||||
@@ -210,12 +210,12 @@ impl Database {
|
||||
listen_address: row.get(0)?,
|
||||
listen_port: row.get::<_, i32>(1)? as u16,
|
||||
max_retries: row.get::<_, i32>(2)? as u8,
|
||||
request_timeout: 300, // 废弃字段,返回默认值
|
||||
request_timeout: 600, // 废弃字段,返回默认值
|
||||
enable_logging: row.get::<_, i32>(3)? != 0,
|
||||
live_takeover_active: false, // 废弃字段
|
||||
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(30) as u64,
|
||||
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(60) as u64,
|
||||
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(300) as u64,
|
||||
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,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -63,6 +63,41 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 全局出站代理 ---
|
||||
|
||||
/// 全局代理 URL 的存储键名
|
||||
const GLOBAL_PROXY_URL_KEY: &'static str = "global_proxy_url";
|
||||
|
||||
/// 获取全局出站代理 URL
|
||||
///
|
||||
/// 返回 None 表示未配置或已清除代理(直连)
|
||||
/// 返回 Some(url) 表示已配置代理
|
||||
pub fn get_global_proxy_url(&self) -> Result<Option<String>, AppError> {
|
||||
self.get_setting(Self::GLOBAL_PROXY_URL_KEY)
|
||||
}
|
||||
|
||||
/// 设置全局出站代理 URL
|
||||
///
|
||||
/// - 传入非空字符串:启用代理
|
||||
/// - 传入空字符串或 None:清除代理设置(直连)
|
||||
pub fn set_global_proxy_url(&self, url: Option<&str>) -> Result<(), AppError> {
|
||||
match url {
|
||||
Some(u) if !u.trim().is_empty() => {
|
||||
self.set_setting(Self::GLOBAL_PROXY_URL_KEY, u.trim())
|
||||
}
|
||||
_ => {
|
||||
// 清除代理设置
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"DELETE FROM settings WHERE key = ?1",
|
||||
params![Self::GLOBAL_PROXY_URL_KEY],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
|
||||
|
||||
/// 获取指定应用的代理接管状态
|
||||
@@ -128,4 +163,27 @@ impl Database {
|
||||
log::info!("已清除所有代理接管状态");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- 整流器配置 ---
|
||||
|
||||
/// 获取整流器配置
|
||||
///
|
||||
/// 返回整流器配置,如果不存在则返回默认值(全部启用)
|
||||
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
|
||||
match self.get_setting("rectifier_config")? {
|
||||
Some(json) => serde_json::from_str(&json)
|
||||
.map_err(|e| AppError::Database(format!("解析整流器配置失败: {e}"))),
|
||||
None => Ok(crate::proxy::types::RectifierConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新整流器配置
|
||||
pub fn set_rectifier_config(
|
||||
&self,
|
||||
config: &crate::proxy::types::RectifierConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let json = serde_json::to_string(config)
|
||||
.map_err(|e| AppError::Database(format!("序列化整流器配置失败: {e}")))?;
|
||||
self.set_setting("rectifier_config", &json)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,159 @@
|
||||
//! 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 directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC")
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
|
||||
FROM skills ORDER BY name ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let skill_iter = stmt
|
||||
.query_map([], |row| {
|
||||
let directory: String = row.get(0)?;
|
||||
let app_type: String = row.get(1)?;
|
||||
let installed: bool = row.get(2)?;
|
||||
let installed_at_ts: i64 = row.get(3)?;
|
||||
|
||||
let installed_at =
|
||||
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
|
||||
|
||||
// 构建复合 key:"app_type:directory"
|
||||
let key = format!("{app_type}:{directory}");
|
||||
|
||||
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)?,
|
||||
opencode: row.get(11)?,
|
||||
},
|
||||
))
|
||||
installed_at: row.get(12)?,
|
||||
})
|
||||
})
|
||||
.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 状态
|
||||
/// key 格式为 "app_type:directory"
|
||||
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
|
||||
// 解析 key
|
||||
let (app_type, directory) = if let Some(idx) = key.find(':') {
|
||||
let (app, dir) = key.split_at(idx);
|
||||
(app, &dir[1..]) // 跳过冒号
|
||||
} else {
|
||||
// 向后兼容:如果没有前缀,默认为 claude
|
||||
("claude", key)
|
||||
};
|
||||
/// 获取单个已安装的 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, enabled_opencode, 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)?,
|
||||
opencode: row.get(11)?,
|
||||
},
|
||||
installed_at: row.get(12)?,
|
||||
})
|
||||
});
|
||||
|
||||
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 (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![directory, app_type, 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, enabled_opencode, installed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
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.apps.opencode,
|
||||
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, enabled_opencode = ?4 WHERE id = ?5",
|
||||
params![apps.claude, apps.codex, apps.gemini, apps.opencode, 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);
|
||||
@@ -101,7 +187,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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 2;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 4;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -58,7 +58,7 @@ impl Database {
|
||||
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
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
)
|
||||
@@ -71,11 +71,22 @@ impl Database {
|
||||
PRIMARY KEY (id, app_type)
|
||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 5. Skills 表
|
||||
// 5. Skills 表(v3.10.0+ 统一结构)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS skills (
|
||||
directory TEXT NOT NULL, app_type TEXT NOT NULL, installed BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (directory, app_type)
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
directory TEXT NOT NULL,
|
||||
repo_owner TEXT,
|
||||
repo_name TEXT,
|
||||
repo_branch TEXT DEFAULT 'main',
|
||||
readme_url TEXT,
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
)
|
||||
@@ -102,12 +113,12 @@ impl Database {
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
|
||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
|
||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -123,7 +134,7 @@ impl Database {
|
||||
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)
|
||||
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
|
||||
VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -132,7 +143,7 @@ impl Database {
|
||||
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)
|
||||
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||
VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -141,7 +152,7 @@ impl Database {
|
||||
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)
|
||||
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||
VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -243,7 +254,7 @@ impl Database {
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 5000",
|
||||
"ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 15721",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
@@ -253,15 +264,15 @@ impl Database {
|
||||
|
||||
// 尝试添加超时配置列到 proxy_config 表
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 60",
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 120",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 300",
|
||||
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 600",
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -331,6 +342,16 @@ impl Database {
|
||||
Self::migrate_v1_to_v2(conn)?;
|
||||
Self::set_user_version(conn, 2)?;
|
||||
}
|
||||
2 => {
|
||||
log::info!("迁移数据库从 v2 到 v3(Skills 统一管理架构)");
|
||||
Self::migrate_v2_to_v3(conn)?;
|
||||
Self::set_user_version(conn, 3)?;
|
||||
}
|
||||
3 => {
|
||||
log::info!("迁移数据库从 v3 到 v4(OpenCode 支持)");
|
||||
Self::migrate_v3_to_v4(conn)?;
|
||||
Self::set_user_version(conn, 4)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -454,7 +475,7 @@ impl Database {
|
||||
conn,
|
||||
"proxy_config",
|
||||
"listen_port",
|
||||
"INTEGER NOT NULL DEFAULT 5000",
|
||||
"INTEGER NOT NULL DEFAULT 15721",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
@@ -467,19 +488,19 @@ impl Database {
|
||||
conn,
|
||||
"proxy_config",
|
||||
"streaming_first_byte_timeout",
|
||||
"INTEGER NOT NULL DEFAULT 30",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"streaming_idle_timeout",
|
||||
"INTEGER NOT NULL DEFAULT 60",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"streaming_idle_timeout",
|
||||
"INTEGER NOT NULL DEFAULT 120",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"non_streaming_timeout",
|
||||
"INTEGER NOT NULL DEFAULT 300",
|
||||
"INTEGER NOT NULL DEFAULT 600",
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -649,12 +670,12 @@ impl Database {
|
||||
conn.execute("CREATE TABLE proxy_config_new (
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
|
||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
|
||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)", [])?;
|
||||
@@ -689,6 +710,17 @@ impl Database {
|
||||
|
||||
/// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键
|
||||
fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> {
|
||||
// v3 结构(统一管理架构)已经是更高版本的 skills 表:
|
||||
// - 主键为 id
|
||||
// - 包含 enabled_claude / enabled_codex / enabled_gemini 等列
|
||||
// 在这种情况下,不应再执行 v1 -> v2 的迁移逻辑,否则会因列不匹配而失败。
|
||||
if Self::has_column(conn, "skills", "enabled_claude")?
|
||||
|| Self::has_column(conn, "skills", "id")?
|
||||
{
|
||||
log::info!("skills 表已经是 v3 结构,跳过 v1 -> v2 迁移");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 检查是否已经是新表结构
|
||||
if Self::has_column(conn, "skills", "app_type")? {
|
||||
log::info!("skills 表已经包含 app_type 字段,跳过迁移");
|
||||
@@ -760,14 +792,101 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v2 -> v3 迁移:Skills 统一管理架构
|
||||
///
|
||||
/// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构,
|
||||
/// 支持三应用启用标志(enabled_claude, enabled_codex, enabled_gemini)。
|
||||
///
|
||||
/// 迁移策略:
|
||||
/// 1. 旧数据库只存储安装记录,真正的 skill 文件在文件系统
|
||||
/// 2. 直接重建新表结构,后续由 SkillService 在首次启动时扫描文件系统重建数据
|
||||
fn migrate_v2_to_v3(conn: &Connection) -> Result<(), AppError> {
|
||||
// 检查是否已经是新结构(通过检查是否有 enabled_claude 列)
|
||||
if Self::has_column(conn, "skills", "enabled_claude")? {
|
||||
log::info!("skills 表已经是 v3 结构,跳过迁移");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)...");
|
||||
|
||||
// 1. 备份旧数据(用于日志)
|
||||
let old_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
log::info!("旧 skills 表有 {old_count} 条记录");
|
||||
|
||||
// 标记:需要在启动后从文件系统扫描并重建 Skills 数据
|
||||
// 说明:v3 结构将 Skills 的 SSOT 迁移到 ~/.cc-switch/skills/,
|
||||
// 旧表只存“安装记录”,无法直接无损迁移到新结构,因此改为启动后扫描 app 目录导入。
|
||||
let _ = conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')",
|
||||
[],
|
||||
);
|
||||
|
||||
// 2. 删除旧表
|
||||
conn.execute("DROP TABLE IF EXISTS skills", [])
|
||||
.map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
|
||||
|
||||
// 3. 创建新表
|
||||
conn.execute(
|
||||
"CREATE TABLE skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
directory TEXT NOT NULL,
|
||||
repo_owner TEXT,
|
||||
repo_name TEXT,
|
||||
repo_branch TEXT DEFAULT 'main',
|
||||
readme_url TEXT,
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
|
||||
|
||||
log::info!(
|
||||
"skills 表已迁移到 v3 结构。\n\
|
||||
注意:旧的安装记录已清除,首次启动时将自动扫描文件系统重建数据。"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v3 -> v4 迁移:添加 OpenCode 支持
|
||||
///
|
||||
/// 为 mcp_servers 和 skills 表添加 enabled_opencode 列。
|
||||
fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {
|
||||
// 为 mcp_servers 表添加 enabled_opencode 列
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"mcp_servers",
|
||||
"enabled_opencode",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
// 为 skills 表添加 enabled_opencode 列
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"skills",
|
||||
"enabled_opencode",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
log::info!("v3 -> v4 迁移完成:已添加 OpenCode 支持");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||
let pricing_data = [
|
||||
// Claude 4.5 系列
|
||||
// Claude 4.5 系列 (Latest Models)
|
||||
(
|
||||
"claude-opus-4-5",
|
||||
"claude-opus-4-5-20251101",
|
||||
"Claude Opus 4.5",
|
||||
"5",
|
||||
"25",
|
||||
@@ -775,7 +894,7 @@ impl Database {
|
||||
"6.25",
|
||||
),
|
||||
(
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"Claude Sonnet 4.5",
|
||||
"3",
|
||||
"15",
|
||||
@@ -783,16 +902,24 @@ impl Database {
|
||||
"3.75",
|
||||
),
|
||||
(
|
||||
"claude-haiku-4-5",
|
||||
"claude-haiku-4-5-20251001",
|
||||
"Claude Haiku 4.5",
|
||||
"1",
|
||||
"5",
|
||||
"0.10",
|
||||
"1.25",
|
||||
),
|
||||
// Claude 4.1 系列
|
||||
// Claude 4 系列 (Legacy Models)
|
||||
(
|
||||
"claude-opus-4-1",
|
||||
"claude-opus-4-20250514",
|
||||
"Claude Opus 4",
|
||||
"15",
|
||||
"75",
|
||||
"1.50",
|
||||
"18.75",
|
||||
),
|
||||
(
|
||||
"claude-opus-4-1-20250805",
|
||||
"Claude Opus 4.1",
|
||||
"15",
|
||||
"75",
|
||||
@@ -800,17 +927,8 @@ impl Database {
|
||||
"18.75",
|
||||
),
|
||||
(
|
||||
"claude-sonnet-4-1",
|
||||
"Claude Sonnet 4.1",
|
||||
"3",
|
||||
"15",
|
||||
"0.30",
|
||||
"3.75",
|
||||
),
|
||||
// Claude 3.7 系列
|
||||
(
|
||||
"claude-sonnet-3-7",
|
||||
"Claude Sonnet 3.7",
|
||||
"claude-sonnet-4-20250514",
|
||||
"Claude Sonnet 4",
|
||||
"3",
|
||||
"15",
|
||||
"0.30",
|
||||
@@ -818,38 +936,167 @@ impl Database {
|
||||
),
|
||||
// Claude 3.5 系列
|
||||
(
|
||||
"claude-sonnet-3-5",
|
||||
"Claude Sonnet 3.5",
|
||||
"3",
|
||||
"15",
|
||||
"0.30",
|
||||
"3.75",
|
||||
),
|
||||
(
|
||||
"claude-haiku-3-5",
|
||||
"Claude Haiku 3.5",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"Claude 3.5 Haiku",
|
||||
"0.80",
|
||||
"4",
|
||||
"0.08",
|
||||
"1",
|
||||
),
|
||||
// GPT-5 系列(model_id 使用短横线格式)
|
||||
(
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"Claude 3.5 Sonnet",
|
||||
"3",
|
||||
"15",
|
||||
"0.30",
|
||||
"3.75",
|
||||
),
|
||||
// GPT-5.2 系列
|
||||
("gpt-5.2", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||||
("gpt-5.2-low", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||||
("gpt-5.2-medium", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||||
("gpt-5.2-high", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||||
("gpt-5.2-xhigh", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||||
("gpt-5.2-codex", "GPT-5.2 Codex", "1.75", "14", "0.175", "0"),
|
||||
(
|
||||
"gpt-5.2-codex-low",
|
||||
"GPT-5.2 Codex",
|
||||
"1.75",
|
||||
"14",
|
||||
"0.175",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5.2-codex-medium",
|
||||
"GPT-5.2 Codex",
|
||||
"1.75",
|
||||
"14",
|
||||
"0.175",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5.2-codex-high",
|
||||
"GPT-5.2 Codex",
|
||||
"1.75",
|
||||
"14",
|
||||
"0.175",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5.2-codex-xhigh",
|
||||
"GPT-5.2 Codex",
|
||||
"1.75",
|
||||
"14",
|
||||
"0.175",
|
||||
"0",
|
||||
),
|
||||
// GPT-5.1 系列
|
||||
("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5.1-medium", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5.1-high", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5.1-minimal", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5.1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
|
||||
(
|
||||
"gpt-5.1-codex-mini",
|
||||
"GPT-5.1 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5.1-codex-max",
|
||||
"GPT-5.1 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5.1-codex-max-high",
|
||||
"GPT-5.1 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5.1-codex-max-xhigh",
|
||||
"GPT-5.1 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
// GPT-5 系列
|
||||
("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-1", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-low", "GPT-5", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-medium", "GPT-5", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-high", "GPT-5", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-minimal", "GPT-5", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-codex-low", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
|
||||
(
|
||||
"gpt-5-codex-medium",
|
||||
"GPT-5 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5-codex-high",
|
||||
"GPT-5 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5-codex-mini",
|
||||
"GPT-5 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5-codex-mini-medium",
|
||||
"GPT-5 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5-codex-mini-high",
|
||||
"GPT-5 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
// Gemini 3 系列
|
||||
(
|
||||
"gemini-3-pro-preview",
|
||||
"Gemini 3 Pro Preview",
|
||||
"2",
|
||||
"12",
|
||||
"0",
|
||||
"0.2",
|
||||
"0",
|
||||
),
|
||||
// Gemini 2.5 系列(model_id 使用短横线格式)
|
||||
(
|
||||
"gemini-2-5-pro",
|
||||
"gemini-3-flash-preview",
|
||||
"Gemini 3 Flash Preview",
|
||||
"0.5",
|
||||
"3",
|
||||
"0.05",
|
||||
"0",
|
||||
),
|
||||
// Gemini 2.5 系列
|
||||
(
|
||||
"gemini-2.5-pro",
|
||||
"Gemini 2.5 Pro",
|
||||
"1.25",
|
||||
"10",
|
||||
@@ -857,13 +1104,75 @@ impl Database {
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gemini-2-5-flash",
|
||||
"gemini-2.5-flash",
|
||||
"Gemini 2.5 Flash",
|
||||
"0.3",
|
||||
"2.5",
|
||||
"0.03",
|
||||
"0",
|
||||
),
|
||||
// ====== 国产模型 (CNY/1M tokens) ======
|
||||
// Doubao (字节跳动)
|
||||
(
|
||||
"doubao-seed-code",
|
||||
"Doubao Seed Code",
|
||||
"1.20",
|
||||
"8.00",
|
||||
"0.24",
|
||||
"0",
|
||||
),
|
||||
// DeepSeek 系列
|
||||
(
|
||||
"deepseek-v3.2",
|
||||
"DeepSeek V3.2",
|
||||
"2.00",
|
||||
"3.00",
|
||||
"0.40",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"deepseek-v3.1",
|
||||
"DeepSeek V3.1",
|
||||
"4.00",
|
||||
"12.00",
|
||||
"0.80",
|
||||
"0",
|
||||
),
|
||||
("deepseek-v3", "DeepSeek V3", "2.00", "8.00", "0.40", "0"),
|
||||
// Kimi (月之暗面)
|
||||
(
|
||||
"kimi-k2-thinking",
|
||||
"Kimi K2 Thinking",
|
||||
"4.00",
|
||||
"16.00",
|
||||
"1.00",
|
||||
"0",
|
||||
),
|
||||
("kimi-k2-0905", "Kimi K2", "4.00", "16.00", "1.00", "0"),
|
||||
(
|
||||
"kimi-k2-turbo",
|
||||
"Kimi K2 Turbo",
|
||||
"8.00",
|
||||
"58.00",
|
||||
"1.00",
|
||||
"0",
|
||||
),
|
||||
// MiniMax 系列
|
||||
("minimax-m2.1", "MiniMax M2.1", "2.10", "8.40", "0.21", "0"),
|
||||
(
|
||||
"minimax-m2.1-lightning",
|
||||
"MiniMax M2.1 Lightning",
|
||||
"2.10",
|
||||
"16.80",
|
||||
"0.21",
|
||||
"0",
|
||||
),
|
||||
("minimax-m2", "MiniMax M2", "2.10", "8.40", "0.21", "0"),
|
||||
// GLM (智谱)
|
||||
("glm-4.7", "GLM-4.7", "2.00", "8.00", "0.40", "0"),
|
||||
("glm-4.6", "GLM-4.6", "2.00", "8.00", "0.40", "0"),
|
||||
// Mimo (小米)
|
||||
("mimo-v2-flash", "Mimo V2 Flash", "0", "0", "0", "0"),
|
||||
];
|
||||
|
||||
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
||||
|
||||
@@ -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(),
|
||||
@@ -248,6 +314,124 @@ fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||
.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
|
||||
@@ -296,9 +480,10 @@ fn dry_run_validates_schema_compatibility() {
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
@@ -166,6 +166,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
};
|
||||
|
||||
for app in apps_str.split(',') {
|
||||
@@ -173,6 +174,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
"claude" => apps.claude = true,
|
||||
"codex" => apps.codex = true,
|
||||
"gemini" => apps.gemini = true,
|
||||
"opencode" => apps.opencode = true,
|
||||
other => {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app in 'apps': {other}"
|
||||
|
||||
@@ -55,7 +55,7 @@ pub struct DeepLinkImportRequest {
|
||||
/// Provider homepage URL
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub homepage: Option<String>,
|
||||
/// API endpoint/base URL
|
||||
/// API endpoint/base URL (supports comma-separated multiple URLs)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint: Option<String>,
|
||||
/// API key
|
||||
|
||||
@@ -101,9 +101,13 @@ fn parse_provider_deeplink(
|
||||
validate_url(hp, "homepage")?;
|
||||
}
|
||||
}
|
||||
// Validate each endpoint (supports comma-separated multiple URLs)
|
||||
if let Some(ref ep) = endpoint {
|
||||
if !ep.is_empty() {
|
||||
validate_url(ep, "endpoint")?;
|
||||
for (i, url) in ep.split(',').enumerate() {
|
||||
let trimmed = url.trim();
|
||||
if !trimmed.is_empty() {
|
||||
validate_url(trimmed, &format!("endpoint[{i}]"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,12 @@ pub fn import_provider_from_deeplink(
|
||||
}
|
||||
|
||||
// Step 1: Merge config file if provided (v3.8+)
|
||||
let merged_request = parse_and_merge_config(&request)?;
|
||||
let mut merged_request = parse_and_merge_config(&request)?;
|
||||
|
||||
// Extract required fields (now as Option)
|
||||
let app_str = merged_request
|
||||
.app
|
||||
.as_ref()
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
|
||||
|
||||
let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
|
||||
@@ -51,14 +51,29 @@ pub fn import_provider_from_deeplink(
|
||||
));
|
||||
}
|
||||
|
||||
let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| {
|
||||
// Get endpoint: supports comma-separated multiple URLs (first is primary)
|
||||
let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| {
|
||||
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
|
||||
})?;
|
||||
|
||||
if endpoint.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Endpoint cannot be empty".to_string(),
|
||||
));
|
||||
// Parse endpoints: split by comma, first is primary
|
||||
let all_endpoints: Vec<String> = endpoint_str
|
||||
.split(',')
|
||||
.map(|e| e.trim().to_string())
|
||||
.filter(|e| !e.is_empty())
|
||||
.collect();
|
||||
|
||||
let primary_endpoint = all_endpoints
|
||||
.first()
|
||||
.ok_or_else(|| AppError::InvalidInput("Endpoint cannot be empty".to_string()))?;
|
||||
|
||||
// Auto-infer homepage from endpoint if not provided
|
||||
if merged_request
|
||||
.homepage
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.is_empty())
|
||||
{
|
||||
merged_request.homepage = infer_homepage_from_endpoint(primary_endpoint);
|
||||
}
|
||||
|
||||
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
|
||||
@@ -73,11 +88,11 @@ pub fn import_provider_from_deeplink(
|
||||
|
||||
let name = merged_request
|
||||
.name
|
||||
.as_ref()
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
|
||||
|
||||
// Parse app type
|
||||
let app_type = AppType::from_str(app_str)
|
||||
let app_type = AppType::from_str(&app_str)
|
||||
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
|
||||
|
||||
// Build provider configuration based on app type
|
||||
@@ -97,6 +112,21 @@ pub fn import_provider_from_deeplink(
|
||||
// Use ProviderService to add the provider
|
||||
ProviderService::add(state, app_type.clone(), provider)?;
|
||||
|
||||
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
||||
for ep in all_endpoints.iter().skip(1) {
|
||||
let normalized = ep.trim().trim_end_matches('/').to_string();
|
||||
if !normalized.is_empty() {
|
||||
if let Err(e) = ProviderService::add_custom_endpoint(
|
||||
state,
|
||||
app_type.clone(),
|
||||
&provider_id,
|
||||
normalized.clone(),
|
||||
) {
|
||||
log::warn!("Failed to add custom endpoint '{normalized}': {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If enabled=true, set as current provider
|
||||
if merged_request.enabled.unwrap_or(false) {
|
||||
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
||||
@@ -115,6 +145,7 @@ pub(crate) fn build_provider_from_request(
|
||||
AppType::Claude => build_claude_settings(request),
|
||||
AppType::Codex => build_codex_settings(request),
|
||||
AppType::Gemini => build_gemini_settings(request),
|
||||
AppType::OpenCode => build_opencode_settings(request),
|
||||
};
|
||||
|
||||
// Build usage script configuration if provided
|
||||
@@ -138,6 +169,16 @@ pub(crate) fn build_provider_from_request(
|
||||
Ok(provider)
|
||||
}
|
||||
|
||||
/// Get primary endpoint from request (first one if comma-separated)
|
||||
fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
|
||||
request
|
||||
.endpoint
|
||||
.as_ref()
|
||||
.and_then(|ep| ep.split(',').next())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -165,6 +206,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||||
|
||||
// Build UsageScript - use provider's API key and endpoint as defaults
|
||||
// Note: use primary endpoint only (first one if comma-separated)
|
||||
let usage_script = UsageScript {
|
||||
enabled,
|
||||
language: "javascript".to_string(),
|
||||
@@ -174,12 +216,17 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
.usage_api_key
|
||||
.clone()
|
||||
.or_else(|| request.api_key.clone()),
|
||||
base_url: request
|
||||
.usage_base_url
|
||||
.clone()
|
||||
.or_else(|| request.endpoint.clone()),
|
||||
base_url: request.usage_base_url.clone().or_else(|| {
|
||||
let primary = get_primary_endpoint(request);
|
||||
if primary.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(primary)
|
||||
}
|
||||
}),
|
||||
access_token: request.usage_access_token.clone(),
|
||||
user_id: request.usage_user_id.clone(),
|
||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||
auto_query_interval: request.usage_auto_interval,
|
||||
};
|
||||
|
||||
@@ -198,7 +245,7 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
);
|
||||
env.insert(
|
||||
"ANTHROPIC_BASE_URL".to_string(),
|
||||
json!(request.endpoint.clone().unwrap_or_default()),
|
||||
json!(get_primary_endpoint(request)),
|
||||
);
|
||||
|
||||
// Add default model if provided
|
||||
@@ -271,11 +318,8 @@ fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
.unwrap_or("gpt-5-codex")
|
||||
.to_string();
|
||||
|
||||
// Endpoint: normalize trailing slashes
|
||||
let endpoint = request
|
||||
.endpoint
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
// Endpoint: normalize trailing slashes (use primary endpoint only)
|
||||
let endpoint = get_primary_endpoint(request)
|
||||
.trim()
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
@@ -309,7 +353,7 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
|
||||
env.insert(
|
||||
"GOOGLE_GEMINI_BASE_URL".to_string(),
|
||||
json!(request.endpoint),
|
||||
json!(get_primary_endpoint(request)),
|
||||
);
|
||||
|
||||
// Add model if provided
|
||||
@@ -320,6 +364,33 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
json!({ "env": env })
|
||||
}
|
||||
|
||||
/// Build OpenCode settings configuration
|
||||
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let endpoint = get_primary_endpoint(request);
|
||||
|
||||
// Build options object
|
||||
let mut options = serde_json::Map::new();
|
||||
if !endpoint.is_empty() {
|
||||
options.insert("baseURL".to_string(), json!(endpoint));
|
||||
}
|
||||
if let Some(api_key) = &request.api_key {
|
||||
options.insert("apiKey".to_string(), json!(api_key));
|
||||
}
|
||||
|
||||
// Build models object
|
||||
let mut models = serde_json::Map::new();
|
||||
if let Some(model) = &request.model {
|
||||
models.insert(model.clone(), json!({ "name": model }));
|
||||
}
|
||||
|
||||
// Default to openai-compatible npm package
|
||||
json!({
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"options": options,
|
||||
"models": models
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Config Merge Logic
|
||||
// =============================================================================
|
||||
@@ -409,27 +480,26 @@ fn merge_claude_config(
|
||||
})?;
|
||||
|
||||
// Auto-fill API key if not provided in URL
|
||||
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
|
||||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
|
||||
request.api_key = Some(token.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fill endpoint if not provided in URL
|
||||
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
|
||||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
||||
request.endpoint = Some(base_url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fill homepage from endpoint if not provided
|
||||
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
||||
&& request.endpoint.is_some()
|
||||
&& !request.endpoint.as_ref().unwrap().is_empty()
|
||||
{
|
||||
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://anthropic.com".to_string());
|
||||
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://anthropic.com".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,7 +538,7 @@ fn merge_codex_config(
|
||||
config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
// Auto-fill API key from auth.OPENAI_API_KEY
|
||||
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
|
||||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(api_key) = config
|
||||
.get("auth")
|
||||
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||||
@@ -483,7 +553,7 @@ fn merge_codex_config(
|
||||
// Parse TOML config string to extract base_url and model
|
||||
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
|
||||
// Extract base_url from model_providers section
|
||||
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
|
||||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(base_url) = extract_codex_base_url(&toml_value) {
|
||||
request.endpoint = Some(base_url);
|
||||
}
|
||||
@@ -499,13 +569,12 @@ fn merge_codex_config(
|
||||
}
|
||||
|
||||
// Auto-fill homepage from endpoint
|
||||
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
||||
&& request.endpoint.is_some()
|
||||
&& !request.endpoint.as_ref().unwrap().is_empty()
|
||||
{
|
||||
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://openai.com".to_string());
|
||||
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://openai.com".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,14 +587,18 @@ fn merge_gemini_config(
|
||||
config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
// Gemini uses flat env structure
|
||||
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
|
||||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
||||
request.api_key = Some(api_key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
|
||||
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
|
||||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(base_url) = config
|
||||
.get("GOOGLE_GEMINI_BASE_URL")
|
||||
.or_else(|| config.get("GEMINI_BASE_URL"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
request.endpoint = Some(base_url.to_string());
|
||||
}
|
||||
}
|
||||
@@ -538,13 +611,12 @@ fn merge_gemini_config(
|
||||
}
|
||||
|
||||
// Auto-fill homepage from endpoint
|
||||
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
||||
&& request.endpoint.is_some()
|
||||
&& !request.endpoint.as_ref().unwrap().is_empty()
|
||||
{
|
||||
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://ai.google.dev".to_string());
|
||||
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://ai.google.dev".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -375,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]
|
||||
@@ -391,16 +391,70 @@ 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");
|
||||
assert_eq!(request.directory.unwrap(), "skills");
|
||||
assert_eq!(request.branch.unwrap(), "dev");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Multiple Endpoints Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_endpoints_comma_separated() {
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com,https%3A%2F%2Fapi2.example.com,https%3A%2F%2Fapi3.example.com&apiKey=sk-test";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert!(request.endpoint.is_some());
|
||||
let endpoint = request.endpoint.unwrap();
|
||||
// Should contain all endpoints comma-separated
|
||||
assert!(endpoint.contains("https://api1.example.com"));
|
||||
assert!(endpoint.contains("https://api2.example.com"));
|
||||
assert!(endpoint.contains("https://api3.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_single_endpoint_backward_compatible() {
|
||||
// Old format with single endpoint should still work
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
request.endpoint,
|
||||
Some("https://api.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_endpoints_with_spaces_trimmed() {
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com%20,%20https%3A%2F%2Fapi2.example.com&apiKey=sk-test";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
// Validation should pass (spaces are trimmed during validation)
|
||||
assert!(request.endpoint.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_homepage_from_endpoint_without_homepage() {
|
||||
// Test that homepage is auto-inferred from endpoint when not provided
|
||||
assert_eq!(
|
||||
infer_homepage_from_endpoint("https://api.cubence.com/v1"),
|
||||
Some("https://cubence.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
infer_homepage_from_endpoint("https://cubence.com"),
|
||||
Some("https://cubence.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ pub enum AppError {
|
||||
},
|
||||
#[error("数据库错误: {0}")]
|
||||
Database(String),
|
||||
#[error("所有供应商已熔断,无可用渠道")]
|
||||
AllProvidersCircuitOpen,
|
||||
#[error("未配置供应商")]
|
||||
NoProvidersConfigured,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
|
||||
@@ -5,15 +5,21 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
fn get_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 Gemini 配置目录路径(支持设置覆盖)
|
||||
pub fn get_gemini_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
||||
return custom;
|
||||
}
|
||||
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".gemini")
|
||||
get_home_dir().join(".gemini")
|
||||
}
|
||||
|
||||
/// 获取 Gemini .env 文件路径
|
||||
|
||||
@@ -134,6 +134,33 @@ pub fn set_mcp_servers_map(
|
||||
obj.remove("homepage");
|
||||
obj.remove("docs");
|
||||
|
||||
// Timeout 转换:Claude/Codex 使用 startup_timeout_sec/tool_timeout_sec
|
||||
// Gemini CLI 只支持 timeout(单位 ms)
|
||||
// 默认值:startup=10s, tool=60s
|
||||
const DEFAULT_STARTUP_MS: u64 = 10_000;
|
||||
const DEFAULT_TOOL_MS: u64 = 60_000;
|
||||
|
||||
let extract_timeout =
|
||||
|obj: &mut Map<String, Value>, key: &str, multiplier: u64| -> Option<u64> {
|
||||
obj.remove(key).and_then(|val| {
|
||||
val.as_u64()
|
||||
.map(|n| n * multiplier)
|
||||
.or_else(|| val.as_f64().map(|f| (f * multiplier as f64) as u64))
|
||||
})
|
||||
};
|
||||
|
||||
// 分别收集 startup 和 tool timeout,未设置时使用默认值
|
||||
let startup_ms = extract_timeout(&mut obj, "startup_timeout_sec", 1000)
|
||||
.or_else(|| extract_timeout(&mut obj, "startup_timeout_ms", 1))
|
||||
.unwrap_or(DEFAULT_STARTUP_MS);
|
||||
let tool_ms = extract_timeout(&mut obj, "tool_timeout_sec", 1000)
|
||||
.or_else(|| extract_timeout(&mut obj, "tool_timeout_ms", 1))
|
||||
.unwrap_or(DEFAULT_TOOL_MS);
|
||||
|
||||
// 取最大值作为 Gemini timeout
|
||||
let final_timeout = startup_ms.max(tool_ms);
|
||||
obj.insert("timeout".to_string(), Value::Number(final_timeout.into()));
|
||||
|
||||
out.insert(id.clone(), Value::Object(obj));
|
||||
}
|
||||
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
+291
-22
@@ -13,6 +13,8 @@ mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod init_status;
|
||||
mod mcp;
|
||||
mod opencode_config;
|
||||
mod panic_hook;
|
||||
mod prompt;
|
||||
mod prompt_files;
|
||||
mod provider;
|
||||
@@ -26,6 +28,7 @@ mod usage_script;
|
||||
|
||||
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
||||
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
||||
pub use commands::open_provider_terminal;
|
||||
pub use commands::*;
|
||||
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||
pub use database::Database;
|
||||
@@ -54,6 +57,37 @@ use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::RunEvent;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
fn redact_url_for_log(url_str: &str) -> String {
|
||||
match url::Url::parse(url_str) {
|
||||
Ok(url) => {
|
||||
let mut output = format!("{}://", url.scheme());
|
||||
if let Some(host) = url.host_str() {
|
||||
output.push_str(host);
|
||||
}
|
||||
output.push_str(url.path());
|
||||
|
||||
let mut keys: Vec<String> = url.query_pairs().map(|(k, _)| k.to_string()).collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
|
||||
if !keys.is_empty() {
|
||||
output.push_str("?[keys:");
|
||||
output.push_str(&keys.join(","));
|
||||
output.push(']');
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
Err(_) => {
|
||||
let base = url_str.split('#').next().unwrap_or(url_str);
|
||||
match base.split_once('?') {
|
||||
Some((prefix, _)) => format!("{prefix}?[redacted]"),
|
||||
None => base.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一处理 ccswitch:// 深链接 URL
|
||||
///
|
||||
/// - 解析 URL
|
||||
@@ -69,7 +103,9 @@ fn handle_deeplink_url(
|
||||
return false;
|
||||
}
|
||||
|
||||
log::info!("✓ Deep link URL detected from {source}: {url_str}");
|
||||
let redacted_url = redact_url_for_log(url_str);
|
||||
log::info!("✓ Deep link URL detected from {source}: {redacted_url}");
|
||||
log::debug!("Deep link URL (raw) from {source}: {url_str}");
|
||||
|
||||
match crate::deeplink::parse_deeplink_url(url_str) {
|
||||
Ok(request) => {
|
||||
@@ -150,15 +186,18 @@ fn macos_tray_icon() -> Option<Image<'static>> {
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// 设置 panic hook,在应用崩溃时记录日志到 <app_config_dir>/crash.log(默认 ~/.cc-switch/crash.log)
|
||||
panic_hook::setup_panic_hook();
|
||||
|
||||
let mut builder = tauri::Builder::default();
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
log::info!("=== Single Instance Callback Triggered ===");
|
||||
log::info!("Args count: {}", args.len());
|
||||
log::debug!("Args count: {}", args.len());
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
log::info!(" arg[{i}]: {arg}");
|
||||
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
|
||||
}
|
||||
|
||||
// Check for deep link URL in args (mainly for Windows/Linux command line)
|
||||
@@ -212,6 +251,10 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.setup(|app| {
|
||||
// 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)
|
||||
app_store::refresh_app_config_dir_override(app.handle());
|
||||
panic_hook::init_app_config_dir(crate::config::get_app_config_dir());
|
||||
|
||||
// 注册 Updater 插件(桌面端)
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
@@ -223,17 +266,34 @@ pub fn run() {
|
||||
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||||
}
|
||||
}
|
||||
// 初始化日志
|
||||
if cfg!(debug_assertions) {
|
||||
// 初始化日志(Debug 和 Release 模式都启用 Info 级别)
|
||||
// 日志同时输出到控制台和文件(<app_config_dir>/logs/;若设置了覆盖则使用覆盖目录)
|
||||
{
|
||||
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
|
||||
|
||||
let log_dir = panic_hook::get_log_dir();
|
||||
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.targets([
|
||||
// 输出到控制台
|
||||
Target::new(TargetKind::Stdout),
|
||||
// 输出到日志文件
|
||||
Target::new(TargetKind::Folder {
|
||||
path: log_dir,
|
||||
file_name: Some("cc-switch".into()),
|
||||
}),
|
||||
])
|
||||
.rotation_strategy(RotationStrategy::KeepAll)
|
||||
.max_file_size(5_000_000) // 5MB 单文件上限
|
||||
.timezone_strategy(TimezoneStrategy::UseLocal)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
|
||||
// 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径
|
||||
app_store::refresh_app_config_dir_override(app.handle());
|
||||
// 清理旧日志文件,只保留最近 2 个
|
||||
panic_hook::cleanup_old_logs();
|
||||
}
|
||||
|
||||
// 初始化数据库
|
||||
let app_config_dir = crate::config::get_app_config_dir();
|
||||
@@ -273,12 +333,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!("用户选择重试初始化数据库");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -324,6 +397,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,
|
||||
@@ -336,6 +450,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) => {
|
||||
@@ -348,6 +483,17 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2.1 OpenCode 供应商导入(累加式模式,需特殊处理)
|
||||
// OpenCode 与其他应用不同:配置文件中可同时存在多个供应商
|
||||
// 需要遍历 provider 字段下的每个供应商并导入
|
||||
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No OpenCode providers found to import"),
|
||||
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
|
||||
}
|
||||
|
||||
// 3. 导入 MCP 服务器配置(表空时触发)
|
||||
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
||||
log::info!("MCP table empty, importing from live configurations...");
|
||||
@@ -375,6 +521,14 @@ pub fn run() {
|
||||
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
|
||||
}
|
||||
|
||||
match crate::services::mcp::McpService::import_from_opencode(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} MCP server(s) from OpenCode");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No OpenCode MCP servers found to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import OpenCode MCP: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 导入提示词文件(表空时触发)
|
||||
@@ -454,7 +608,7 @@ pub fn run() {
|
||||
|
||||
for (i, url) in urls.iter().enumerate() {
|
||||
let url_str = url.as_str();
|
||||
log::info!(" URL[{i}]: {url_str}");
|
||||
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
|
||||
|
||||
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
|
||||
break; // Process only first ccswitch:// URL
|
||||
@@ -507,12 +661,37 @@ pub fn run() {
|
||||
app.manage(app_state);
|
||||
|
||||
// 初始化 SkillService
|
||||
match SkillService::new() {
|
||||
Ok(skill_service) => {
|
||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("初始化 SkillService 失败: {e}");
|
||||
let skill_service = SkillService::new();
|
||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||
|
||||
// 初始化全局出站代理 HTTP 客户端
|
||||
{
|
||||
let db = &app.state::<AppState>().db;
|
||||
let proxy_url = db.get_global_proxy_url().ok().flatten();
|
||||
|
||||
if let Err(e) = crate::proxy::http_client::init(proxy_url.as_deref()) {
|
||||
log::error!(
|
||||
"[GlobalProxy] [GP-005] Failed to initialize with saved config: {e}"
|
||||
);
|
||||
|
||||
// 清除无效的代理配置
|
||||
if proxy_url.is_some() {
|
||||
log::warn!(
|
||||
"[GlobalProxy] [GP-006] Clearing invalid proxy config from database"
|
||||
);
|
||||
if let Err(clear_err) = db.set_global_proxy_url(None) {
|
||||
log::error!(
|
||||
"[GlobalProxy] [GP-007] Failed to clear invalid config: {clear_err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用直连模式重新初始化
|
||||
if let Err(fallback_err) = crate::proxy::http_client::init(None) {
|
||||
log::error!(
|
||||
"[GlobalProxy] [GP-008] Failed to initialize direct connection: {fallback_err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,6 +732,7 @@ pub fn run() {
|
||||
commands::add_provider,
|
||||
commands::update_provider,
|
||||
commands::delete_provider,
|
||||
commands::remove_provider_from_live_config,
|
||||
commands::switch_provider,
|
||||
commands::import_default_config,
|
||||
commands::get_claude_config_status,
|
||||
@@ -564,15 +744,19 @@ 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,
|
||||
commands::get_rectifier_config,
|
||||
commands::set_rectifier_config,
|
||||
commands::restart_app,
|
||||
commands::check_for_updates,
|
||||
commands::is_portable_mode,
|
||||
@@ -601,6 +785,7 @@ pub fn run() {
|
||||
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,
|
||||
@@ -635,7 +820,15 @@ 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,
|
||||
@@ -694,12 +887,23 @@ pub fn run() {
|
||||
commands::get_stream_check_config,
|
||||
commands::save_stream_check_config,
|
||||
commands::get_tool_versions,
|
||||
// Provider terminal
|
||||
commands::open_provider_terminal,
|
||||
// Universal Provider management
|
||||
commands::get_universal_providers,
|
||||
commands::get_universal_provider,
|
||||
commands::upsert_universal_provider,
|
||||
commands::delete_universal_provider,
|
||||
commands::sync_universal_provider,
|
||||
// OpenCode specific
|
||||
commands::import_opencode_providers_from_live,
|
||||
commands::get_opencode_live_provider_ids,
|
||||
// Global upstream proxy
|
||||
commands::get_global_proxy_url,
|
||||
commands::set_global_proxy_url,
|
||||
commands::test_proxy_url,
|
||||
commands::get_upstream_proxy_status,
|
||||
commands::scan_local_proxies,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
@@ -966,3 +1170,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()
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -235,6 +235,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: true,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
//! - `claude` - Claude MCP 同步和导入
|
||||
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
|
||||
//! - `gemini` - Gemini MCP 同步和导入
|
||||
//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换)
|
||||
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
mod opencode;
|
||||
mod validation;
|
||||
|
||||
// 重新导出公共 API
|
||||
@@ -26,3 +28,6 @@ pub use gemini::{
|
||||
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
||||
sync_single_server_to_gemini,
|
||||
};
|
||||
pub use opencode::{
|
||||
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
//! OpenCode MCP 同步和导入模块
|
||||
//!
|
||||
//! 本模块处理 CC Switch 统一 MCP 格式与 OpenCode 格式之间的转换。
|
||||
//!
|
||||
//! ## 格式差异
|
||||
//!
|
||||
//! | CC Switch 统一格式 | OpenCode 格式 |
|
||||
//! |----------------------|---------------------|
|
||||
//! | `type: "stdio"` | `type: "local"` |
|
||||
//! | `command` + `args` | `command: [cmd, ...args]` |
|
||||
//! | `env` | `environment` |
|
||||
//! | `type: "sse"/"http"` | `type: "remote"` |
|
||||
//! | `url` | `url` |
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
use crate::opencode_config;
|
||||
|
||||
use super::validation::validate_server_spec;
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Check if OpenCode MCP sync should proceed
|
||||
fn should_sync_opencode_mcp() -> bool {
|
||||
// Skip if OpenCode config directory doesn't exist
|
||||
opencode_config::get_opencode_dir().exists()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Format Conversion: CC Switch → OpenCode
|
||||
// ============================================================================
|
||||
|
||||
/// Convert CC Switch unified format to OpenCode format
|
||||
///
|
||||
/// Conversion rules:
|
||||
/// - `stdio` → `local`, command+args → command array, env → environment
|
||||
/// - `sse`/`http` → `remote`, url preserved
|
||||
pub fn convert_to_opencode_format(spec: &Value) -> Result<Value, AppError> {
|
||||
let obj = spec
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?;
|
||||
|
||||
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
|
||||
match typ {
|
||||
"stdio" => {
|
||||
// Convert to "local" type
|
||||
result.insert("type".into(), json!("local"));
|
||||
|
||||
// Merge command and args into a single array
|
||||
let cmd = obj.get("command").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let mut command_arr = vec![json!(cmd)];
|
||||
|
||||
if let Some(args) = obj.get("args").and_then(|v| v.as_array()) {
|
||||
for arg in args {
|
||||
command_arr.push(arg.clone());
|
||||
}
|
||||
}
|
||||
result.insert("command".into(), Value::Array(command_arr));
|
||||
|
||||
// Convert env → environment
|
||||
if let Some(env) = obj.get("env") {
|
||||
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
|
||||
result.insert("environment".into(), env.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Add enabled flag (OpenCode expects this)
|
||||
result.insert("enabled".into(), json!(true));
|
||||
}
|
||||
"sse" | "http" => {
|
||||
// Convert to "remote" type
|
||||
result.insert("type".into(), json!("remote"));
|
||||
|
||||
// Preserve url
|
||||
if let Some(url) = obj.get("url") {
|
||||
result.insert("url".into(), url.clone());
|
||||
}
|
||||
|
||||
// Convert headers if present
|
||||
if let Some(headers) = obj.get("headers") {
|
||||
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
|
||||
{
|
||||
result.insert("headers".into(), headers.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Add enabled flag
|
||||
result.insert("enabled".into(), json!(true));
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::McpValidation(format!(
|
||||
"Unknown MCP type: {}",
|
||||
typ
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Value::Object(result))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Format Conversion: OpenCode → CC Switch
|
||||
// ============================================================================
|
||||
|
||||
/// Convert OpenCode format to CC Switch unified format
|
||||
///
|
||||
/// Conversion rules:
|
||||
/// - `local` → `stdio`, command array → command+args, environment → env
|
||||
/// - `remote` → `sse`, url preserved
|
||||
pub fn convert_from_opencode_format(spec: &Value) -> Result<Value, AppError> {
|
||||
let obj = spec
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::McpValidation("OpenCode MCP spec must be a JSON object".into()))?;
|
||||
|
||||
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("local");
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
|
||||
match typ {
|
||||
"local" => {
|
||||
// Convert to "stdio" type
|
||||
result.insert("type".into(), json!("stdio"));
|
||||
|
||||
// Split command array into command and args
|
||||
if let Some(cmd_arr) = obj.get("command").and_then(|v| v.as_array()) {
|
||||
if !cmd_arr.is_empty() {
|
||||
// First element is the command
|
||||
if let Some(cmd) = cmd_arr.first().and_then(|v| v.as_str()) {
|
||||
result.insert("command".into(), json!(cmd));
|
||||
}
|
||||
|
||||
// Rest are args
|
||||
if cmd_arr.len() > 1 {
|
||||
let args: Vec<Value> = cmd_arr[1..].to_vec();
|
||||
result.insert("args".into(), Value::Array(args));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert environment → env
|
||||
if let Some(env) = obj.get("environment") {
|
||||
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
|
||||
result.insert("env".into(), env.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
"remote" => {
|
||||
// Convert to "sse" type (default remote protocol)
|
||||
result.insert("type".into(), json!("sse"));
|
||||
|
||||
// Preserve url
|
||||
if let Some(url) = obj.get("url") {
|
||||
result.insert("url".into(), url.clone());
|
||||
}
|
||||
|
||||
// Preserve headers
|
||||
if let Some(headers) = obj.get("headers") {
|
||||
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
|
||||
{
|
||||
result.insert("headers".into(), headers.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::McpValidation(format!(
|
||||
"Unknown OpenCode MCP type: {}",
|
||||
typ
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Value::Object(result))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API: Sync Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Sync a single MCP server to OpenCode live config
|
||||
pub fn sync_single_server_to_opencode(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
if !should_sync_opencode_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Convert to OpenCode format
|
||||
let opencode_spec = convert_to_opencode_format(server_spec)?;
|
||||
|
||||
// Set in OpenCode config
|
||||
opencode_config::set_mcp_server(id, opencode_spec)
|
||||
}
|
||||
|
||||
/// Remove a single MCP server from OpenCode live config
|
||||
pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError> {
|
||||
if !should_sync_opencode_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
opencode_config::remove_mcp_server(id)
|
||||
}
|
||||
|
||||
/// Import MCP servers from OpenCode config to unified structure
|
||||
///
|
||||
/// Existing servers will have OpenCode app enabled without overwriting other fields.
|
||||
pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
let mcp_map = opencode_config::get_mcp_servers()?;
|
||||
if mcp_map.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Ensure servers map exists
|
||||
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||
|
||||
let mut changed = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (id, spec) in mcp_map {
|
||||
// Convert from OpenCode format to unified format
|
||||
let unified_spec = match convert_from_opencode_format(&spec) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("Skip invalid OpenCode MCP server '{}': {}", id, e);
|
||||
errors.push(format!("{}: {}", id, e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Validate the converted spec
|
||||
if let Err(e) = validate_server_spec(&unified_spec) {
|
||||
log::warn!("Skip invalid MCP server '{}' after conversion: {}", id, e);
|
||||
errors.push(format!("{}: {}", id, e));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = servers.get_mut(&id) {
|
||||
// Existing server: just enable OpenCode app
|
||||
if !existing.apps.opencode {
|
||||
existing.apps.opencode = true;
|
||||
changed += 1;
|
||||
log::info!("MCP server '{}' enabled for OpenCode", id);
|
||||
}
|
||||
} else {
|
||||
// New server: default to only OpenCode enabled
|
||||
servers.insert(
|
||||
id.clone(),
|
||||
McpServer {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
server: unified_spec,
|
||||
apps: McpApps {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: true,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
changed += 1;
|
||||
log::info!("Imported new MCP server '{}' from OpenCode", id);
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
log::warn!(
|
||||
"Import completed with {} failures: {:?}",
|
||||
errors.len(),
|
||||
errors
|
||||
);
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_convert_stdio_to_local() {
|
||||
let spec = json!({
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
"env": { "HOME": "/Users/test" }
|
||||
});
|
||||
|
||||
let result = convert_to_opencode_format(&spec).unwrap();
|
||||
assert_eq!(result["type"], "local");
|
||||
assert_eq!(result["command"][0], "npx");
|
||||
assert_eq!(result["command"][1], "-y");
|
||||
assert_eq!(
|
||||
result["command"][2],
|
||||
"@modelcontextprotocol/server-filesystem"
|
||||
);
|
||||
assert_eq!(result["environment"]["HOME"], "/Users/test");
|
||||
assert_eq!(result["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_sse_to_remote() {
|
||||
let spec = json!({
|
||||
"type": "sse",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer xxx" }
|
||||
});
|
||||
|
||||
let result = convert_to_opencode_format(&spec).unwrap();
|
||||
assert_eq!(result["type"], "remote");
|
||||
assert_eq!(result["url"], "https://example.com/mcp");
|
||||
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
|
||||
assert_eq!(result["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_local_to_stdio() {
|
||||
let spec = json!({
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "@modelcontextprotocol/server-filesystem"],
|
||||
"environment": { "HOME": "/Users/test" }
|
||||
});
|
||||
|
||||
let result = convert_from_opencode_format(&spec).unwrap();
|
||||
assert_eq!(result["type"], "stdio");
|
||||
assert_eq!(result["command"], "npx");
|
||||
assert_eq!(result["args"][0], "-y");
|
||||
assert_eq!(result["args"][1], "@modelcontextprotocol/server-filesystem");
|
||||
assert_eq!(result["env"]["HOME"], "/Users/test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_remote_to_sse() {
|
||||
let spec = json!({
|
||||
"type": "remote",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer xxx" }
|
||||
});
|
||||
|
||||
let result = convert_from_opencode_format(&spec).unwrap();
|
||||
assert_eq!(result["type"], "sse");
|
||||
assert_eq!(result["url"], "https://example.com/mcp");
|
||||
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! OpenCode 配置文件读写模块
|
||||
//!
|
||||
//! 处理 `~/.config/opencode/opencode.json` 配置文件的读写操作。
|
||||
//! OpenCode 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
|
||||
//!
|
||||
//! ## 配置文件格式
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "$schema": "https://opencode.ai/config.json",
|
||||
//! "provider": {
|
||||
//! "my-provider": {
|
||||
//! "npm": "@ai-sdk/openai-compatible",
|
||||
//! "options": { "baseURL": "...", "apiKey": "{env:API_KEY}" },
|
||||
//! "models": { "gpt-4o": { "name": "GPT-4o" } }
|
||||
//! }
|
||||
//! },
|
||||
//! "mcp": {
|
||||
//! "my-server": { "type": "local", "command": ["..."] }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use crate::config::write_json_file;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::OpenCodeProviderConfig;
|
||||
use crate::settings::get_opencode_override_dir;
|
||||
use indexmap::IndexMap;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
// ============================================================================
|
||||
// Path Functions
|
||||
// ============================================================================
|
||||
|
||||
/// 获取 OpenCode 配置目录
|
||||
///
|
||||
/// 默认路径: `~/.config/opencode/`
|
||||
/// 可通过 settings.opencode_config_dir 覆盖
|
||||
pub fn get_opencode_dir() -> PathBuf {
|
||||
if let Some(override_dir) = get_opencode_override_dir() {
|
||||
return override_dir;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows: %APPDATA%\opencode
|
||||
dirs::data_dir()
|
||||
.map(|d| d.join("opencode"))
|
||||
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Unix: ~/.config/opencode
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".config").join("opencode"))
|
||||
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 OpenCode 配置文件路径
|
||||
///
|
||||
/// 返回 `~/.config/opencode/opencode.json`
|
||||
pub fn get_opencode_config_path() -> PathBuf {
|
||||
get_opencode_dir().join("opencode.json")
|
||||
}
|
||||
|
||||
/// 获取 OpenCode 环境变量文件路径(如果存在)
|
||||
///
|
||||
/// 返回 `~/.config/opencode/.env`
|
||||
#[allow(dead_code)]
|
||||
pub fn get_opencode_env_path() -> PathBuf {
|
||||
get_opencode_dir().join(".env")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Core Read/Write Functions
|
||||
// ============================================================================
|
||||
|
||||
/// 读取 OpenCode 配置文件
|
||||
///
|
||||
/// 返回完整的配置 JSON 对象
|
||||
pub fn read_opencode_config() -> Result<Value, AppError> {
|
||||
let path = get_opencode_config_path();
|
||||
|
||||
if !path.exists() {
|
||||
// Return empty config with schema
|
||||
return Ok(json!({
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
}));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
|
||||
}
|
||||
|
||||
/// 写入 OpenCode 配置文件(原子写入)
|
||||
///
|
||||
/// 使用临时文件 + 重命名确保原子性
|
||||
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
||||
let path = get_opencode_config_path();
|
||||
// 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况)
|
||||
write_json_file(&path, config)?;
|
||||
|
||||
log::debug!("OpenCode config written to {:?}", path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Functions (Untyped - for raw JSON operations)
|
||||
// ============================================================================
|
||||
|
||||
/// 获取所有供应商配置(原始 JSON)
|
||||
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
||||
let config = read_opencode_config()?;
|
||||
Ok(config
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 设置供应商配置(原始 JSON)
|
||||
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
||||
let mut full_config = read_opencode_config()?;
|
||||
|
||||
if full_config.get("provider").is_none() {
|
||||
full_config["provider"] = json!({});
|
||||
}
|
||||
|
||||
if let Some(providers) = full_config
|
||||
.get_mut("provider")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
providers.insert(id.to_string(), config);
|
||||
}
|
||||
|
||||
write_opencode_config(&full_config)
|
||||
}
|
||||
|
||||
/// 删除供应商配置
|
||||
pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
|
||||
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
|
||||
providers.remove(id);
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Functions (Typed - using OpenCodeProviderConfig)
|
||||
// ============================================================================
|
||||
|
||||
/// 获取所有供应商配置(类型化)
|
||||
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
|
||||
let providers = get_providers()?;
|
||||
let mut result = IndexMap::new();
|
||||
|
||||
for (id, value) in providers {
|
||||
match serde_json::from_value::<OpenCodeProviderConfig>(value.clone()) {
|
||||
Ok(config) => {
|
||||
result.insert(id, config);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to parse provider '{}': {}", id, e);
|
||||
// Skip invalid providers but continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 设置供应商配置(类型化)
|
||||
pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> {
|
||||
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
set_provider(id, value)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Functions
|
||||
// ============================================================================
|
||||
|
||||
/// 获取所有 MCP 服务器配置
|
||||
pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
||||
let config = read_opencode_config()?;
|
||||
Ok(config
|
||||
.get("mcp")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 设置 MCP 服务器配置
|
||||
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
||||
let mut full_config = read_opencode_config()?;
|
||||
|
||||
if full_config.get("mcp").is_none() {
|
||||
full_config["mcp"] = json!({});
|
||||
}
|
||||
|
||||
if let Some(mcp) = full_config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
||||
mcp.insert(id.to_string(), config);
|
||||
}
|
||||
|
||||
write_opencode_config(&full_config)
|
||||
}
|
||||
|
||||
/// 删除 MCP 服务器配置
|
||||
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
|
||||
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
||||
mcp.remove(id);
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Panic Hook 模块
|
||||
//!
|
||||
//! 在应用崩溃时捕获 panic 信息并记录到 `<app_config_dir>/crash.log` 文件中(默认 `~/.cc-switch/crash.log`)。
|
||||
//! 便于用户和开发者诊断闪退问题。
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::panic;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 应用版本号(从 Cargo.toml 读取)
|
||||
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// 日志文件保留数量
|
||||
const LOG_FILES_TO_KEEP: usize = 2;
|
||||
|
||||
static APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
pub fn init_app_config_dir(dir: PathBuf) {
|
||||
let _ = APP_CONFIG_DIR.set(dir);
|
||||
}
|
||||
|
||||
/// 获取默认应用配置目录(不会 panic)
|
||||
fn default_app_config_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".cc-switch")
|
||||
}
|
||||
|
||||
/// 获取应用配置目录(优先使用初始化时写入的值;不会 panic)
|
||||
fn get_app_config_dir() -> PathBuf {
|
||||
APP_CONFIG_DIR
|
||||
.get()
|
||||
.cloned()
|
||||
.unwrap_or_else(default_app_config_dir)
|
||||
}
|
||||
|
||||
/// 获取崩溃日志文件路径
|
||||
fn get_crash_log_path() -> PathBuf {
|
||||
get_app_config_dir().join("crash.log")
|
||||
}
|
||||
|
||||
/// 获取日志目录路径
|
||||
pub fn get_log_dir() -> PathBuf {
|
||||
get_app_config_dir().join("logs")
|
||||
}
|
||||
|
||||
/// 清理旧日志文件,只保留最近 N 个
|
||||
///
|
||||
/// 在应用启动时调用,确保日志文件不会无限增长。
|
||||
pub fn cleanup_old_logs() {
|
||||
let log_dir = get_log_dir();
|
||||
|
||||
if !log_dir.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取目录中的所有 .log 文件
|
||||
let mut log_files: Vec<_> = match std::fs::read_dir(&log_dir) {
|
||||
Ok(entries) => entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().map(|ext| ext == "log").unwrap_or(false))
|
||||
.collect(),
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// 如果文件数量不超过保留数量,无需清理
|
||||
if log_files.len() <= LOG_FILES_TO_KEEP {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按修改时间排序(最新的在前)
|
||||
log_files.sort_by(|a, b| {
|
||||
let time_a = a.metadata().and_then(|m| m.modified()).ok();
|
||||
let time_b = b.metadata().and_then(|m| m.modified()).ok();
|
||||
time_b.cmp(&time_a) // 降序
|
||||
});
|
||||
|
||||
// 删除多余的旧文件
|
||||
for old_file in log_files.into_iter().skip(LOG_FILES_TO_KEEP) {
|
||||
if let Err(e) = std::fs::remove_file(&old_file) {
|
||||
log::warn!("清理旧日志文件失败 {}: {e}", old_file.display());
|
||||
} else {
|
||||
log::info!("已清理旧日志文件: {}", old_file.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全获取环境信息(不会 panic)
|
||||
fn get_system_info() -> String {
|
||||
let os = std::env::consts::OS;
|
||||
let arch = std::env::consts::ARCH;
|
||||
let family = std::env::consts::FAMILY;
|
||||
|
||||
// 安全获取当前工作目录
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
|
||||
// 安全获取当前线程信息
|
||||
let thread = std::thread::current();
|
||||
let thread_name = thread.name().unwrap_or("unnamed");
|
||||
let thread_id = format!("{:?}", thread.id());
|
||||
|
||||
format!(
|
||||
"OS: {os} ({family})\n\
|
||||
Arch: {arch}\n\
|
||||
App Version: {APP_VERSION}\n\
|
||||
Working Dir: {cwd}\n\
|
||||
Thread: {thread_name} (ID: {thread_id})"
|
||||
)
|
||||
}
|
||||
|
||||
/// 设置 panic hook,捕获崩溃信息并写入日志文件
|
||||
///
|
||||
/// 在应用启动时调用此函数,确保任何 panic 都会被记录。
|
||||
/// 日志格式包含:
|
||||
/// - 时间戳
|
||||
/// - 应用版本和系统信息
|
||||
/// - Panic 信息
|
||||
/// - 发生位置(文件:行号)
|
||||
/// - Backtrace(完整调用栈)
|
||||
pub fn setup_panic_hook() {
|
||||
// 启用 backtrace(确保 release 模式也能捕获)
|
||||
if std::env::var("RUST_BACKTRACE").is_err() {
|
||||
std::env::set_var("RUST_BACKTRACE", "1");
|
||||
}
|
||||
|
||||
let default_hook = panic::take_hook();
|
||||
|
||||
panic::set_hook(Box::new(move |panic_info| {
|
||||
let log_path = get_crash_log_path();
|
||||
|
||||
// 确保目录存在
|
||||
if let Some(parent) = log_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
// 构建崩溃信息(使用 catch_unwind 保护时间格式化,避免嵌套 panic)
|
||||
let timestamp = std::panic::catch_unwind(|| {
|
||||
chrono::Local::now()
|
||||
.format("%Y-%m-%d %H:%M:%S%.3f")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|_| {
|
||||
// chrono panic 时回退到 unix timestamp
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| format!("unix:{}.{:03}", d.as_secs(), d.subsec_millis()))
|
||||
.unwrap_or_else(|_| "unknown".to_string())
|
||||
});
|
||||
|
||||
// 获取系统信息
|
||||
let system_info = std::panic::catch_unwind(get_system_info)
|
||||
.unwrap_or_else(|_| "Failed to get system info".to_string());
|
||||
|
||||
// 获取 panic 消息(尝试多种方式提取)
|
||||
let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
// 尝试使用 Display trait
|
||||
format!("{panic_info}")
|
||||
};
|
||||
|
||||
// 获取位置信息
|
||||
let location = if let Some(loc) = panic_info.location() {
|
||||
format!(
|
||||
"File: {}\n Line: {}\n Column: {}",
|
||||
loc.file(),
|
||||
loc.line(),
|
||||
loc.column()
|
||||
)
|
||||
} else {
|
||||
"Unknown location".to_string()
|
||||
};
|
||||
|
||||
// 捕获 backtrace(完整调用栈)
|
||||
let backtrace = std::backtrace::Backtrace::force_capture();
|
||||
let backtrace_str = format!("{backtrace}");
|
||||
|
||||
// 格式化日志条目
|
||||
let separator = "=".repeat(80);
|
||||
let sub_separator = "-".repeat(40);
|
||||
let crash_entry = format!(
|
||||
r#"
|
||||
{separator}
|
||||
[CRASH REPORT] {timestamp}
|
||||
{separator}
|
||||
|
||||
{sub_separator}
|
||||
System Information
|
||||
{sub_separator}
|
||||
{system_info}
|
||||
|
||||
{sub_separator}
|
||||
Error Details
|
||||
{sub_separator}
|
||||
Message: {message}
|
||||
|
||||
Location: {location}
|
||||
|
||||
{sub_separator}
|
||||
Stack Trace (Backtrace)
|
||||
{sub_separator}
|
||||
{backtrace_str}
|
||||
|
||||
{separator}
|
||||
"#
|
||||
);
|
||||
|
||||
// 写入文件(追加模式)
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {
|
||||
let _ = file.write_all(crash_entry.as_bytes());
|
||||
let _ = file.flush();
|
||||
|
||||
// 记录日志文件位置到 stderr
|
||||
eprintln!("\n[CC-Switch] Crash log saved to: {}", log_path.display());
|
||||
}
|
||||
|
||||
// 同时输出到 stderr(便于开发调试)
|
||||
eprintln!("{crash_entry}");
|
||||
|
||||
// 调用默认 hook
|
||||
default_hook(panic_info);
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_crash_log_path() {
|
||||
let path = get_crash_log_path();
|
||||
assert!(path.ends_with("crash.log"));
|
||||
assert!(path.to_string_lossy().contains(".cc-switch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_info() {
|
||||
let info = get_system_info();
|
||||
assert!(info.contains("OS:"));
|
||||
assert!(info.contains("Arch:"));
|
||||
assert!(info.contains("App Version:"));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use crate::codex_config::get_codex_auth_path;
|
||||
use crate::config::get_claude_settings_path;
|
||||
use crate::error::AppError;
|
||||
use crate::gemini_config::get_gemini_dir;
|
||||
use crate::opencode_config::get_opencode_dir;
|
||||
|
||||
/// 返回指定应用所使用的提示词文件路径。
|
||||
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
@@ -12,12 +13,14 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
|
||||
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
||||
AppType::Gemini => get_gemini_dir(),
|
||||
AppType::OpenCode => get_opencode_dir(),
|
||||
};
|
||||
|
||||
let filename = match app {
|
||||
AppType::Claude => "CLAUDE.md",
|
||||
AppType::Codex => "AGENTS.md",
|
||||
AppType::Gemini => "GEMINI.md",
|
||||
AppType::OpenCode => "AGENTS.md",
|
||||
};
|
||||
|
||||
Ok(base_dir.join(filename))
|
||||
|
||||
+103
-5
@@ -98,6 +98,10 @@ pub struct UsageScript {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: Option<String>,
|
||||
/// 模板类型(用于后端判断验证规则)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "templateType")]
|
||||
pub template_type: Option<String>,
|
||||
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "autoQueryInterval")]
|
||||
@@ -147,6 +151,9 @@ pub struct ProviderMeta {
|
||||
/// 用量查询脚本配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_script: Option<UsageScript>,
|
||||
/// 请求地址管理:测速后自动选择最佳端点
|
||||
#[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint_auto_select: Option<bool>,
|
||||
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
|
||||
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
|
||||
pub is_partner: Option<bool>,
|
||||
@@ -386,16 +393,15 @@ impl UniversalProvider {
|
||||
// 生成 Codex 的 config.toml 内容
|
||||
let config_toml = format!(
|
||||
r#"model_provider = "newapi"
|
||||
model = "{}"
|
||||
model_reasoning_effort = "{}"
|
||||
model = "{model}"
|
||||
model_reasoning_effort = "{reasoning_effort}"
|
||||
disable_response_storage = true
|
||||
|
||||
[model_providers.newapi]
|
||||
name = "NewAPI"
|
||||
base_url = "{}"
|
||||
base_url = "{codex_base_url}"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true"#,
|
||||
model, reasoning_effort, codex_base_url
|
||||
requires_openai_auth = true"#
|
||||
);
|
||||
|
||||
let settings_config = serde_json::json!({
|
||||
@@ -456,3 +462,95 @@ requires_openai_auth = true"#,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OpenCode 供应商配置结构
|
||||
// ============================================================================
|
||||
|
||||
/// OpenCode 供应商的 settings_config 结构
|
||||
///
|
||||
/// OpenCode 使用 AI SDK 包名来指定供应商类型,与其他应用的配置格式不同。
|
||||
/// 配置示例:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "npm": "@ai-sdk/openai-compatible",
|
||||
/// "options": { "baseURL": "https://api.example.com/v1", "apiKey": "sk-xxx" },
|
||||
/// "models": { "gpt-4o": { "name": "GPT-4o" } }
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeProviderConfig {
|
||||
/// AI SDK 包名,如 "@ai-sdk/openai-compatible", "@ai-sdk/anthropic"
|
||||
pub npm: String,
|
||||
|
||||
/// 供应商名称(可选,用于显示)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// 供应商选项(API 密钥、基础 URL 等)
|
||||
#[serde(default)]
|
||||
pub options: OpenCodeProviderOptions,
|
||||
|
||||
/// 模型定义映射
|
||||
#[serde(default)]
|
||||
pub models: HashMap<String, OpenCodeModel>,
|
||||
}
|
||||
|
||||
impl Default for OpenCodeProviderConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
npm: "@ai-sdk/openai-compatible".to_string(),
|
||||
name: None,
|
||||
options: OpenCodeProviderOptions::default(),
|
||||
models: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenCode 供应商选项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct OpenCodeProviderOptions {
|
||||
/// API 基础 URL
|
||||
#[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
|
||||
/// API 密钥(支持环境变量引用,如 "{env:API_KEY}")
|
||||
#[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
|
||||
/// 自定义请求头
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
|
||||
/// 额外选项(timeout, setCacheKey 等)
|
||||
/// 使用 flatten 捕获所有未明确定义的字段
|
||||
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenCode 模型定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeModel {
|
||||
/// 模型显示名称
|
||||
pub name: String,
|
||||
|
||||
/// 模型限制(上下文和输出 token 数)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<OpenCodeModelLimit>,
|
||||
|
||||
/// 模型额外选项(provider 路由等)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<HashMap<String, Value>>,
|
||||
}
|
||||
|
||||
/// OpenCode 模型限制
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct OpenCodeModelLimit {
|
||||
/// 上下文 token 限制
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<u64>,
|
||||
|
||||
/// 输出 token 限制
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
|
||||
|
||||
use super::log_codes::cb as log_cb;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -49,10 +50,10 @@ pub struct CircuitBreakerConfig {
|
||||
impl Default for CircuitBreakerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
failure_threshold: 5,
|
||||
failure_threshold: 4,
|
||||
success_threshold: 2,
|
||||
timeout_seconds: 60,
|
||||
error_rate_threshold: 0.5,
|
||||
error_rate_threshold: 0.6,
|
||||
min_requests: 10,
|
||||
}
|
||||
}
|
||||
@@ -106,7 +107,6 @@ impl CircuitBreaker {
|
||||
/// 更新熔断器配置(热更新,不重置状态)
|
||||
pub async fn update_config(&self, new_config: CircuitBreakerConfig) {
|
||||
*self.config.write().await = new_config;
|
||||
log::debug!("Circuit breaker config updated");
|
||||
}
|
||||
|
||||
/// 判断当前 Provider 是否“可被纳入候选链路”
|
||||
@@ -128,7 +128,8 @@ impl CircuitBreaker {
|
||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
log::info!(
|
||||
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
|
||||
"[{}] 熔断器 Open → HalfOpen (超时恢复)",
|
||||
log_cb::OPEN_TO_HALF_OPEN
|
||||
);
|
||||
self.transition_to_half_open().await;
|
||||
return true;
|
||||
@@ -155,7 +156,8 @@ impl CircuitBreaker {
|
||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
log::info!(
|
||||
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
|
||||
"[{}] 熔断器 Open → HalfOpen (超时恢复)",
|
||||
log_cb::OPEN_TO_HALF_OPEN
|
||||
);
|
||||
self.transition_to_half_open().await;
|
||||
|
||||
@@ -197,25 +199,17 @@ impl CircuitBreaker {
|
||||
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 state == CircuitState::HalfOpen {
|
||||
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
if successes >= config.success_threshold {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
log::info!("Circuit breaker transitioning from HalfOpen to Closed (success threshold reached)");
|
||||
self.transition_to_closed().await;
|
||||
}
|
||||
if successes >= config.success_threshold {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
log::info!(
|
||||
"[{}] 熔断器 HalfOpen → Closed (恢复正常)",
|
||||
log_cb::HALF_OPEN_TO_CLOSED
|
||||
);
|
||||
self.transition_to_closed().await;
|
||||
}
|
||||
CircuitState::Closed => {
|
||||
log::debug!("Circuit breaker Closed: request succeeded");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,18 +230,14 @@ impl CircuitBreaker {
|
||||
// 重置成功计数
|
||||
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");
|
||||
log::warn!(
|
||||
"[{}] 熔断器 HalfOpen 探测失败 → Open",
|
||||
log_cb::HALF_OPEN_PROBE_FAILED
|
||||
);
|
||||
drop(config);
|
||||
self.transition_to_open().await;
|
||||
}
|
||||
@@ -255,9 +245,8 @@ impl CircuitBreaker {
|
||||
// 检查连续失败次数
|
||||
if failures >= config.failure_threshold {
|
||||
log::warn!(
|
||||
"Circuit breaker opening due to {} consecutive failures (threshold: {})",
|
||||
failures,
|
||||
config.failure_threshold
|
||||
"[{}] 熔断器触发: 连续失败 {failures} 次 → Open",
|
||||
log_cb::TRIGGERED_FAILURES
|
||||
);
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_open().await;
|
||||
@@ -268,18 +257,12 @@ impl CircuitBreaker {
|
||||
|
||||
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
|
||||
"[{}] 熔断器触发: 错误率 {:.1}% → Open",
|
||||
log_cb::TRIGGERED_ERROR_RATE,
|
||||
error_rate * 100.0
|
||||
);
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_open().await;
|
||||
@@ -312,22 +295,16 @@ impl CircuitBreaker {
|
||||
/// 重置熔断器(手动恢复)
|
||||
#[allow(dead_code)]
|
||||
pub async fn reset(&self) {
|
||||
log::info!("Circuit breaker manually reset to Closed state");
|
||||
log::info!("[{}] 熔断器手动重置 → Closed", log_cb::MANUAL_RESET);
|
||||
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,
|
||||
@@ -335,9 +312,6 @@ impl CircuitBreaker {
|
||||
} 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,
|
||||
@@ -345,12 +319,14 @@ impl CircuitBreaker {
|
||||
}
|
||||
}
|
||||
|
||||
fn release_half_open_permit(&self) {
|
||||
/// 仅释放 HalfOpen permit,不影响健康统计
|
||||
///
|
||||
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
|
||||
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
|
||||
pub 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,24 @@ pub enum ProxyError {
|
||||
#[error("地址绑定失败: {0}")]
|
||||
BindFailed(String),
|
||||
|
||||
#[error("停止超时")]
|
||||
StopTimeout,
|
||||
|
||||
#[error("停止失败: {0}")]
|
||||
StopFailed(String),
|
||||
|
||||
#[error("请求转发失败: {0}")]
|
||||
ForwardFailed(String),
|
||||
|
||||
#[error("无可用的Provider")]
|
||||
NoAvailableProvider,
|
||||
|
||||
#[error("所有供应商已熔断,无可用渠道")]
|
||||
AllProvidersCircuitOpen,
|
||||
|
||||
#[error("未配置供应商")]
|
||||
NoProvidersConfigured,
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[error("Provider不健康: {0}")]
|
||||
ProviderUnhealthy(String),
|
||||
@@ -107,10 +119,22 @@ impl IntoResponse for ProxyError {
|
||||
ProxyError::BindFailed(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
ProxyError::StopTimeout => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
ProxyError::StopFailed(_) => {
|
||||
(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())
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
|
||||
// 无可用 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,
|
||||
|
||||
@@ -57,6 +63,8 @@ pub fn get_error_message(error: &ProxyError) -> String {
|
||||
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}"),
|
||||
|
||||
@@ -81,7 +81,22 @@ impl FailoverSwitchManager {
|
||||
provider_id: &str,
|
||||
provider_name: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})");
|
||||
// 检查该应用是否已被代理接管(enabled=true)
|
||||
// 只有被接管的应用才允许执行故障转移切换
|
||||
let app_enabled = match self.db.get_proxy_config_for_app(app_type).await {
|
||||
Ok(config) => config.enabled,
|
||||
Err(e) => {
|
||||
log::warn!("[FO-002] 无法读取 {app_type} 配置: {e},跳过切换");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
if !app_enabled {
|
||||
log::debug!("[Failover] {app_type} 未启用代理,跳过切换");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
log::info!("[FO-001] 切换: {app_type} → {provider_name}");
|
||||
|
||||
// 1. 更新数据库 is_current
|
||||
self.db.set_current_provider(app_type, provider_id)?;
|
||||
@@ -102,7 +117,7 @@ impl FailoverSwitchManager {
|
||||
.update_live_backup_from_provider(app_type, &provider)
|
||||
.await
|
||||
{
|
||||
log::warn!("[Failover] 更新 Live 备份失败: {e}");
|
||||
log::warn!("[FO-003] Live 备份更新失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,12 +138,10 @@ impl FailoverSwitchManager {
|
||||
"source": "failover" // 标识来源是故障转移
|
||||
});
|
||||
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||
log::error!("[Failover] 发射供应商切换事件失败: {e}");
|
||||
log::error!("[Failover] 发射事件失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("[Failover] 供应商切换完成: {app_type} -> {provider_name} ({provider_id})");
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
+357
-243
@@ -1,22 +1,75 @@
|
||||
//! 请求转发器
|
||||
//!
|
||||
//! 负责将请求转发到上游Provider,支持重试和故障转移
|
||||
//! 负责将请求转发到上游Provider,支持故障转移
|
||||
|
||||
use super::{
|
||||
body_filter::filter_private_params_with_whitelist,
|
||||
error::*,
|
||||
failover_switch::FailoverSwitchManager,
|
||||
provider_router::ProviderRouter,
|
||||
providers::{get_adapter, ProviderAdapter},
|
||||
types::ProxyStatus,
|
||||
providers::{get_adapter, ProviderAdapter, ProviderType},
|
||||
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
|
||||
types::{ProxyStatus, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use crate::{app_config::AppType, provider::Provider};
|
||||
use reqwest::{Client, Response};
|
||||
use reqwest::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,
|
||||
@@ -28,11 +81,8 @@ pub struct ForwardError {
|
||||
}
|
||||
|
||||
pub struct RequestForwarder {
|
||||
client: Client,
|
||||
/// 共享的 ProviderRouter(持有熔断器状态)
|
||||
router: Arc<ProviderRouter>,
|
||||
/// 单个 Provider 内的最大重试次数
|
||||
max_retries: u8,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
/// 故障转移切换管理器
|
||||
@@ -41,6 +91,10 @@ pub struct RequestForwarder {
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
||||
current_provider_id_at_start: String,
|
||||
/// 整流器配置
|
||||
rectifier_config: RectifierConfig,
|
||||
/// 非流式请求超时(秒)
|
||||
non_streaming_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl RequestForwarder {
|
||||
@@ -48,7 +102,6 @@ impl RequestForwarder {
|
||||
pub fn new(
|
||||
router: Arc<ProviderRouter>,
|
||||
non_streaming_timeout: u64,
|
||||
max_retries: u8,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
failover_manager: Arc<FailoverSwitchManager>,
|
||||
@@ -56,89 +109,20 @@ impl RequestForwarder {
|
||||
current_provider_id_at_start: String,
|
||||
_streaming_first_byte_timeout: u64,
|
||||
_streaming_idle_timeout: u64,
|
||||
rectifier_config: RectifierConfig,
|
||||
) -> 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,
|
||||
max_retries,
|
||||
status,
|
||||
current_providers,
|
||||
failover_manager,
|
||||
app_handle,
|
||||
current_provider_id_at_start,
|
||||
rectifier_config,
|
||||
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||
}
|
||||
}
|
||||
|
||||
/// 对单个 Provider 执行请求(带重试)
|
||||
///
|
||||
/// 在同一个 Provider 上最多重试 max_retries 次,使用指数退避
|
||||
async fn forward_with_provider_retry(
|
||||
&self,
|
||||
provider: &Provider,
|
||||
endpoint: &str,
|
||||
body: &Value,
|
||||
headers: &axum::http::HeaderMap,
|
||||
adapter: &dyn ProviderAdapter,
|
||||
) -> Result<Response, ProxyError> {
|
||||
let mut last_error = None;
|
||||
|
||||
for attempt in 0..=self.max_retries {
|
||||
if attempt > 0 {
|
||||
// 指数退避:100ms, 200ms, 400ms, ...
|
||||
let delay_ms = 100 * 2u64.pow(attempt as u32 - 1);
|
||||
log::info!(
|
||||
"[{}] 重试第 {}/{} 次(等待 {}ms)",
|
||||
adapter.name(),
|
||||
attempt,
|
||||
self.max_retries,
|
||||
delay_ms
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
|
||||
match self
|
||||
.forward(provider, endpoint, body, headers, adapter)
|
||||
.await
|
||||
{
|
||||
Ok(response) => return Ok(response),
|
||||
Err(e) => {
|
||||
// 只有“同一 Provider 内可重试”的错误才继续重试
|
||||
if !self.should_retry_same_provider(&e) {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"[{}] Provider {} 第 {} 次请求失败: {}",
|
||||
adapter.name(),
|
||||
provider.name,
|
||||
attempt + 1,
|
||||
e
|
||||
);
|
||||
last_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded))
|
||||
}
|
||||
|
||||
/// 转发请求(带故障转移)
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -151,7 +135,7 @@ impl RequestForwarder {
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
endpoint: &str,
|
||||
body: Value,
|
||||
mut body: Value,
|
||||
headers: axum::http::HeaderMap,
|
||||
providers: Vec<Provider>,
|
||||
) -> Result<ForwardResult, ForwardError> {
|
||||
@@ -166,16 +150,13 @@ impl RequestForwarder {
|
||||
});
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] 故障转移链: {} 个可用供应商",
|
||||
app_type_str,
|
||||
providers.len()
|
||||
);
|
||||
|
||||
let mut last_error = None;
|
||||
let mut last_provider = None;
|
||||
let mut attempted_providers = 0usize;
|
||||
|
||||
// 整流器重试标记:确保整流最多触发一次
|
||||
let mut rectifier_retried = false;
|
||||
|
||||
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||
let bypass_circuit_breaker = providers.len() == 1;
|
||||
|
||||
@@ -194,25 +175,11 @@ impl RequestForwarder {
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -222,18 +189,14 @@ impl RequestForwarder {
|
||||
status.last_request_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
// 转发请求(带单 Provider 内重试)
|
||||
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
|
||||
match self
|
||||
.forward_with_provider_retry(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
|
||||
// 成功:记录成功并更新熔断器
|
||||
if let Err(e) = self
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
@@ -242,10 +205,7 @@ impl RequestForwarder {
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to record success: {e}");
|
||||
}
|
||||
.await;
|
||||
|
||||
// 更新当前应用类型使用的 provider
|
||||
{
|
||||
@@ -265,12 +225,6 @@ impl RequestForwarder {
|
||||
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();
|
||||
@@ -280,10 +234,7 @@ impl RequestForwarder {
|
||||
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}");
|
||||
}
|
||||
let _ = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await;
|
||||
});
|
||||
}
|
||||
// 重新计算成功率
|
||||
@@ -294,23 +245,213 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
// 检测是否需要触发整流器(仅 Claude/ClaudeAuth 供应商)
|
||||
let provider_type = ProviderType::from_app_type_and_config(app_type, provider);
|
||||
let is_anthropic_provider = matches!(
|
||||
provider_type,
|
||||
ProviderType::Claude | ProviderType::ClaudeAuth
|
||||
);
|
||||
|
||||
if is_anthropic_provider {
|
||||
let error_message = extract_error_message(&e);
|
||||
if should_rectify_thinking_signature(
|
||||
error_message.as_deref(),
|
||||
&self.rectifier_config,
|
||||
) {
|
||||
// 已经重试过:直接返回错误(不可重试客户端错误)
|
||||
if rectifier_retried {
|
||||
log::warn!("[{app_type_str}] [RECT-005] 整流器已触发过,不再重试");
|
||||
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
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;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
// 首次触发:整流请求体
|
||||
let rectified = rectify_anthropic_request(&mut body);
|
||||
|
||||
// 整流未生效:直接返回错误(不可重试客户端错误)
|
||||
if !rectified.applied {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
|
||||
);
|
||||
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
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;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
|
||||
app_type_str,
|
||||
rectified.removed_thinking_blocks,
|
||||
rectified.removed_redacted_thinking_blocks,
|
||||
rectified.removed_signature_fields
|
||||
);
|
||||
|
||||
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
|
||||
let _ = std::mem::replace(&mut rectifier_retried, true);
|
||||
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||
// 记录成功
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 更新当前应用类型使用的 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;
|
||||
|
||||
// 异步触发供应商切换,更新 UI/托盘
|
||||
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 {
|
||||
let _ = fm
|
||||
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
// 整流重试仍失败:区分错误类型决定是否记录熔断器
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
|
||||
);
|
||||
|
||||
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
|
||||
let is_provider_error = match &retry_err {
|
||||
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
|
||||
true
|
||||
}
|
||||
ProxyError::UpstreamError { status, .. } => *status >= 500,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_provider_error {
|
||||
// Provider 问题:记录失败到熔断器
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
false,
|
||||
Some(retry_err.to_string()),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
// 客户端问题:仅释放 permit,不记录熔断器
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(retry_err.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: retry_err,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 失败:记录失败并更新熔断器
|
||||
if let Err(record_err) = self
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
@@ -319,10 +460,7 @@ impl RequestForwarder {
|
||||
false,
|
||||
Some(e.to_string()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to record failure: {record_err}");
|
||||
}
|
||||
.await;
|
||||
|
||||
// 分类错误
|
||||
let category = self.categorize_proxy_error(&e);
|
||||
@@ -337,11 +475,11 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"[{}] Provider {} 失败(可重试): {} - {}ms",
|
||||
"[{}] [FWD-001] Provider {} 失败,切换下一个 ({}/{})",
|
||||
app_type_str,
|
||||
provider.name,
|
||||
e,
|
||||
latency
|
||||
attempted_providers,
|
||||
providers.len()
|
||||
);
|
||||
|
||||
last_error = Some(e);
|
||||
@@ -361,12 +499,6 @@ impl RequestForwarder {
|
||||
* 100.0;
|
||||
}
|
||||
}
|
||||
log::error!(
|
||||
"[{}] Provider {} 失败(不可重试): {}",
|
||||
app_type_str,
|
||||
provider.name,
|
||||
e
|
||||
);
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
@@ -405,11 +537,7 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
log::error!(
|
||||
"[{}] 所有 {} 个供应商都失败了",
|
||||
app_type_str,
|
||||
providers.len()
|
||||
);
|
||||
log::warn!("[{app_type_str}] [FWD-002] 所有 Provider 均失败");
|
||||
|
||||
Err(ForwardError {
|
||||
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
|
||||
@@ -428,7 +556,6 @@ impl RequestForwarder {
|
||||
) -> 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);
|
||||
@@ -443,94 +570,100 @@ impl RequestForwarder {
|
||||
// 使用适配器构建 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) =
|
||||
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
|
||||
adapter.transform_request(mapped_body, provider)?
|
||||
} else {
|
||||
mapped_body
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"[{}] 转发请求: {} -> {}",
|
||||
adapter.name(),
|
||||
provider.name,
|
||||
url
|
||||
);
|
||||
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
|
||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
||||
|
||||
// 构建请求
|
||||
let mut request = self.client.post(&url);
|
||||
// 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置)
|
||||
let client = super::http_client::get();
|
||||
let mut request = client.post(&url);
|
||||
|
||||
// 只透传必要的 Headers(白名单模式)
|
||||
let allowed_headers = [
|
||||
"accept",
|
||||
"user-agent",
|
||||
"x-request-id",
|
||||
"x-stainless-arch",
|
||||
"x-stainless-lang",
|
||||
"x-stainless-os",
|
||||
"x-stainless-package-version",
|
||||
"x-stainless-runtime",
|
||||
"x-stainless-runtime-version",
|
||||
];
|
||||
// 只有当 timeout > 0 时才设置请求超时
|
||||
// Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时"
|
||||
// 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒)
|
||||
if !self.non_streaming_timeout.is_zero() {
|
||||
request = request.timeout(self.non_streaming_timeout);
|
||||
}
|
||||
|
||||
// 过滤黑名单 Headers,保护隐私并避免冲突
|
||||
for (key, value) in headers {
|
||||
let key_str = key.as_str().to_lowercase();
|
||||
if allowed_headers.contains(&key_str.as_str()) {
|
||||
request = request.header(key, value);
|
||||
if HEADER_BLACKLIST
|
||||
.iter()
|
||||
.any(|h| key.as_str().eq_ignore_ascii_case(h))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
request = request.header(key, value);
|
||||
}
|
||||
|
||||
// 处理 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);
|
||||
}
|
||||
|
||||
// 客户端 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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 Content-Type 是 json
|
||||
request = request.header("Content-Type", "application/json");
|
||||
// 禁用压缩,避免 gzip 流式响应解析错误
|
||||
// 参考 CCH: undici 在连接提前关闭时会对不完整的 gzip 流抛出错误
|
||||
request = request.header("accept-encoding", "identity");
|
||||
|
||||
// 使用适配器添加认证头
|
||||
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);
|
||||
} 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);
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
log::info!("[{}] 发送请求到: {}", adapter.name(), url);
|
||||
let response = request.json(&request_body).send().await.map_err(|e| {
|
||||
log::error!("[{}] 请求失败: {}", adapter.name(), e);
|
||||
let response = request.json(&filtered_body).send().await.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
ProxyError::Timeout(format!("请求超时: {e}"))
|
||||
} else if e.is_connect() {
|
||||
@@ -542,19 +675,12 @@ impl RequestForwarder {
|
||||
|
||||
// 检查响应状态
|
||||
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,
|
||||
@@ -563,25 +689,6 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类ProxyError
|
||||
///
|
||||
/// 决定哪些错误应该触发故障转移到下一个 Provider
|
||||
///
|
||||
/// 设计原则:既然用户配置了多个供应商,就应该让所有供应商都尝试一遍。
|
||||
/// 只有明确是客户端中断的情况才不重试。
|
||||
fn should_retry_same_provider(&self, error: &ProxyError) -> bool {
|
||||
match error {
|
||||
// 网络类错误:短暂抖动时同一 Provider 内重试有意义
|
||||
ProxyError::Timeout(_) => true,
|
||||
ProxyError::ForwardFailed(_) => true,
|
||||
// 上游 HTTP 错误:只对“可能瞬态”的状态码做同 Provider 重试(其余交给 failover)
|
||||
ProxyError::UpstreamError { status, .. } => {
|
||||
*status == 408 || *status == 429 || *status >= 500
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
|
||||
match error {
|
||||
// 网络和上游错误:都应该尝试下一个供应商
|
||||
@@ -597,7 +704,6 @@ impl RequestForwarder {
|
||||
ProxyError::TransformError(_) => ErrorCategory::Retryable,
|
||||
ProxyError::AuthError(_) => ErrorCategory::Retryable,
|
||||
ProxyError::StreamIdleTimeout(_) => ErrorCategory::Retryable,
|
||||
ProxyError::MaxRetriesExceeded => ErrorCategory::Retryable,
|
||||
// 无可用供应商:所有供应商都试过了,无法重试
|
||||
ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
|
||||
// 其他错误(数据库/内部错误等):不是换供应商能解决的问题
|
||||
@@ -605,3 +711,11 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 ProxyError 中提取错误消息
|
||||
fn extract_error_message(error: &ProxyError) -> Option<String> {
|
||||
match error {
|
||||
ProxyError::UpstreamError { body, .. } => body.clone(),
|
||||
_ => Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,10 +58,10 @@ fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Codex Responses API 流式响应模型提取(优先使用 usage.model)
|
||||
fn codex_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
/// Codex 智能流式响应模型提取(自动检测格式)
|
||||
fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
// 首先尝试从解析的 usage 中获取模型
|
||||
if let Some(usage) = TokenUsage::from_codex_stream_events(events) {
|
||||
if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) {
|
||||
if let Some(model) = usage.model {
|
||||
return model;
|
||||
}
|
||||
@@ -76,6 +76,10 @@ fn codex_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
// 再回退:从 OpenAI 格式事件中提取
|
||||
events.iter().find_map(|e| e.get("model")?.as_str())
|
||||
})
|
||||
.unwrap_or(request_model)
|
||||
.to_string()
|
||||
}
|
||||
@@ -111,11 +115,11 @@ pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||
app_type_str: "codex",
|
||||
};
|
||||
|
||||
/// Codex Responses API 解析配置(用于 /v1/responses)
|
||||
/// Codex 智能解析配置(自动检测 OpenAI 或 Codex 格式)
|
||||
pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||
stream_parser: TokenUsage::from_codex_stream_events,
|
||||
response_parser: TokenUsage::from_codex_response,
|
||||
model_extractor: codex_model_extractor,
|
||||
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",
|
||||
};
|
||||
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::{
|
||||
forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, ProxyError,
|
||||
extract_session_id,
|
||||
forwarder::RequestForwarder,
|
||||
server::ProxyState,
|
||||
types::{AppProxyConfig, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use axum::http::HeaderMap;
|
||||
use std::time::Instant;
|
||||
|
||||
/// 流式超时配置
|
||||
@@ -26,6 +31,7 @@ pub struct StreamingTimeoutConfig {
|
||||
/// - 选中的 Provider 列表(用于故障转移)
|
||||
/// - 请求模型名称
|
||||
/// - 日志标签
|
||||
/// - Session ID(用于日志关联)
|
||||
pub struct RequestContext {
|
||||
/// 请求开始时间
|
||||
pub start_time: Instant,
|
||||
@@ -35,7 +41,7 @@ pub struct RequestContext {
|
||||
pub provider: Provider,
|
||||
/// 完整的 Provider 列表(用于故障转移)
|
||||
providers: Vec<Provider>,
|
||||
/// 请求开始时的“当前供应商”(用于判断是否需要同步 UI/托盘)
|
||||
/// 请求开始时的"当前供应商"(用于判断是否需要同步 UI/托盘)
|
||||
///
|
||||
/// 这里使用本地 settings 的设备级 current provider。
|
||||
/// 代理模式下如果实际使用的 provider 与此不一致,会触发切换以确保 UI 始终准确。
|
||||
@@ -49,6 +55,10 @@ pub struct RequestContext {
|
||||
/// 应用类型(预留,目前通过 app_type_str 使用)
|
||||
#[allow(dead_code)]
|
||||
pub app_type: AppType,
|
||||
/// Session ID(从客户端请求提取或新生成)
|
||||
pub session_id: String,
|
||||
/// 整流器配置
|
||||
pub rectifier_config: RectifierConfig,
|
||||
}
|
||||
|
||||
impl RequestContext {
|
||||
@@ -57,6 +67,7 @@ impl RequestContext {
|
||||
/// # Arguments
|
||||
/// * `state` - 代理服务器状态
|
||||
/// * `body` - 请求体 JSON
|
||||
/// * `headers` - 请求头(用于提取 Session ID)
|
||||
/// * `app_type` - 应用类型
|
||||
/// * `tag` - 日志标签
|
||||
/// * `app_type_str` - 应用类型字符串
|
||||
@@ -66,6 +77,7 @@ impl RequestContext {
|
||||
pub async fn new(
|
||||
state: &ProxyState,
|
||||
body: &serde_json::Value,
|
||||
headers: &HeaderMap,
|
||||
app_type: AppType,
|
||||
tag: &'static str,
|
||||
app_type_str: &'static str,
|
||||
@@ -79,6 +91,9 @@ impl RequestContext {
|
||||
.await
|
||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||
|
||||
// 从数据库读取整流器配置
|
||||
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
|
||||
|
||||
let current_provider_id =
|
||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||
|
||||
@@ -89,25 +104,44 @@ impl RequestContext {
|
||||
.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| ProxyError::DatabaseError(e.to_string()))?;
|
||||
.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",
|
||||
log::debug!(
|
||||
"[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}",
|
||||
tag,
|
||||
provider.name,
|
||||
request_model,
|
||||
providers.len()
|
||||
providers.len(),
|
||||
session_id
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
@@ -120,6 +154,8 @@ impl RequestContext {
|
||||
tag,
|
||||
app_type_str,
|
||||
app_type,
|
||||
session_id,
|
||||
rectifier_config,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -141,25 +177,45 @@ impl RequestContext {
|
||||
.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::debug!(
|
||||
"[{}] Failover disabled, timeout configs are bypassed",
|
||||
self.tag
|
||||
);
|
||||
(0, 0, 0)
|
||||
};
|
||||
|
||||
RequestForwarder::new(
|
||||
state.provider_router.clone(),
|
||||
self.app_config.non_streaming_timeout as u64,
|
||||
self.app_config.max_retries as u8,
|
||||
non_streaming_timeout,
|
||||
state.status.clone(),
|
||||
state.current_providers.clone(),
|
||||
state.failover_manager.clone(),
|
||||
state.app_handle.clone(),
|
||||
self.current_provider_id.clone(),
|
||||
self.app_config.streaming_first_byte_timeout as u64,
|
||||
self.app_config.streaming_idle_timeout as u64,
|
||||
first_byte_timeout,
|
||||
idle_timeout,
|
||||
self.rectifier_config.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,11 +233,24 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
/// 获取流式超时配置
|
||||
///
|
||||
/// 配置生效规则:
|
||||
/// - 故障转移开启:返回配置的值(0 表示禁用超时检查)
|
||||
/// - 故障转移关闭:返回 0(禁用超时检查)
|
||||
#[inline]
|
||||
pub fn streaming_timeout_config(&self) -> StreamingTimeoutConfig {
|
||||
StreamingTimeoutConfig {
|
||||
first_byte_timeout: self.app_config.streaming_first_byte_timeout as u64,
|
||||
idle_timeout: self.app_config.streaming_idle_timeout as u64,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,8 @@ pub async fn handle_messages(
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -97,16 +98,6 @@ pub async fn handle_messages(
|
||||
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;
|
||||
@@ -130,8 +121,6 @@ async fn handle_claude_transform(
|
||||
|
||||
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);
|
||||
|
||||
@@ -195,13 +184,10 @@ async fn handle_claude_transform(
|
||||
);
|
||||
|
||||
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| {
|
||||
@@ -210,31 +196,17 @@ async fn handle_claude_transform(
|
||||
})?;
|
||||
|
||||
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
|
||||
@@ -264,8 +236,6 @@ async fn handle_claude_transform(
|
||||
});
|
||||
}
|
||||
|
||||
log::info!("[Claude] ====== 请求结束 ======");
|
||||
|
||||
// 构建响应
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
|
||||
@@ -284,13 +254,11 @@ async fn handle_claude_transform(
|
||||
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())
|
||||
builder.body(body).map_err(|e| {
|
||||
log::error!("[Claude] 构建响应失败: {e}");
|
||||
ProxyError::Internal(format!("Failed to build response: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -303,21 +271,14 @@ pub async fn handle_chat_completions(
|
||||
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, AppType::Codex, "Codex", "codex").await?;
|
||||
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(
|
||||
@@ -342,8 +303,6 @@ pub async fn handle_chat_completions(
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||
|
||||
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
@@ -353,7 +312,8 @@ pub async fn handle_responses(
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -384,8 +344,6 @@ pub async fn handle_responses(
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||
|
||||
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
@@ -401,7 +359,7 @@ pub async fn handle_gemini(
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
// Gemini 的模型名称在 URI 中
|
||||
let mut ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
|
||||
let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini")
|
||||
.await?
|
||||
.with_model_from_uri(&uri);
|
||||
|
||||
@@ -411,8 +369,6 @@ pub async fn handle_gemini(
|
||||
.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())
|
||||
@@ -442,8 +398,6 @@ pub async fn handle_gemini(
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
log::info!("[Gemini] 上游响应状态: {}", response.status());
|
||||
|
||||
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
@@ -465,7 +419,7 @@ fn log_forward_error(
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
if let Err(e) = logger.log_error_with_context(
|
||||
request_id.clone(),
|
||||
request_id,
|
||||
ctx.provider.id.clone(),
|
||||
ctx.app_type_str.to_string(),
|
||||
ctx.request_model.clone(),
|
||||
@@ -473,7 +427,7 @@ fn log_forward_error(
|
||||
error_message,
|
||||
ctx.latency_ms(),
|
||||
is_streaming,
|
||||
Some(request_id),
|
||||
Some(ctx.session_id.clone()),
|
||||
None,
|
||||
) {
|
||||
log::warn!("记录失败请求日志失败: {e}");
|
||||
@@ -502,7 +456,12 @@ async fn log_usage(
|
||||
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))
|
||||
Decimal::from_str(&cm).unwrap_or_else(|e| {
|
||||
log::warn!(
|
||||
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
|
||||
);
|
||||
Decimal::from(1)
|
||||
})
|
||||
} else {
|
||||
Decimal::from(1)
|
||||
}
|
||||
@@ -529,6 +488,6 @@ async fn log_usage(
|
||||
None, // provider_type
|
||||
is_streaming,
|
||||
) {
|
||||
log::warn!("记录使用量失败: {e}");
|
||||
log::warn!("[USG-001] 记录使用量失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
//! 全局 HTTP 客户端模块
|
||||
//!
|
||||
//! 提供支持全局代理配置的 HTTP 客户端。
|
||||
//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use reqwest::Client;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 全局 HTTP 客户端实例
|
||||
static GLOBAL_CLIENT: OnceCell<RwLock<Client>> = OnceCell::new();
|
||||
|
||||
/// 当前代理 URL(用于日志和状态查询)
|
||||
static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
|
||||
|
||||
/// 初始化全局 HTTP 客户端
|
||||
///
|
||||
/// 应在应用启动时调用一次。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_url` - 代理 URL,如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080`
|
||||
/// 传入 None 或空字符串表示直连
|
||||
pub fn init(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
let client = build_client(effective_url)?;
|
||||
|
||||
// 尝试初始化全局客户端,如果已存在则记录警告并使用 apply_proxy 更新
|
||||
if GLOBAL_CLIENT.set(RwLock::new(client.clone())).is_err() {
|
||||
log::warn!(
|
||||
"[GlobalProxy] [GP-003] Already initialized, updating instead: {}",
|
||||
effective_url
|
||||
.map(mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
// 已初始化,改用 apply_proxy 更新
|
||||
return apply_proxy(proxy_url);
|
||||
}
|
||||
|
||||
// 初始化代理 URL 记录
|
||||
let _ = CURRENT_PROXY_URL.set(RwLock::new(effective_url.map(|s| s.to_string())));
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] Initialized: {}",
|
||||
effective_url
|
||||
.map(mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证代理配置(不应用)
|
||||
///
|
||||
/// 只验证代理 URL 是否有效,不实际更新全局客户端。
|
||||
/// 用于在持久化之前验证配置的有效性。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_url` - 代理 URL,None 或空字符串表示直连
|
||||
///
|
||||
/// # Returns
|
||||
/// 验证成功返回 Ok(()),失败返回错误信息
|
||||
pub fn validate_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
// 只调用 build_client 来验证,但不应用
|
||||
build_client(effective_url)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 应用代理配置(假设已验证)
|
||||
///
|
||||
/// 直接应用代理配置到全局客户端,不做额外验证。
|
||||
/// 应在 validate_proxy 成功后调用。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_url` - 代理 URL,None 或空字符串表示直连
|
||||
pub fn apply_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
let new_client = build_client(effective_url)?;
|
||||
|
||||
// 更新客户端
|
||||
if let Some(lock) = GLOBAL_CLIENT.get() {
|
||||
let mut client = lock.write().map_err(|e| {
|
||||
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
|
||||
"Failed to update proxy: lock poisoned".to_string()
|
||||
})?;
|
||||
*client = new_client;
|
||||
} else {
|
||||
// 如果还没初始化,则初始化
|
||||
return init(proxy_url);
|
||||
}
|
||||
|
||||
// 更新代理 URL 记录
|
||||
if let Some(lock) = CURRENT_PROXY_URL.get() {
|
||||
let mut url = lock.write().map_err(|e| {
|
||||
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
|
||||
"Failed to update proxy URL record: lock poisoned".to_string()
|
||||
})?;
|
||||
*url = effective_url.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] Applied: {}",
|
||||
effective_url
|
||||
.map(mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新代理配置(热更新)
|
||||
///
|
||||
/// 可在运行时调用以更改代理设置,无需重启应用。
|
||||
/// 注意:此函数同时验证和应用,如果需要先验证后持久化再应用,
|
||||
/// 请使用 validate_proxy + apply_proxy 组合。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy_url` - 新的代理 URL,None 或空字符串表示直连
|
||||
#[allow(dead_code)]
|
||||
pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
let new_client = build_client(effective_url)?;
|
||||
|
||||
// 更新客户端
|
||||
if let Some(lock) = GLOBAL_CLIENT.get() {
|
||||
let mut client = lock.write().map_err(|e| {
|
||||
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
|
||||
"Failed to update proxy: lock poisoned".to_string()
|
||||
})?;
|
||||
*client = new_client;
|
||||
} else {
|
||||
// 如果还没初始化,则初始化
|
||||
return init(proxy_url);
|
||||
}
|
||||
|
||||
// 更新代理 URL 记录
|
||||
if let Some(lock) = CURRENT_PROXY_URL.get() {
|
||||
let mut url = lock.write().map_err(|e| {
|
||||
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
|
||||
"Failed to update proxy URL record: lock poisoned".to_string()
|
||||
})?;
|
||||
*url = effective_url.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] Updated: {}",
|
||||
effective_url
|
||||
.map(mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取全局 HTTP 客户端
|
||||
///
|
||||
/// 返回配置了代理的客户端(如果已配置代理),否则返回直连客户端。
|
||||
pub fn get() -> Client {
|
||||
GLOBAL_CLIENT
|
||||
.get()
|
||||
.and_then(|lock| lock.read().ok())
|
||||
.map(|c| c.clone())
|
||||
.unwrap_or_else(|| {
|
||||
// 如果还没初始化,创建一个默认客户端(配置与 build_client 一致)
|
||||
log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback");
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(600))
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(60))
|
||||
.no_proxy()
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取当前代理 URL
|
||||
///
|
||||
/// 返回当前配置的代理 URL,None 表示直连。
|
||||
pub fn get_current_proxy_url() -> Option<String> {
|
||||
CURRENT_PROXY_URL
|
||||
.get()
|
||||
.and_then(|lock| lock.read().ok())
|
||||
.and_then(|url| url.clone())
|
||||
}
|
||||
|
||||
/// 检查是否正在使用代理
|
||||
#[allow(dead_code)]
|
||||
pub fn is_proxy_enabled() -> bool {
|
||||
get_current_proxy_url().is_some()
|
||||
}
|
||||
|
||||
/// 构建 HTTP 客户端
|
||||
fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
||||
let mut builder = Client::builder()
|
||||
.timeout(Duration::from_secs(600))
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(60));
|
||||
|
||||
// 有代理地址则使用代理,否则直连
|
||||
if let Some(url) = proxy_url {
|
||||
// 先验证 URL 格式和 scheme
|
||||
let parsed = url::Url::parse(url)
|
||||
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
|
||||
|
||||
let scheme = parsed.scheme();
|
||||
if !["http", "https", "socks5", "socks5h"].contains(&scheme) {
|
||||
return Err(format!(
|
||||
"Invalid proxy scheme '{}' in URL '{}'. Supported: http, https, socks5, socks5h",
|
||||
scheme,
|
||||
mask_url(url)
|
||||
));
|
||||
}
|
||||
|
||||
let proxy = reqwest::Proxy::all(url)
|
||||
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
|
||||
builder = builder.proxy(proxy);
|
||||
log::debug!("[GlobalProxy] Proxy configured: {}", mask_url(url));
|
||||
} else {
|
||||
builder = builder.no_proxy();
|
||||
log::debug!("[GlobalProxy] Direct connection (no proxy)");
|
||||
}
|
||||
|
||||
builder
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))
|
||||
}
|
||||
|
||||
/// 隐藏 URL 中的敏感信息(用于日志)
|
||||
pub fn mask_url(url: &str) -> String {
|
||||
if let Ok(parsed) = url::Url::parse(url) {
|
||||
// 隐藏用户名和密码,保留 scheme、host 和端口
|
||||
let host = parsed.host_str().unwrap_or("?");
|
||||
match parsed.port() {
|
||||
Some(port) => format!("{}://{}:{}", parsed.scheme(), host, port),
|
||||
None => format!("{}://{}", parsed.scheme(), host),
|
||||
}
|
||||
} else {
|
||||
// URL 解析失败,返回部分内容
|
||||
if url.len() > 20 {
|
||||
format!("{}...", &url[..20])
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mask_url() {
|
||||
assert_eq!(mask_url("http://127.0.0.1:7890"), "http://127.0.0.1:7890");
|
||||
assert_eq!(
|
||||
mask_url("http://user:pass@127.0.0.1:7890"),
|
||||
"http://127.0.0.1:7890"
|
||||
);
|
||||
assert_eq!(
|
||||
mask_url("socks5://admin:secret@proxy.example.com:1080"),
|
||||
"socks5://proxy.example.com:1080"
|
||||
);
|
||||
// 无端口的 URL 不应显示 ":?"
|
||||
assert_eq!(
|
||||
mask_url("http://proxy.example.com"),
|
||||
"http://proxy.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
mask_url("https://user:pass@proxy.example.com"),
|
||||
"https://proxy.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_client_direct() {
|
||||
let result = build_client(None);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_client_with_http_proxy() {
|
||||
let result = build_client(Some("http://127.0.0.1:7890"));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_client_with_socks5_proxy() {
|
||||
let result = build_client(Some("socks5://127.0.0.1:1080"));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_client_invalid_url() {
|
||||
// reqwest::Proxy::all 对某些无效 URL 不会立即报错
|
||||
// 使用明确无效的 scheme 来触发错误
|
||||
let result = build_client(Some("invalid-scheme://127.0.0.1:7890"));
|
||||
assert!(result.is_err(), "Should reject invalid proxy scheme");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! 代理模块日志错误码定义
|
||||
//!
|
||||
//! 格式: [模块-编号] 消息
|
||||
//! - CB: Circuit Breaker (熔断器)
|
||||
//! - SRV: Server (服务器)
|
||||
//! - FWD: Forwarder (转发器)
|
||||
//! - FO: Failover (故障转移)
|
||||
//! - RSP: Response (响应处理)
|
||||
//! - USG: Usage (使用量)
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
/// 熔断器日志码
|
||||
pub mod cb {
|
||||
pub const OPEN_TO_HALF_OPEN: &str = "CB-001";
|
||||
pub const HALF_OPEN_TO_CLOSED: &str = "CB-002";
|
||||
pub const HALF_OPEN_PROBE_FAILED: &str = "CB-003";
|
||||
pub const TRIGGERED_FAILURES: &str = "CB-004";
|
||||
pub const TRIGGERED_ERROR_RATE: &str = "CB-005";
|
||||
pub const MANUAL_RESET: &str = "CB-006";
|
||||
}
|
||||
|
||||
/// 服务器日志码
|
||||
pub mod srv {
|
||||
pub const STARTED: &str = "SRV-001";
|
||||
pub const STOPPED: &str = "SRV-002";
|
||||
pub const STOP_TIMEOUT: &str = "SRV-003";
|
||||
pub const TASK_ERROR: &str = "SRV-004";
|
||||
}
|
||||
|
||||
/// 转发器日志码
|
||||
pub mod fwd {
|
||||
pub const PROVIDER_FAILED_RETRY: &str = "FWD-001";
|
||||
pub const ALL_PROVIDERS_FAILED: &str = "FWD-002";
|
||||
}
|
||||
|
||||
/// 故障转移日志码
|
||||
pub mod fo {
|
||||
pub const SWITCH_SUCCESS: &str = "FO-001";
|
||||
pub const CONFIG_READ_ERROR: &str = "FO-002";
|
||||
pub const LIVE_BACKUP_ERROR: &str = "FO-003";
|
||||
pub const ALL_CIRCUIT_OPEN: &str = "FO-004";
|
||||
pub const NO_PROVIDERS: &str = "FO-005";
|
||||
}
|
||||
|
||||
/// 响应处理日志码
|
||||
pub mod rsp {
|
||||
pub const BUILD_STREAM_ERROR: &str = "RSP-001";
|
||||
pub const READ_BODY_ERROR: &str = "RSP-002";
|
||||
pub const BUILD_RESPONSE_ERROR: &str = "RSP-003";
|
||||
pub const STREAM_TIMEOUT: &str = "RSP-004";
|
||||
pub const STREAM_ERROR: &str = "RSP-005";
|
||||
}
|
||||
|
||||
/// 使用量日志码
|
||||
pub mod usg {
|
||||
pub const LOG_FAILED: &str = "USG-001";
|
||||
pub const PRICING_NOT_FOUND: &str = "USG-002";
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
|
||||
|
||||
pub mod body_filter;
|
||||
pub mod circuit_breaker;
|
||||
pub mod error;
|
||||
pub mod error_mapper;
|
||||
@@ -11,6 +12,8 @@ pub mod handler_config;
|
||||
pub mod handler_context;
|
||||
mod handlers;
|
||||
mod health;
|
||||
pub mod http_client;
|
||||
pub mod log_codes;
|
||||
pub mod model_mapper;
|
||||
pub mod provider_router;
|
||||
pub mod providers;
|
||||
@@ -18,6 +21,7 @@ pub mod response_handler;
|
||||
pub mod response_processor;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub mod thinking_rectifier;
|
||||
pub(crate) mod types;
|
||||
pub mod usage;
|
||||
|
||||
@@ -33,7 +37,9 @@ pub use provider_router::ProviderRouter;
|
||||
#[allow(unused_imports)]
|
||||
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
|
||||
#[allow(unused_imports)]
|
||||
pub use session::{ClientFormat, ProxySession};
|
||||
pub use session::{
|
||||
extract_session_id, ClientFormat, ProxySession, SessionIdResult, SessionIdSource,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ impl ModelMapping {
|
||||
|| self.sonnet_model.is_some()
|
||||
|| self.opus_model.is_some()
|
||||
|| self.default_model.is_some()
|
||||
|| self.reasoning_model.is_some()
|
||||
}
|
||||
|
||||
/// 根据原始模型名称获取映射后的模型
|
||||
@@ -126,7 +127,7 @@ pub fn apply_model_mapping(
|
||||
let mapped = mapping.map_model(original, has_thinking);
|
||||
|
||||
if mapped != *original {
|
||||
log::info!("[ModelMapper] 模型映射: {original} → {mapped}");
|
||||
log::debug!("[ModelMapper] 模型映射: {original} → {mapped}");
|
||||
body["model"] = serde_json::json!(mapped);
|
||||
return (body, Some(original.clone()), Some(mapped));
|
||||
}
|
||||
@@ -182,6 +183,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -222,6 +244,31 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -34,18 +34,14 @@ impl ProviderRouter {
|
||||
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
|
||||
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
|
||||
}
|
||||
Ok(config) => config.auto_failover_enabled,
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
|
||||
);
|
||||
log::error!("[{app_type}] 读取 proxy_config 失败: {e},默认禁用故障转移");
|
||||
false
|
||||
}
|
||||
};
|
||||
@@ -53,64 +49,38 @@ impl ProviderRouter {
|
||||
if auto_failover_enabled {
|
||||
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
|
||||
let failover_providers = self.db.get_failover_providers(app_type)?;
|
||||
log::info!(
|
||||
"[{}] Failover enabled, using queue order ({} items)",
|
||||
app_type,
|
||||
failover_providers.len()
|
||||
);
|
||||
total_providers = failover_providers.len();
|
||||
|
||||
for provider in failover_providers {
|
||||
// 检查熔断器状态
|
||||
let circuit_key = format!("{}:{}", app_type, provider.id);
|
||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||
|
||||
if breaker.is_available().await {
|
||||
log::info!(
|
||||
"[{}] Queue provider available: {} ({}) at sort_index {:?}",
|
||||
app_type,
|
||||
provider.name,
|
||||
provider.id,
|
||||
provider.sort_index
|
||||
);
|
||||
result.push(provider);
|
||||
} else {
|
||||
log::debug!(
|
||||
"[{}] Queue provider {} circuit breaker open, skipping",
|
||||
app_type,
|
||||
provider.name
|
||||
);
|
||||
circuit_open_count += 1;
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
return Err(AppError::Config(format!(
|
||||
"No available provider for {app_type} (all circuit breakers open or no providers configured)"
|
||||
)));
|
||||
if total_providers > 0 && circuit_open_count == total_providers {
|
||||
log::warn!("[{app_type}] [FO-004] 所有供应商均已熔断");
|
||||
return Err(AppError::AllProvidersCircuitOpen);
|
||||
} else {
|
||||
log::warn!("[{app_type}] [FO-005] 未配置供应商");
|
||||
return Err(AppError::NoProvidersConfigured);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] Provider chain: {} provider(s) available",
|
||||
app_type,
|
||||
result.len()
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -137,15 +107,10 @@ impl ProviderRouter {
|
||||
success: bool,
|
||||
error_msg: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
// 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用)
|
||||
// 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 // 默认值
|
||||
}
|
||||
Err(_) => 5, // 默认值
|
||||
};
|
||||
|
||||
// 2. 更新熔断器状态
|
||||
@@ -154,14 +119,8 @@ impl ProviderRouter {
|
||||
|
||||
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. 更新数据库健康状态(使用配置的阈值)
|
||||
@@ -182,7 +141,6 @@ impl ProviderRouter {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -193,19 +151,30 @@ impl ProviderRouter {
|
||||
self.reset_circuit_breaker(&circuit_key).await;
|
||||
}
|
||||
|
||||
/// 更新所有熔断器的配置(热更新)
|
||||
/// 仅释放 HalfOpen permit,不影响健康统计(neutral 接口)
|
||||
///
|
||||
/// 当用户在 UI 中修改熔断器配置后调用此方法,
|
||||
/// 所有现有的熔断器会立即使用新配置
|
||||
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
|
||||
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
|
||||
pub async fn release_permit_neutral(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
used_half_open_permit: bool,
|
||||
) {
|
||||
if !used_half_open_permit {
|
||||
return;
|
||||
}
|
||||
let circuit_key = format!("{app_type}:{provider_id}");
|
||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||
breaker.release_half_open_permit();
|
||||
}
|
||||
|
||||
/// 更新所有熔断器的配置(热更新)
|
||||
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} 个熔断器的配置");
|
||||
}
|
||||
|
||||
/// 获取熔断器状态
|
||||
@@ -248,32 +217,16 @@ impl ProviderRouter {
|
||||
|
||||
// 按应用独立读取熔断器配置
|
||||
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()
|
||||
}
|
||||
Ok(app_config) => 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(_) => 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());
|
||||
|
||||
@@ -390,4 +343,55 @@ mod tests {
|
||||
|
||||
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_release_permit_neutral_frees_half_open_slot() {
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
|
||||
// 配置熔断器:1 次失败即熔断,0 秒超时立即进入 HalfOpen
|
||||
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);
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.add_to_failover_queue("claude", "a").unwrap();
|
||||
|
||||
// 启用自动故障转移
|
||||
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());
|
||||
|
||||
// 触发熔断:1 次失败
|
||||
router
|
||||
.record_result("a", "claude", false, false, Some("fail".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 第一次请求:获取 HalfOpen 探测名额
|
||||
let first = router.allow_provider_request("a", "claude").await;
|
||||
assert!(first.allowed);
|
||||
assert!(first.used_half_open_permit);
|
||||
|
||||
// 第二次请求应被拒绝(名额已被占用)
|
||||
let second = router.allow_provider_request("a", "claude").await;
|
||||
assert!(!second.allowed);
|
||||
|
||||
// 使用 release_permit_neutral 释放名额(不影响健康统计)
|
||||
router
|
||||
.release_permit_neutral("a", "claude", first.used_half_open_permit)
|
||||
.await;
|
||||
|
||||
// 第三次请求应被允许(名额已释放)
|
||||
let third = router.allow_provider_request("a", "claude").await;
|
||||
assert!(third.allowed);
|
||||
assert!(third.used_half_open_permit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,13 +38,20 @@ impl AuthInfo {
|
||||
///
|
||||
/// 显示前4位和后4位,中间用 `...` 代替
|
||||
/// 如果 key 长度不足8位,则返回 `***`
|
||||
#[allow(dead_code)]
|
||||
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..]
|
||||
)
|
||||
if self.api_key.chars().count() > 8 {
|
||||
let prefix: String = self.api_key.chars().take(4).collect();
|
||||
let suffix: String = self
|
||||
.api_key
|
||||
.chars()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
format!("{prefix}...{suffix}")
|
||||
} else {
|
||||
"***".to_string()
|
||||
}
|
||||
@@ -54,8 +61,17 @@ impl AuthInfo {
|
||||
#[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..])
|
||||
if token.chars().count() > 8 {
|
||||
let prefix: String = token.chars().take(4).collect();
|
||||
let suffix: String = token
|
||||
.chars()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
format!("{prefix}...{suffix}")
|
||||
} else {
|
||||
"***".to_string()
|
||||
}
|
||||
@@ -126,6 +142,13 @@ mod tests {
|
||||
assert_eq!(auth.masked_key(), "1234...6789");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_key_utf8_safe() {
|
||||
let auth = AuthInfo::new("测试⚠️1234567890".to_string(), AuthStrategy::Bearer);
|
||||
let masked = auth.masked_key();
|
||||
assert!(!masked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_strategy_equality() {
|
||||
assert_eq!(AuthStrategy::Anthropic, AuthStrategy::Anthropic);
|
||||
@@ -160,6 +183,14 @@ mod tests {
|
||||
assert_eq!(auth.masked_access_token(), Some("ya29...cdef".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_access_token_utf8_safe() {
|
||||
let auth =
|
||||
AuthInfo::with_access_token("refresh".to_string(), "令牌⚠️1234567890".to_string());
|
||||
let masked = auth.masked_access_token().unwrap();
|
||||
assert!(!masked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_access_token_short() {
|
||||
let auth = AuthInfo::with_access_token("refresh".to_string(), "short".to_string());
|
||||
|
||||
@@ -62,7 +62,8 @@ impl ClaudeAdapter {
|
||||
let normalized = value.trim().to_lowercase();
|
||||
normalized == "true" || normalized == "1"
|
||||
}
|
||||
_ => true,
|
||||
// OpenRouter now supports Claude Code compatible API, default to passthrough
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,28 +218,37 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||
|
||||
format!(
|
||||
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 + anthropic-version
|
||||
// Anthropic 官方: Authorization Bearer + x-api-key
|
||||
AuthStrategy::Anthropic => request
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("x-api-key", &auth.api_key)
|
||||
.header("anthropic-version", "2023-06-01"),
|
||||
.header("x-api-key", &auth.api_key),
|
||||
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
|
||||
AuthStrategy::ClaudeAuth => request
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("anthropic-version", "2023-06-01"),
|
||||
AuthStrategy::ClaudeAuth => {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
}
|
||||
// OpenRouter: Bearer
|
||||
AuthStrategy::Bearer => request
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("anthropic-version", "2023-06-01"),
|
||||
AuthStrategy::Bearer => {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
}
|
||||
_ => request,
|
||||
}
|
||||
}
|
||||
@@ -416,15 +426,33 @@ mod tests {
|
||||
#[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");
|
||||
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");
|
||||
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]
|
||||
@@ -438,12 +466,22 @@ mod tests {
|
||||
}));
|
||||
assert!(!adapter.needs_transform(&anthropic_provider));
|
||||
|
||||
// OpenRouter provider without explicit setting now defaults to passthrough (no transform)
|
||||
let openrouter_provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||
}
|
||||
}));
|
||||
assert!(adapter.needs_transform(&openrouter_provider));
|
||||
assert!(!adapter.needs_transform(&openrouter_provider));
|
||||
|
||||
// OpenRouter provider with explicit compat mode enabled should transform
|
||||
let openrouter_enabled = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||
},
|
||||
"openrouter_compat_mode": true
|
||||
}));
|
||||
assert!(adapter.needs_transform(&openrouter_enabled));
|
||||
|
||||
let openrouter_disabled = create_provider(json!({
|
||||
"env": {
|
||||
|
||||
@@ -132,6 +132,10 @@ impl ProviderType {
|
||||
}
|
||||
ProviderType::Gemini
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy, but return a default type for completeness
|
||||
ProviderType::Codex // Fallback to Codex-like type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +180,10 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy, fallback to Codex adapter
|
||||
Box::new(CodexAdapter::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,8 +75,6 @@ pub fn create_anthropic_sse_stream(
|
||||
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 {
|
||||
@@ -96,25 +94,18 @@ pub fn create_anthropic_sse_stream(
|
||||
for l in line.lines() {
|
||||
if let Some(data) = l.strip_prefix("data: ") {
|
||||
if data.trim() == "[DONE]" {
|
||||
log::info!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
|
||||
log::debug!("[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");
|
||||
log::debug!("[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}");
|
||||
}
|
||||
// 仅在 DEBUG 级别简短记录 SSE 事件
|
||||
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
|
||||
|
||||
if message_id.is_none() {
|
||||
message_id = Some(chunk.id.clone());
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::{
|
||||
usage::parser::TokenUsage,
|
||||
ProxyError,
|
||||
};
|
||||
use axum::response::Response;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use rust_decimal::Decimal;
|
||||
@@ -46,8 +46,6 @@ pub async fn handle_streaming(
|
||||
state: &ProxyState,
|
||||
parser_config: &UsageParserConfig,
|
||||
) -> Response {
|
||||
log::info!("[{}] 流式透传响应 (SSE)", ctx.tag);
|
||||
|
||||
let status = response.status();
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
|
||||
@@ -72,7 +70,13 @@ pub async fn handle_streaming(
|
||||
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()
|
||||
match builder.body(body) {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
log::error!("[{}] 构建流式响应失败: {e}", ctx.tag);
|
||||
ProxyError::Internal(format!("Failed to build streaming response: {e}")).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理非流式响应
|
||||
@@ -93,12 +97,6 @@ pub async fn handle_non_streaming(
|
||||
|
||||
// 解析并记录使用量
|
||||
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 字段,最后回退到请求模型
|
||||
@@ -112,21 +110,40 @@ pub async fn handle_non_streaming(
|
||||
|
||||
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!(
|
||||
log::debug!(
|
||||
"[{}] <<< 响应 (非 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() {
|
||||
@@ -134,7 +151,10 @@ pub async fn handle_non_streaming(
|
||||
}
|
||||
|
||||
let body = axum::body::Body::from(body_bytes);
|
||||
Ok(builder.body(body).unwrap())
|
||||
builder.body(body).map_err(|e| {
|
||||
log::error!("[{}] 构建响应失败: {e}", ctx.tag);
|
||||
ProxyError::Internal(format!("Failed to build response: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// 通用响应处理入口
|
||||
@@ -243,6 +263,7 @@ fn create_usage_collector(
|
||||
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) {
|
||||
@@ -251,6 +272,7 @@ fn create_usage_collector(
|
||||
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let session_id = session_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage_internal(
|
||||
@@ -263,10 +285,32 @@ fn create_usage_collector(
|
||||
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 统计,跳过消费记录");
|
||||
}
|
||||
})
|
||||
@@ -286,6 +330,7 @@ fn spawn_log_usage(
|
||||
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(
|
||||
@@ -298,6 +343,7 @@ fn spawn_log_usage(
|
||||
None,
|
||||
is_streaming,
|
||||
status_code,
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
@@ -315,6 +361,7 @@ async fn log_usage_internal(
|
||||
first_token_ms: Option<u64>,
|
||||
is_streaming: bool,
|
||||
status_code: u16,
|
||||
session_id: Option<String>,
|
||||
) {
|
||||
use super::usage::logger::UsageLogger;
|
||||
|
||||
@@ -325,7 +372,12 @@ async fn log_usage_internal(
|
||||
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))
|
||||
Decimal::from_str(&cm).unwrap_or_else(|e| {
|
||||
log::warn!(
|
||||
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
|
||||
);
|
||||
Decimal::from(1)
|
||||
})
|
||||
} else {
|
||||
Decimal::from(1)
|
||||
}
|
||||
@@ -338,6 +390,15 @@ async fn log_usage_internal(
|
||||
|
||||
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(),
|
||||
@@ -348,11 +409,11 @@ async fn log_usage_internal(
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
status_code,
|
||||
None,
|
||||
session_id,
|
||||
None, // provider_type
|
||||
is_streaming,
|
||||
) {
|
||||
log::warn!("记录使用量失败: {e}");
|
||||
log::warn!("[USG-001] 记录使用量失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,16 +488,16 @@ pub fn create_logged_passthrough_stream(
|
||||
if let Some(c) = &collector {
|
||||
c.push(json_value.clone()).await;
|
||||
}
|
||||
log::info!(
|
||||
"[{}] <<< SSE 事件:\n{}",
|
||||
log::debug!(
|
||||
"[{}] <<< SSE 事件: {}",
|
||||
tag,
|
||||
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
|
||||
data.chars().take(100).collect::<String>()
|
||||
);
|
||||
} else {
|
||||
log::info!("[{tag}] <<< SSE 数据: {data}");
|
||||
log::debug!("[{tag}] <<< SSE 数据: {}", data.chars().take(100).collect::<String>());
|
||||
}
|
||||
} else {
|
||||
log::info!("[{tag}] <<< SSE: [DONE]");
|
||||
log::debug!("[{tag}] <<< SSE: [DONE]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -457,8 +518,6 @@ pub fn create_logged_passthrough_stream(
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("[{}] ====== 流结束 ======", tag);
|
||||
|
||||
if let Some(c) = collector.take() {
|
||||
c.finish().await;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! 基于Axum的HTTP服务器,处理代理请求
|
||||
|
||||
use super::{
|
||||
failover_switch::FailoverSwitchManager, handlers, provider_router::ProviderRouter, types::*,
|
||||
ProxyError,
|
||||
failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
|
||||
provider_router::ProviderRouter, types::*, ProxyError,
|
||||
};
|
||||
use crate::database::Database;
|
||||
use axum::{
|
||||
@@ -95,7 +95,7 @@ impl ProxyServer {
|
||||
.await
|
||||
.map_err(|e| ProxyError::BindFailed(e.to_string()))?;
|
||||
|
||||
log::info!("代理服务器启动于 {addr}");
|
||||
log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED);
|
||||
|
||||
// 保存关闭句柄
|
||||
*self.shutdown_tx.write().await = Some(shutdown_tx);
|
||||
@@ -146,13 +146,25 @@ impl ProxyServer {
|
||||
// 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(Ok(())) => {
|
||||
log::info!("[{}] 代理服务器已完全停止", log_srv::STOPPED);
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::warn!("[{}] 代理服务器任务异常终止: {e}", log_srv::TASK_ERROR);
|
||||
Err(ProxyError::StopFailed(e.to_string()))
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!(
|
||||
"[{}] 代理服务器停止超时(5秒),强制继续",
|
||||
log_srv::STOP_TIMEOUT
|
||||
);
|
||||
Err(ProxyError::StopTimeout)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_status(&self) -> ProxyStatus {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
//! 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;
|
||||
|
||||
@@ -176,6 +184,179 @@ impl ProxySession {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session ID 提取器
|
||||
// ============================================================================
|
||||
|
||||
/// Session ID 来源
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionIdSource {
|
||||
/// 从 metadata.user_id 提取 (Claude)
|
||||
MetadataUserId,
|
||||
/// 从 metadata.session_id 提取
|
||||
MetadataSessionId,
|
||||
/// 从 headers 提取 (Codex)
|
||||
Header,
|
||||
/// 从 previous_response_id 提取 (Codex)
|
||||
PreviousResponseId,
|
||||
/// 新生成
|
||||
Generated,
|
||||
}
|
||||
|
||||
/// Session ID 提取结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionIdResult {
|
||||
/// 提取或生成的 Session ID
|
||||
pub session_id: String,
|
||||
/// Session ID 来源
|
||||
pub source: SessionIdSource,
|
||||
/// 是否为客户端提供的 ID(非新生成)
|
||||
pub client_provided: bool,
|
||||
}
|
||||
|
||||
/// 从请求中提取或生成 Session ID
|
||||
///
|
||||
/// 轻量化实现,仅提取 session_id 用于日志记录,不做复杂的 Session 管理。
|
||||
///
|
||||
/// ## 提取优先级
|
||||
///
|
||||
/// ### Claude 请求
|
||||
/// 1. `metadata.user_id` (格式: `user_xxx_session_yyy`) → 提取 `yyy` 部分
|
||||
/// 2. `metadata.session_id` → 直接使用
|
||||
/// 3. 生成新 UUID
|
||||
///
|
||||
/// ### Codex 请求
|
||||
/// 1. Headers: `session_id` 或 `x-session-id`
|
||||
/// 2. `metadata.session_id`
|
||||
/// 3. `previous_response_id` (对话延续)
|
||||
/// 4. 生成新 UUID
|
||||
///
|
||||
/// ## 示例
|
||||
///
|
||||
/// ```ignore
|
||||
/// let result = extract_session_id(&headers, &body, "claude");
|
||||
/// println!("Session ID: {} (from {:?})", result.session_id, result.source);
|
||||
/// ```
|
||||
pub fn extract_session_id(
|
||||
headers: &HeaderMap,
|
||||
body: &serde_json::Value,
|
||||
client_format: &str,
|
||||
) -> SessionIdResult {
|
||||
// Codex 请求特殊处理
|
||||
if client_format == "codex" || client_format == "openai" {
|
||||
if let Some(result) = extract_codex_session(headers, body) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Claude 请求:从 metadata 提取
|
||||
if let Some(result) = extract_from_metadata(body) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 兜底:生成新 Session ID
|
||||
generate_new_session_id()
|
||||
}
|
||||
|
||||
/// 提取 Codex Session ID
|
||||
fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<SessionIdResult> {
|
||||
// 1. 从 headers 提取
|
||||
for header_name in &["session_id", "x-session-id"] {
|
||||
if let Some(value) = headers.get(*header_name) {
|
||||
if let Ok(session_id) = value.to_str() {
|
||||
// Codex Session ID 通常较长(UUID 格式)
|
||||
if session_id.len() > 20 {
|
||||
return Some(SessionIdResult {
|
||||
session_id: format!("codex_{session_id}"),
|
||||
source: SessionIdSource::Header,
|
||||
client_provided: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 从 body.metadata.session_id 提取
|
||||
if let Some(session_id) = body
|
||||
.get("metadata")
|
||||
.and_then(|m| m.get("session_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if session_id.len() > 10 {
|
||||
return Some(SessionIdResult {
|
||||
session_id: format!("codex_{session_id}"),
|
||||
source: SessionIdSource::MetadataSessionId,
|
||||
client_provided: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 从 previous_response_id 提取(对话延续)
|
||||
if let Some(prev_id) = body.get("previous_response_id").and_then(|v| v.as_str()) {
|
||||
if prev_id.len() > 10 {
|
||||
return Some(SessionIdResult {
|
||||
session_id: format!("codex_{prev_id}"),
|
||||
source: SessionIdSource::PreviousResponseId,
|
||||
client_provided: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 从 metadata 提取 Session ID (Claude)
|
||||
fn extract_from_metadata(body: &serde_json::Value) -> Option<SessionIdResult> {
|
||||
let metadata = body.get("metadata")?;
|
||||
|
||||
// 1. 从 metadata.user_id 提取(格式: user_xxx_session_yyy)
|
||||
if let Some(user_id) = metadata.get("user_id").and_then(|v| v.as_str()) {
|
||||
if let Some(session_id) = parse_session_from_user_id(user_id) {
|
||||
return Some(SessionIdResult {
|
||||
session_id,
|
||||
source: SessionIdSource::MetadataUserId,
|
||||
client_provided: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 直接从 metadata.session_id 提取
|
||||
if let Some(session_id) = metadata.get("session_id").and_then(|v| v.as_str()) {
|
||||
if !session_id.is_empty() {
|
||||
return Some(SessionIdResult {
|
||||
session_id: session_id.to_string(),
|
||||
source: SessionIdSource::MetadataSessionId,
|
||||
client_provided: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 从 user_id 解析 session_id
|
||||
///
|
||||
/// 格式: `user_identifier_session_actual_session_id`
|
||||
fn parse_session_from_user_id(user_id: &str) -> Option<String> {
|
||||
// 查找 "_session_" 分隔符
|
||||
if let Some(pos) = user_id.find("_session_") {
|
||||
let session_id = &user_id[pos + 9..]; // "_session_" 长度为 9
|
||||
if !session_id.is_empty() {
|
||||
return Some(session_id.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 生成新的 Session ID
|
||||
fn generate_new_session_id() -> SessionIdResult {
|
||||
SessionIdResult {
|
||||
session_id: Uuid::new_v4().to_string(),
|
||||
source: SessionIdSource::Generated,
|
||||
client_provided: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -295,4 +476,92 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
//! Thinking Signature 整流器
|
||||
//!
|
||||
//! 用于自动修复 Anthropic API 中因签名校验失败导致的请求错误。
|
||||
//! 当上游 API 返回签名相关错误时,系统会自动移除有问题的签名字段并重试请求。
|
||||
|
||||
use super::types::RectifierConfig;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 整流结果
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RectifyResult {
|
||||
/// 是否应用了整流
|
||||
pub applied: bool,
|
||||
/// 移除的 thinking block 数量
|
||||
pub removed_thinking_blocks: usize,
|
||||
/// 移除的 redacted_thinking block 数量
|
||||
pub removed_redacted_thinking_blocks: usize,
|
||||
/// 移除的 signature 字段数量
|
||||
pub removed_signature_fields: usize,
|
||||
}
|
||||
|
||||
/// 检测是否需要触发 thinking 签名整流器
|
||||
///
|
||||
/// 返回 `true` 表示需要触发整流器,`false` 表示不需要。
|
||||
/// 会检查配置开关。
|
||||
pub fn should_rectify_thinking_signature(
|
||||
error_message: Option<&str>,
|
||||
config: &RectifierConfig,
|
||||
) -> bool {
|
||||
// 检查总开关
|
||||
if !config.enabled {
|
||||
return false;
|
||||
}
|
||||
// 检查子开关
|
||||
if !config.request_thinking_signature {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检测错误类型
|
||||
let Some(msg) = error_message else {
|
||||
return false;
|
||||
};
|
||||
let lower = msg.to_lowercase();
|
||||
|
||||
// 场景1: thinking block 中的签名无效
|
||||
// 错误示例: "Invalid 'signature' in 'thinking' block"
|
||||
if lower.contains("invalid")
|
||||
&& lower.contains("signature")
|
||||
&& lower.contains("thinking")
|
||||
&& lower.contains("block")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景2: assistant 消息必须以 thinking block 开头
|
||||
// 错误示例: "must start with a thinking block"
|
||||
if lower.contains("must start with a thinking block") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景3: expected thinking or redacted_thinking, found tool_use
|
||||
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
|
||||
if lower.contains("expected")
|
||||
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
|
||||
&& lower.contains("found")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景4: signature 字段必需但缺失
|
||||
// 错误示例: "signature: Field required"
|
||||
if lower.contains("signature") && lower.contains("field required") {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 对 Anthropic 请求体做最小侵入整流
|
||||
///
|
||||
/// - 移除 messages[*].content 中的 thinking/redacted_thinking block
|
||||
/// - 移除非 thinking block 上遗留的 signature 字段
|
||||
/// - 特定条件下删除顶层 thinking 字段
|
||||
///
|
||||
/// 注意:该函数会原地修改 body 对象
|
||||
pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
|
||||
let mut result = RectifyResult::default();
|
||||
|
||||
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
||||
Some(m) => m,
|
||||
None => return result,
|
||||
};
|
||||
|
||||
// 遍历所有消息
|
||||
for msg in messages.iter_mut() {
|
||||
let content = match msg.get_mut("content").and_then(|c| c.as_array_mut()) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let mut new_content = Vec::with_capacity(content.len());
|
||||
let mut content_modified = false;
|
||||
|
||||
for block in content.iter() {
|
||||
let block_type = block.get("type").and_then(|t| t.as_str());
|
||||
|
||||
match block_type {
|
||||
Some("thinking") => {
|
||||
result.removed_thinking_blocks += 1;
|
||||
content_modified = true;
|
||||
continue;
|
||||
}
|
||||
Some("redacted_thinking") => {
|
||||
result.removed_redacted_thinking_blocks += 1;
|
||||
content_modified = true;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 移除非 thinking block 上的 signature 字段
|
||||
if block.get("signature").is_some() {
|
||||
let mut block_clone = block.clone();
|
||||
if let Some(obj) = block_clone.as_object_mut() {
|
||||
obj.remove("signature");
|
||||
result.removed_signature_fields += 1;
|
||||
content_modified = true;
|
||||
new_content.push(Value::Object(obj.clone()));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
new_content.push(block.clone());
|
||||
}
|
||||
|
||||
if content_modified {
|
||||
result.applied = true;
|
||||
*content = new_content;
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底处理:thinking 启用 + 工具调用链路中最后一条 assistant 消息未以 thinking 开头
|
||||
let messages_snapshot: Vec<Value> = body
|
||||
.get("messages")
|
||||
.and_then(|m| m.as_array())
|
||||
.map(|a| a.to_vec())
|
||||
.unwrap_or_default();
|
||||
|
||||
if should_remove_top_level_thinking(body, &messages_snapshot) {
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.remove("thinking");
|
||||
result.applied = true;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 判断是否需要删除顶层 thinking 字段
|
||||
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
|
||||
// 检查 thinking 是否启用
|
||||
let thinking_enabled = body
|
||||
.get("thinking")
|
||||
.and_then(|t| t.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
== Some("enabled");
|
||||
|
||||
if !thinking_enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 找到最后一条 assistant 消息
|
||||
let last_assistant = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"));
|
||||
|
||||
let last_assistant_content = match last_assistant
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_array())
|
||||
{
|
||||
Some(c) if !c.is_empty() => c,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// 检查首块是否为 thinking/redacted_thinking
|
||||
let first_block_type = last_assistant_content
|
||||
.first()
|
||||
.and_then(|b| b.get("type"))
|
||||
.and_then(|t| t.as_str());
|
||||
|
||||
let missing_thinking_prefix =
|
||||
first_block_type != Some("thinking") && first_block_type != Some("redacted_thinking");
|
||||
|
||||
if !missing_thinking_prefix {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否存在 tool_use
|
||||
last_assistant_content
|
||||
.iter()
|
||||
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn enabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn master_disabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: false,
|
||||
request_thinking_signature: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== should_rectify_thinking_signature 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_signature() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("messages.1.content.0: Invalid `signature` in `thinking` block"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_signature_no_backticks() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("Messages.1.Content.0: invalid signature in thinking block"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_signature_nested_json() {
|
||||
// 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式)
|
||||
let nested_error = r#"{"error":{"message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_xxx\"}"}}"#;
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some(nested_error),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_thinking_expected() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `tool_use`."),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_must_start_with_thinking() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("a final `assistant` message must start with a thinking block"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_trigger_for_unrelated_error() {
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("Request timeout"),
|
||||
&enabled_config()
|
||||
));
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("Connection refused"),
|
||||
&enabled_config()
|
||||
));
|
||||
assert!(!should_rectify_thinking_signature(None, &enabled_config()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_signature_field_required() {
|
||||
// 场景4: signature 字段缺失
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("***.***.***.***.***.signature: Field required"),
|
||||
&enabled_config()
|
||||
));
|
||||
// 嵌套 JSON 格式
|
||||
let nested_error = r#"{"error":{"type":"<nil>","message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.***.***.***.***.signature: Field required\"},\"request_id\":\"req_xxx\"}"}}"#;
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some(nested_error),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_config() {
|
||||
// 即使错误匹配,配置关闭时也不触发
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("Invalid `signature` in `thinking` block"),
|
||||
&disabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_master_disabled() {
|
||||
// 总开关关闭时,即使子开关开启也不触发
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("Invalid `signature` in `thinking` block"),
|
||||
&master_disabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== rectify_anthropic_request 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_rectify_removes_thinking_blocks() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "thinking", "thinking": "t", "signature": "sig" },
|
||||
{ "type": "text", "text": "hello", "signature": "sig_text" },
|
||||
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {}, "signature": "sig_tool" },
|
||||
{ "type": "redacted_thinking", "data": "r", "signature": "sig_redacted" }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.removed_thinking_blocks, 1);
|
||||
assert_eq!(result.removed_redacted_thinking_blocks, 1);
|
||||
assert_eq!(result.removed_signature_fields, 2);
|
||||
|
||||
let content = body["messages"][0]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 2);
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
assert!(content[0].get("signature").is_none());
|
||||
assert_eq!(content[1]["type"], "tool_use");
|
||||
assert!(content[1].get("signature").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_removes_top_level_thinking() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 1024 },
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
|
||||
]
|
||||
}, {
|
||||
"role": "user",
|
||||
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert!(body.get("thinking").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_no_change_when_no_issues() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(result.removed_thinking_blocks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_no_messages() {
|
||||
let mut body = json!({ "model": "claude-test" });
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
assert!(!result.applied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_preserves_thinking_when_prefix_exists() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled" },
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "thinking", "thinking": "some thought" },
|
||||
{ "type": "tool_use", "id": "toolu_1", "name": "Test", "input": {} }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
// thinking block 被移除,但顶层 thinking 不应被移除(因为原本有 thinking 前缀)
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.removed_thinking_blocks, 1);
|
||||
// 注意:由于 thinking block 被移除后,首块变成了 tool_use,
|
||||
// 此时会触发删除顶层 thinking 的逻辑
|
||||
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,11 @@ pub struct ProxyConfig {
|
||||
}
|
||||
|
||||
fn default_streaming_first_byte_timeout() -> u64 {
|
||||
30
|
||||
60
|
||||
}
|
||||
|
||||
fn default_streaming_idle_timeout() -> u64 {
|
||||
60
|
||||
120
|
||||
}
|
||||
|
||||
fn default_non_streaming_timeout() -> u64 {
|
||||
@@ -45,11 +45,11 @@ impl Default for ProxyConfig {
|
||||
listen_address: "127.0.0.1".to_string(),
|
||||
listen_port: 15721, // 使用较少占用的高位端口
|
||||
max_retries: 3,
|
||||
request_timeout: 300,
|
||||
request_timeout: 600,
|
||||
enable_logging: true,
|
||||
live_takeover_active: false,
|
||||
streaming_first_byte_timeout: 30,
|
||||
streaming_idle_timeout: 60,
|
||||
streaming_first_byte_timeout: 60,
|
||||
streaming_idle_timeout: 120,
|
||||
non_streaming_timeout: 600,
|
||||
}
|
||||
}
|
||||
@@ -191,3 +191,67 @@ pub struct AppProxyConfig {
|
||||
/// 计算错误率的最小请求数
|
||||
pub circuit_min_requests: u32,
|
||||
}
|
||||
|
||||
/// 整流器配置
|
||||
///
|
||||
/// 存储在 settings 表中
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RectifierConfig {
|
||||
/// 总开关:是否启用整流器
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// 请求整流:启用 thinking 签名整流器
|
||||
///
|
||||
/// 处理错误:Invalid 'signature' in 'thinking' block
|
||||
#[serde(default = "default_true")]
|
||||
pub request_thinking_signature: bool,
|
||||
}
|
||||
|
||||
impl Default for RectifierConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_default_enabled() {
|
||||
// 验证 RectifierConfig::default() 返回全启用状态
|
||||
// 防止回归:#[derive(Default)] 会使 bool 默认为 false
|
||||
let config = RectifierConfig::default();
|
||||
assert!(config.enabled, "整流器总开关默认应为 true");
|
||||
assert!(
|
||||
config.request_thinking_signature,
|
||||
"thinking 签名整流器默认应为 true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_default() {
|
||||
// 验证反序列化缺字段时使用 default_true
|
||||
let json = "{}";
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(config.enabled);
|
||||
assert!(config.request_thinking_signature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_explicit_false() {
|
||||
// 验证显式设置 false 时正确反序列化
|
||||
let json = r#"{"enabled": false, "requestThinkingSignature": false}"#;
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(!config.enabled);
|
||||
assert!(!config.request_thinking_signature);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ impl CostCalculator {
|
||||
/// - `usage`: Token 使用量
|
||||
/// - `pricing`: 模型定价
|
||||
/// - `cost_multiplier`: 成本倍数 (provider 自定义)
|
||||
///
|
||||
/// # 计算逻辑
|
||||
/// - input_cost: (input_tokens - cache_read_tokens) × 输入价格
|
||||
/// - cache_read_cost: cache_read_tokens × 缓存读取价格
|
||||
/// - 这样避免缓存部分被重复计费
|
||||
pub fn calculate(
|
||||
usage: &TokenUsage,
|
||||
pricing: &ModelPricing,
|
||||
@@ -42,7 +47,10 @@ impl CostCalculator {
|
||||
) -> CostBreakdown {
|
||||
let million = Decimal::from(1_000_000);
|
||||
|
||||
let input_cost = Decimal::from(usage.input_tokens) * pricing.input_cost_per_million
|
||||
// 计算实际需要按输入价格计费的 token 数(减去缓存命中部分)
|
||||
let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens);
|
||||
|
||||
let input_cost = Decimal::from(billable_input_tokens) * pricing.input_cost_per_million
|
||||
/ million
|
||||
* cost_multiplier;
|
||||
let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million
|
||||
@@ -113,8 +121,8 @@ mod tests {
|
||||
|
||||
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
||||
|
||||
// input: 1000 * 3.0 / 1M = 0.003
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap());
|
||||
// input: (1000 - 200) * 3.0 / 1M = 0.0024 (只计算非缓存部分)
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap());
|
||||
// output: 500 * 15.0 / 1M = 0.0075
|
||||
assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap());
|
||||
// cache_read: 200 * 0.3 / 1M = 0.00006
|
||||
@@ -124,8 +132,8 @@ mod tests {
|
||||
cost.cache_creation_cost,
|
||||
Decimal::from_str("0.000375").unwrap()
|
||||
);
|
||||
// total: 0.003 + 0.0075 + 0.00006 + 0.000375 = 0.010935
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.010935").unwrap());
|
||||
// total: 0.0024 + 0.0075 + 0.00006 + 0.000375 = 0.010335
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.010335").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -65,8 +65,11 @@ impl<'a> UsageLogger<'a> {
|
||||
|
||||
let created_at = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("SystemTime is before UNIX_EPOCH, falling back to 0: {e}");
|
||||
0
|
||||
});
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
@@ -211,7 +214,7 @@ impl<'a> UsageLogger<'a> {
|
||||
let pricing = self.get_model_pricing(&model)?;
|
||||
|
||||
if pricing.is_none() {
|
||||
log::warn!("模型 {model} 的定价信息未找到,成本将记录为 0");
|
||||
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0");
|
||||
}
|
||||
|
||||
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
|
||||
|
||||
@@ -163,13 +163,21 @@ impl TokenUsage {
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let cached_tokens = usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.or_else(|| {
|
||||
usage
|
||||
.get("input_tokens_details")
|
||||
.and_then(|d| d.get("cached_tokens"))
|
||||
.and_then(|v| v.as_u64())
|
||||
})
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
Some(Self {
|
||||
input_tokens: input_tokens? as u32,
|
||||
output_tokens: output_tokens? as u32,
|
||||
cache_read_tokens: usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
cache_read_tokens: cached_tokens,
|
||||
cache_creation_tokens: usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
@@ -188,16 +196,27 @@ impl TokenUsage {
|
||||
let input_tokens = usage.get("input_tokens")?.as_u64()? as u32;
|
||||
let output_tokens = usage.get("output_tokens")?.as_u64()? as u32;
|
||||
|
||||
// 获取 cached_tokens (可能在 input_tokens_details 中)
|
||||
// 获取 cached_tokens (可能在 cache_read_input_tokens 或 input_tokens_details 中)
|
||||
let cached_tokens = usage
|
||||
.get("input_tokens_details")
|
||||
.and_then(|d| d.get("cached_tokens"))
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.or_else(|| {
|
||||
usage
|
||||
.get("input_tokens_details")
|
||||
.and_then(|d| d.get("cached_tokens"))
|
||||
.and_then(|v| v.as_u64())
|
||||
})
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
// 调整 input_tokens: 减去 cached_tokens
|
||||
let adjusted_input = input_tokens.saturating_sub(cached_tokens);
|
||||
|
||||
// 提取响应中的模型名称
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Some(Self {
|
||||
input_tokens: adjusted_input,
|
||||
output_tokens,
|
||||
@@ -206,7 +225,7 @@ impl TokenUsage {
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
model: None,
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -220,7 +239,7 @@ impl TokenUsage {
|
||||
if event_type == "response.completed" {
|
||||
if let Some(response) = event.get("response") {
|
||||
log::debug!("[Codex] 找到 response.completed 事件,解析 usage");
|
||||
return Self::from_codex_response(response);
|
||||
return Self::from_codex_response_adjusted(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,6 +248,51 @@ impl TokenUsage {
|
||||
None
|
||||
}
|
||||
|
||||
/// 智能 Codex 响应解析 - 自动检测 OpenAI 或 Codex 格式
|
||||
///
|
||||
/// Codex 支持两种 API 格式:
|
||||
/// - `/v1/responses`: 使用 input_tokens/output_tokens
|
||||
/// - `/v1/chat/completions`: 使用 prompt_tokens/completion_tokens (OpenAI 格式)
|
||||
///
|
||||
/// 注意:记录原始 input_tokens,费用计算时再减去 cached_tokens
|
||||
pub fn from_codex_response_auto(body: &Value) -> Option<Self> {
|
||||
let usage = body.get("usage")?;
|
||||
|
||||
// 检测格式:OpenAI 使用 prompt_tokens,Codex 使用 input_tokens
|
||||
if usage.get("prompt_tokens").is_some() {
|
||||
log::debug!("[Codex] 检测到 OpenAI 格式 (prompt_tokens)");
|
||||
Self::from_openai_response(body)
|
||||
} else if usage.get("input_tokens").is_some() {
|
||||
log::debug!("[Codex] 检测到 Codex 格式 (input_tokens)");
|
||||
// 使用非调整版本,记录原始 input_tokens
|
||||
Self::from_codex_response(body)
|
||||
} else {
|
||||
log::debug!("[Codex] 无法识别响应格式,usage: {usage:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 智能 Codex 流式响应解析 - 自动检测 OpenAI 或 Codex 格式
|
||||
pub fn from_codex_stream_events_auto(events: &[Value]) -> Option<Self> {
|
||||
log::debug!("[Codex] 智能解析流式事件,共 {} 个事件", events.len());
|
||||
|
||||
// 先尝试 Codex Responses API 格式 (response.completed 事件)
|
||||
for event in events {
|
||||
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
|
||||
if event_type == "response.completed" {
|
||||
if let Some(response) = event.get("response") {
|
||||
log::debug!("[Codex] 找到 response.completed 事件");
|
||||
return Self::from_codex_response_auto(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到 OpenAI Chat Completions 格式 (最后一个 chunk 包含 usage)
|
||||
log::debug!("[Codex] 尝试 OpenAI 流式格式");
|
||||
Self::from_openai_stream_events(events)
|
||||
}
|
||||
|
||||
/// 从 OpenAI Chat Completions API 响应解析 (prompt_tokens, completion_tokens)
|
||||
pub fn from_openai_response(body: &Value) -> Option<Self> {
|
||||
let usage = body.get("usage")?;
|
||||
@@ -284,9 +348,16 @@ impl TokenUsage {
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let prompt_tokens = usage.get("promptTokenCount")?.as_u64()? as u32;
|
||||
let total_tokens = usage.get("totalTokenCount")?.as_u64()? as u32;
|
||||
|
||||
// 输出 tokens = 总 tokens - 输入 tokens
|
||||
// 这包含了 candidatesTokenCount + thoughtsTokenCount
|
||||
let output_tokens = total_tokens.saturating_sub(prompt_tokens);
|
||||
|
||||
Some(Self {
|
||||
input_tokens: usage.get("promptTokenCount")?.as_u64()? as u32,
|
||||
output_tokens: usage.get("candidatesTokenCount")?.as_u64()? as u32,
|
||||
input_tokens: prompt_tokens,
|
||||
output_tokens,
|
||||
cache_read_tokens: usage
|
||||
.get("cachedContentTokenCount")
|
||||
.and_then(|v| v.as_u64())
|
||||
@@ -300,20 +371,25 @@ impl TokenUsage {
|
||||
#[allow(dead_code)]
|
||||
pub fn from_gemini_stream_chunks(chunks: &[Value]) -> Option<Self> {
|
||||
let mut total_input = 0u32;
|
||||
let mut total_output = 0u32;
|
||||
let mut total_tokens = 0u32;
|
||||
let mut total_cache_read = 0u32;
|
||||
let mut model: Option<String> = None;
|
||||
|
||||
for chunk in chunks {
|
||||
if let Some(usage) = chunk.get("usageMetadata") {
|
||||
// 输入 tokens (通常在所有 chunk 中保持不变)
|
||||
total_input = usage
|
||||
.get("promptTokenCount")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
total_output += usage
|
||||
.get("candidatesTokenCount")
|
||||
|
||||
// 总 tokens (包含输入 + 输出 + 思考)
|
||||
total_tokens = usage
|
||||
.get("totalTokenCount")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
// 缓存读取 tokens
|
||||
total_cache_read = usage
|
||||
.get("cachedContentTokenCount")
|
||||
.and_then(|v| v.as_u64())
|
||||
@@ -328,6 +404,9 @@ impl TokenUsage {
|
||||
}
|
||||
}
|
||||
|
||||
// 输出 tokens = 总 tokens - 输入 tokens
|
||||
let total_output = total_tokens.saturating_sub(total_input);
|
||||
|
||||
if total_input > 0 || total_output > 0 {
|
||||
Some(Self {
|
||||
input_tokens: total_input,
|
||||
@@ -466,15 +545,18 @@ mod tests {
|
||||
let response = json!({
|
||||
"modelVersion": "gemini-3-pro-high",
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 100,
|
||||
"promptTokenCount": 8383,
|
||||
"candidatesTokenCount": 50,
|
||||
"thoughtsTokenCount": 114,
|
||||
"totalTokenCount": 8547,
|
||||
"cachedContentTokenCount": 20
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_gemini_response(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.input_tokens, 8383);
|
||||
// output_tokens = totalTokenCount - promptTokenCount = 8547 - 8383 = 164
|
||||
assert_eq!(usage.output_tokens, 164);
|
||||
assert_eq!(usage.cache_read_tokens, 20);
|
||||
assert_eq!(usage.cache_creation_tokens, 0);
|
||||
assert_eq!(usage.model, Some("gemini-3-pro-high".to_string()));
|
||||
@@ -486,19 +568,78 @@ mod tests {
|
||||
let response = json!({
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 100,
|
||||
"candidatesTokenCount": 50,
|
||||
"totalTokenCount": 150,
|
||||
"cachedContentTokenCount": 20
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_gemini_response(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
// output_tokens = totalTokenCount - promptTokenCount = 150 - 100 = 50
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.cache_read_tokens, 20);
|
||||
assert_eq!(usage.cache_creation_tokens, 0);
|
||||
assert_eq!(usage.model, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gemini_response_with_thoughts() {
|
||||
// 测试包含 thoughtsTokenCount 的实际响应
|
||||
// 这是用户报告的真实场景
|
||||
let response = json!({
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "",
|
||||
"thoughtSignature": "EvcECvQE..."
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}
|
||||
],
|
||||
"modelVersion": "gemini-3-pro-high",
|
||||
"responseId": "yupTafqLDu-PjMcPhrOx4QQ",
|
||||
"usageMetadata": {
|
||||
"candidatesTokenCount": 50,
|
||||
"promptTokenCount": 8383,
|
||||
"thoughtsTokenCount": 114,
|
||||
"totalTokenCount": 8547
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_gemini_response(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 8383);
|
||||
// output_tokens = totalTokenCount - promptTokenCount
|
||||
// = 8547 - 8383 = 164 (包含 candidatesTokenCount 50 + thoughtsTokenCount 114)
|
||||
assert_eq!(usage.output_tokens, 164);
|
||||
assert_eq!(usage.cache_read_tokens, 0);
|
||||
assert_eq!(usage.cache_creation_tokens, 0);
|
||||
assert_eq!(usage.model, Some("gemini-3-pro-high".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_parsing_cached_tokens_in_details() {
|
||||
let response = json!({
|
||||
"usage": {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 500,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 300
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_codex_response(&response).unwrap();
|
||||
// 非调整模式:input_tokens 保持原值,但应记录缓存命中
|
||||
assert_eq!(usage.input_tokens, 1000);
|
||||
assert_eq!(usage.output_tokens, 500);
|
||||
assert_eq!(usage.cache_read_tokens, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_adjusted() {
|
||||
let response = json!({
|
||||
@@ -534,6 +675,22 @@ mod tests {
|
||||
assert_eq!(usage.cache_read_tokens, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_adjusted_cache_read_input_tokens() {
|
||||
let response = json!({
|
||||
"usage": {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 500,
|
||||
"cache_read_input_tokens": 200
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 800);
|
||||
assert_eq!(usage.output_tokens, 500);
|
||||
assert_eq!(usage.cache_read_tokens, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_adjusted_saturating_sub() {
|
||||
// 测试 cached_tokens > input_tokens 的边界情况
|
||||
@@ -615,4 +772,110 @@ mod tests {
|
||||
assert_eq!(usage.cache_read_tokens, 50);
|
||||
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 智能 Codex 解析测试
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_auto_openai_format() {
|
||||
// OpenAI 格式 (prompt_tokens/completion_tokens)
|
||||
let response = json!({
|
||||
"model": "gpt-4o",
|
||||
"usage": {
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 500,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 200
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_codex_response_auto(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 1000);
|
||||
assert_eq!(usage.output_tokens, 500);
|
||||
assert_eq!(usage.cache_read_tokens, 200);
|
||||
assert_eq!(usage.model, Some("gpt-4o".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_auto_codex_format() {
|
||||
// Codex 格式 (input_tokens/output_tokens)
|
||||
let response = json!({
|
||||
"model": "o3",
|
||||
"usage": {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 500,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 300
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_codex_response_auto(&response).unwrap();
|
||||
// 记录原始 input_tokens,不调整
|
||||
assert_eq!(usage.input_tokens, 1000);
|
||||
assert_eq!(usage.output_tokens, 500);
|
||||
assert_eq!(usage.cache_read_tokens, 300);
|
||||
assert_eq!(usage.model, Some("o3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_stream_events_auto_codex_format() {
|
||||
// Codex Responses API 流式格式 (response.completed 事件)
|
||||
let events = vec![
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_123"
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"model": "o3",
|
||||
"usage": {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 500,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap();
|
||||
// 记录原始 input_tokens,不调整
|
||||
assert_eq!(usage.input_tokens, 1000);
|
||||
assert_eq!(usage.output_tokens, 500);
|
||||
assert_eq!(usage.cache_read_tokens, 200);
|
||||
assert_eq!(usage.model, Some("o3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_stream_events_auto_openai_format() {
|
||||
// OpenAI Chat Completions 流式格式 (最后一个 chunk 包含 usage)
|
||||
let events = vec![
|
||||
json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4o",
|
||||
"choices": [{"delta": {"content": "Hello"}}]
|
||||
}),
|
||||
json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4o",
|
||||
"choices": [{"delta": {}}],
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap();
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.model, Some("gpt-4o".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,10 @@ impl ConfigService {
|
||||
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
||||
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode, no live sync needed
|
||||
// OpenCode providers are managed directly in the config file
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -37,6 +37,9 @@ impl McpService {
|
||||
if prev_apps.gemini && !server.apps.gemini {
|
||||
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
|
||||
}
|
||||
if prev_apps.opencode && !server.apps.opencode {
|
||||
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
|
||||
}
|
||||
|
||||
// 同步到各个启用的应用
|
||||
Self::sync_server_to_apps(state, &server)?;
|
||||
@@ -113,6 +116,13 @@ impl McpService {
|
||||
AppType::Gemini => {
|
||||
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
mcp::sync_single_server_to_opencode(
|
||||
&Default::default(),
|
||||
&server.id,
|
||||
&server.server,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -135,6 +145,9 @@ impl McpService {
|
||||
AppType::Claude => mcp::remove_server_from_claude(id)?,
|
||||
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
||||
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
|
||||
AppType::OpenCode => {
|
||||
mcp::remove_server_from_opencode(id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -206,6 +219,8 @@ impl McpService {
|
||||
// 调用原有的导入逻辑(从 mcp.rs)
|
||||
let count = crate::mcp::import_from_claude(&mut temp_config)?;
|
||||
|
||||
let mut new_count = 0;
|
||||
|
||||
// 如果有导入的服务器,保存到数据库
|
||||
if count > 0 {
|
||||
if let Some(servers) = &temp_config.mcp.servers {
|
||||
@@ -217,6 +232,8 @@ impl McpService {
|
||||
merged.apps.claude = true;
|
||||
merged
|
||||
} else {
|
||||
// 真正的新服务器
|
||||
new_count += 1;
|
||||
server.clone()
|
||||
};
|
||||
|
||||
@@ -229,7 +246,7 @@ impl McpService {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// 从 Codex 导入 MCP(v3.7.0 已更新为统一结构)
|
||||
@@ -240,6 +257,8 @@ impl McpService {
|
||||
// 调用原有的导入逻辑(从 mcp.rs)
|
||||
let count = crate::mcp::import_from_codex(&mut temp_config)?;
|
||||
|
||||
let mut new_count = 0;
|
||||
|
||||
// 如果有导入的服务器,保存到数据库
|
||||
if count > 0 {
|
||||
if let Some(servers) = &temp_config.mcp.servers {
|
||||
@@ -251,6 +270,8 @@ impl McpService {
|
||||
merged.apps.codex = true;
|
||||
merged
|
||||
} else {
|
||||
// 真正的新服务器
|
||||
new_count += 1;
|
||||
server.clone()
|
||||
};
|
||||
|
||||
@@ -263,7 +284,7 @@ impl McpService {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// 从 Gemini 导入 MCP(v3.7.0 已更新为统一结构)
|
||||
@@ -274,6 +295,8 @@ impl McpService {
|
||||
// 调用原有的导入逻辑(从 mcp.rs)
|
||||
let count = crate::mcp::import_from_gemini(&mut temp_config)?;
|
||||
|
||||
let mut new_count = 0;
|
||||
|
||||
// 如果有导入的服务器,保存到数据库
|
||||
if count > 0 {
|
||||
if let Some(servers) = &temp_config.mcp.servers {
|
||||
@@ -285,6 +308,8 @@ impl McpService {
|
||||
merged.apps.gemini = true;
|
||||
merged
|
||||
} else {
|
||||
// 真正的新服务器
|
||||
new_count += 1;
|
||||
server.clone()
|
||||
};
|
||||
|
||||
@@ -297,6 +322,44 @@ impl McpService {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// 从 OpenCode 导入 MCP(v3.9.2+ 新增)
|
||||
pub fn import_from_opencode(state: &AppState) -> Result<usize, AppError> {
|
||||
// 创建临时 MultiAppConfig 用于导入
|
||||
let mut temp_config = crate::app_config::MultiAppConfig::default();
|
||||
|
||||
// 调用原有的导入逻辑(从 mcp/opencode.rs)
|
||||
let count = crate::mcp::import_from_opencode(&mut temp_config)?;
|
||||
|
||||
let mut new_count = 0;
|
||||
|
||||
// 如果有导入的服务器,保存到数据库
|
||||
if count > 0 {
|
||||
if let Some(servers) = &temp_config.mcp.servers {
|
||||
let mut existing = state.db.get_all_mcp_servers()?;
|
||||
for server in servers.values() {
|
||||
// 已存在:仅启用 OpenCode,不覆盖其他字段(与导入模块语义保持一致)
|
||||
let to_save = if let Some(existing_server) = existing.get(&server.id) {
|
||||
let mut merged = existing_server.clone();
|
||||
merged.apps.opencode = true;
|
||||
merged
|
||||
} else {
|
||||
// 真正的新服务器
|
||||
new_count += 1;
|
||||
server.clone()
|
||||
};
|
||||
|
||||
state.db.save_mcp_server(&to_save)?;
|
||||
existing.insert(to_save.id.clone(), to_save.clone());
|
||||
|
||||
// 同步到对应应用 live 配置
|
||||
Self::sync_server_to_apps(state, &to_save)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(new_count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ pub use mcp::McpService;
|
||||
pub use prompt::PromptService;
|
||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||
pub use proxy::ProxyService;
|
||||
pub use skill::{Skill, SkillRepo, SkillService};
|
||||
#[allow(unused_imports)]
|
||||
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||
#[allow(unused_imports)]
|
||||
pub use usage_stats::{
|
||||
|
||||
@@ -85,7 +85,7 @@ impl PromptService {
|
||||
if !content_exists {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let backup_id = format!("backup-{timestamp}");
|
||||
let backup_prompt = Prompt {
|
||||
|
||||
@@ -120,6 +120,64 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
// Delegate to write_gemini_live which handles env file writing correctly
|
||||
write_gemini_live(provider)?;
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode - write provider to config
|
||||
use crate::opencode_config;
|
||||
use crate::provider::OpenCodeProviderConfig;
|
||||
|
||||
// Defensive check: if settings_config is a full config structure, extract provider fragment
|
||||
let config_to_write = if let Some(obj) = provider.settings_config.as_object() {
|
||||
// Detect full config structure (has $schema or top-level provider field)
|
||||
if obj.contains_key("$schema") || obj.contains_key("provider") {
|
||||
log::warn!(
|
||||
"OpenCode provider '{}' has full config structure in settings_config, attempting to extract fragment",
|
||||
provider.id
|
||||
);
|
||||
// Try to extract from provider.{id}
|
||||
obj.get("provider")
|
||||
.and_then(|p| p.get(&provider.id))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| provider.settings_config.clone())
|
||||
} else {
|
||||
provider.settings_config.clone()
|
||||
}
|
||||
} else {
|
||||
provider.settings_config.clone()
|
||||
};
|
||||
|
||||
// Convert settings_config to OpenCodeProviderConfig
|
||||
let opencode_config_result =
|
||||
serde_json::from_value::<OpenCodeProviderConfig>(config_to_write.clone());
|
||||
|
||||
match opencode_config_result {
|
||||
Ok(config) => {
|
||||
opencode_config::set_typed_provider(&provider.id, &config)?;
|
||||
log::info!("OpenCode provider '{}' written to live config", provider.id);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to parse OpenCode provider config for '{}': {}",
|
||||
provider.id,
|
||||
e
|
||||
);
|
||||
// Only write if config looks like a valid provider fragment
|
||||
if config_to_write.get("npm").is_some()
|
||||
|| config_to_write.get("options").is_some()
|
||||
{
|
||||
opencode_config::set_provider(&provider.id, config_to_write)?;
|
||||
log::info!(
|
||||
"OpenCode provider '{}' written as raw JSON to live config",
|
||||
provider.id
|
||||
);
|
||||
} else {
|
||||
log::error!(
|
||||
"OpenCode provider '{}' has invalid config structure, skipping write",
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -148,6 +206,15 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
|
||||
// MCP sync
|
||||
McpService::sync_all_enabled(state)?;
|
||||
|
||||
// Skill sync
|
||||
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
|
||||
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
|
||||
// Continue syncing other apps, don't abort
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -211,6 +278,21 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
"config": config_obj
|
||||
}))
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
|
||||
|
||||
let config_path = get_opencode_config_path();
|
||||
if !config_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"opencode.config.missing",
|
||||
"OpenCode 配置文件不存在",
|
||||
"OpenCode configuration file not found",
|
||||
));
|
||||
}
|
||||
|
||||
let config = read_opencode_config()?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +368,24 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
"config": config_obj
|
||||
})
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode - import from live is not the same pattern
|
||||
// For now, return an empty config structure
|
||||
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
|
||||
|
||||
let config_path = get_opencode_config_path();
|
||||
if !config_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"opencode.live.missing",
|
||||
"OpenCode 配置文件不存在",
|
||||
"OpenCode configuration file is missing",
|
||||
));
|
||||
}
|
||||
|
||||
// For OpenCode, we return the full config - but note that OpenCode
|
||||
// uses additive mode, so importing defaults works differently
|
||||
read_opencode_config()?
|
||||
}
|
||||
};
|
||||
|
||||
let mut provider = Provider::with_id(
|
||||
@@ -390,3 +490,84 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove an OpenCode provider from the live configuration
|
||||
///
|
||||
/// This is specific to OpenCode's additive mode - removing a provider
|
||||
/// from the opencode.json file.
|
||||
pub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<(), AppError> {
|
||||
use crate::opencode_config;
|
||||
|
||||
// Check if OpenCode config directory exists
|
||||
if !opencode_config::get_opencode_dir().exists() {
|
||||
log::debug!(
|
||||
"OpenCode config directory doesn't exist, skipping removal of '{}'",
|
||||
provider_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
opencode_config::remove_provider(provider_id)?;
|
||||
log::info!(
|
||||
"OpenCode provider '{}' removed from live config",
|
||||
provider_id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import all providers from OpenCode live config to database
|
||||
///
|
||||
/// This imports existing providers from ~/.config/opencode/opencode.json
|
||||
/// into the CC Switch database. Each provider found will be added to the
|
||||
/// database with is_current set to false.
|
||||
pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, AppError> {
|
||||
use crate::opencode_config;
|
||||
|
||||
let providers = opencode_config::get_typed_providers()?;
|
||||
if providers.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut imported = 0;
|
||||
let existing = state.db.get_all_providers("opencode")?;
|
||||
|
||||
for (id, config) in providers {
|
||||
// Skip if already exists in database
|
||||
if existing.contains_key(&id) {
|
||||
log::debug!(
|
||||
"OpenCode provider '{}' already exists in database, skipping",
|
||||
id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert to Value for settings_config
|
||||
let settings_config = match serde_json::to_value(&config) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to serialize OpenCode provider '{}': {}", id, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Create provider
|
||||
let provider = Provider::with_id(
|
||||
id.clone(),
|
||||
config.name.clone().unwrap_or_else(|| id.clone()),
|
||||
settings_config,
|
||||
None,
|
||||
);
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("opencode", &provider) {
|
||||
log::warn!("Failed to import OpenCode provider '{}': {}", id, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
imported += 1;
|
||||
log::info!("Imported OpenCode provider '{}' from live config", id);
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
@@ -20,13 +20,16 @@ use crate::settings::CustomEndpoint;
|
||||
use crate::store::AppState;
|
||||
|
||||
// Re-export sub-module functions for external access
|
||||
pub use live::{import_default_config, read_live_settings, sync_current_to_live};
|
||||
pub use live::{
|
||||
import_default_config, import_opencode_providers_from_live, read_live_settings,
|
||||
sync_current_to_live,
|
||||
};
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
pub(crate) use live::write_live_snapshot;
|
||||
|
||||
// Internal re-exports
|
||||
use live::write_gemini_live;
|
||||
use live::{remove_opencode_provider_from_live, write_gemini_live};
|
||||
use usage::validate_usage_script;
|
||||
|
||||
/// Provider business logic service
|
||||
@@ -71,6 +74,47 @@ mod tests {
|
||||
assert_eq!(api_key, "token");
|
||||
assert_eq!(base_url, "https://claude.example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_codex_common_config_preserves_mcp_servers_base_url() {
|
||||
let config_toml = r#"model_provider = "azure"
|
||||
model = "gpt-4"
|
||||
disable_response_storage = true
|
||||
|
||||
[model_providers.azure]
|
||||
name = "Azure OpenAI"
|
||||
base_url = "https://azure.example/v1"
|
||||
wire_api = "responses"
|
||||
|
||||
[mcp_servers.my_server]
|
||||
base_url = "http://localhost:8080"
|
||||
"#;
|
||||
|
||||
let settings = json!({ "config": config_toml });
|
||||
let extracted = ProviderService::extract_codex_common_config(&settings)
|
||||
.expect("extract_codex_common_config should succeed");
|
||||
|
||||
assert!(
|
||||
!extracted
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with("model_provider")),
|
||||
"should remove top-level model_provider"
|
||||
);
|
||||
assert!(
|
||||
!extracted
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with("model =")),
|
||||
"should remove top-level model"
|
||||
);
|
||||
assert!(
|
||||
!extracted.contains("[model_providers"),
|
||||
"should remove entire model_providers table"
|
||||
);
|
||||
assert!(
|
||||
extracted.contains("http://localhost:8080"),
|
||||
"should keep mcp_servers.* base_url"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderService {
|
||||
@@ -96,7 +140,13 @@ impl ProviderService {
|
||||
/// 使用有效的当前供应商 ID(验证过存在性)。
|
||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
|
||||
///
|
||||
/// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。
|
||||
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
|
||||
// OpenCode uses additive mode - no "current" provider concept
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
return Ok(String::new());
|
||||
}
|
||||
crate::settings::get_effective_current_provider(&state.db, &app_type)
|
||||
.map(|opt| opt.unwrap_or_default())
|
||||
}
|
||||
@@ -111,7 +161,13 @@ impl ProviderService {
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
// Check if sync is needed (if this is current provider, or no current provider)
|
||||
// OpenCode uses additive mode - always write to live config
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// For other apps: Check if sync is needed (if this is current provider, or no current provider)
|
||||
let current = state.db.get_current_provider(app_type.as_str())?;
|
||||
if current.is_none() {
|
||||
// No current provider, set as current and sync
|
||||
@@ -135,14 +191,20 @@ impl ProviderService {
|
||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||
Self::validate_provider_settings(&app_type, &provider)?;
|
||||
|
||||
// Check if this is current provider (use effective current, not just DB)
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
// OpenCode uses additive mode - always update in live config
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// For other apps: Check if this is current provider (use effective current, not just DB)
|
||||
let effective_current =
|
||||
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||
let is_current = effective_current.as_deref() == Some(provider.id.as_str());
|
||||
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
if is_current {
|
||||
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
|
||||
// - 不写 Live 配置(否则会破坏接管)
|
||||
@@ -175,8 +237,18 @@ impl ProviderService {
|
||||
/// Delete a provider
|
||||
///
|
||||
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
|
||||
/// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。
|
||||
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
// Check both local settings and database
|
||||
// OpenCode uses additive mode - no current provider concept
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
// Remove from database
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
// Also remove from live config
|
||||
remove_opencode_provider_from_live(id)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// For other apps: Check both local settings and database
|
||||
let local_current = crate::settings::get_current_provider(&app_type);
|
||||
let db_current = state.db.get_current_provider(app_type.as_str())?;
|
||||
|
||||
@@ -189,6 +261,27 @@ impl ProviderService {
|
||||
state.db.delete_provider(app_type.as_str(), id)
|
||||
}
|
||||
|
||||
/// Remove provider from live config only (for additive mode apps like OpenCode)
|
||||
///
|
||||
/// Does NOT delete from database - provider remains in the list.
|
||||
/// This is used when user wants to "remove" a provider from active config
|
||||
/// but keep it available for future use.
|
||||
pub fn remove_from_live_config(app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
match app_type {
|
||||
AppType::OpenCode => {
|
||||
remove_opencode_provider_from_live(id)?;
|
||||
}
|
||||
// Future: add other additive mode apps here
|
||||
_ => {
|
||||
return Err(AppError::Message(format!(
|
||||
"App {} does not support remove from live config",
|
||||
app_type.as_str()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Switch to a provider
|
||||
///
|
||||
/// Switch flow:
|
||||
@@ -217,9 +310,12 @@ impl ProviderService {
|
||||
.flatten()
|
||||
.is_some();
|
||||
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
|
||||
let live_taken_over = state
|
||||
.proxy_service
|
||||
.detect_takeover_in_live_config_for_app(&app_type);
|
||||
|
||||
// Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
|
||||
let should_hot_switch = is_app_taken_over && is_proxy_running;
|
||||
let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running;
|
||||
|
||||
if should_hot_switch {
|
||||
// Proxy takeover mode: hot-switch only, don't write Live config
|
||||
@@ -248,6 +344,14 @@ impl ProviderService {
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||
|
||||
// 关键修复:接管模式下切换供应商不会写回 Live 配置,
|
||||
// 需要主动清理 Claude Live 中的“模型覆盖”字段,避免仍以旧模型名发起请求。
|
||||
if matches!(app_type, AppType::Claude) {
|
||||
if let Err(e) = state.proxy_service.cleanup_claude_model_overrides_in_live() {
|
||||
log::warn!("清理 Claude Live 模型字段失败(不影响切换结果): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Note: No Live config write, no MCP sync
|
||||
// The proxy server will route requests to the new provider via is_current
|
||||
return Ok(());
|
||||
@@ -274,22 +378,29 @@ impl ProviderService {
|
||||
|
||||
if let Some(current_id) = current_id {
|
||||
if current_id != id {
|
||||
// Only backfill when switching to a different provider
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
current_provider.settings_config = live_config;
|
||||
// Ignore backfill failure, don't affect switch flow
|
||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||
// OpenCode uses additive mode - all providers coexist in the same file,
|
||||
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
|
||||
if !matches!(app_type, AppType::OpenCode) {
|
||||
// Only backfill when switching to a different provider
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
current_provider.settings_config = live_config;
|
||||
// Ignore backfill failure, don't affect switch flow
|
||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update local settings (device-level, takes priority)
|
||||
crate::settings::set_current_provider(&app_type, Some(id))?;
|
||||
// OpenCode uses additive mode - skip setting is_current (no such concept)
|
||||
if !matches!(app_type, AppType::OpenCode) {
|
||||
// Update local settings (device-level, takes priority)
|
||||
crate::settings::set_current_provider(&app_type, Some(id))?;
|
||||
|
||||
// Update database is_current (as default for new devices)
|
||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||
// Update database is_current (as default for new devices)
|
||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||
}
|
||||
|
||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
@@ -305,6 +416,199 @@ impl ProviderService {
|
||||
sync_current_to_live(state)
|
||||
}
|
||||
|
||||
/// Extract common config snippet from current provider
|
||||
///
|
||||
/// Extracts the current provider's configuration and removes provider-specific fields
|
||||
/// (API keys, model settings, endpoints) to create a reusable common config snippet.
|
||||
pub fn extract_common_config_snippet(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
) -> Result<String, AppError> {
|
||||
// Get current provider
|
||||
let current_id = Self::current(state, app_type.clone())?;
|
||||
if current_id.is_empty() {
|
||||
return Err(AppError::Message("No current provider".to_string()));
|
||||
}
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let provider = providers
|
||||
.get(¤t_id)
|
||||
.ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?;
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract common config snippet from a config value (e.g. editor content).
|
||||
pub fn extract_common_config_snippet_from_settings(
|
||||
app_type: AppType,
|
||||
settings_config: &Value,
|
||||
) -> Result<String, AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract common config for Claude (JSON format)
|
||||
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
let mut config = settings.clone();
|
||||
|
||||
// Fields to exclude from common config
|
||||
const ENV_EXCLUDES: &[&str] = &[
|
||||
// Auth
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
// Models (5 fields)
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_REASONING_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
// Endpoint
|
||||
"ANTHROPIC_BASE_URL",
|
||||
];
|
||||
|
||||
const TOP_LEVEL_EXCLUDES: &[&str] = &[
|
||||
"apiBaseUrl",
|
||||
// Legacy model fields
|
||||
"primaryModel",
|
||||
"smallFastModel",
|
||||
];
|
||||
|
||||
// Remove env fields
|
||||
if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
for key in ENV_EXCLUDES {
|
||||
env.remove(*key);
|
||||
}
|
||||
// If env is empty after removal, remove the env object itself
|
||||
if env.is_empty() {
|
||||
config.as_object_mut().map(|obj| obj.remove("env"));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove top-level fields
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
for key in TOP_LEVEL_EXCLUDES {
|
||||
obj.remove(*key);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if result is empty
|
||||
if config.as_object().is_none_or(|obj| obj.is_empty()) {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Extract common config for Codex (TOML format)
|
||||
fn extract_codex_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// Codex config is stored as { "auth": {...}, "config": "toml string" }
|
||||
let config_toml = settings
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if config_toml.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let mut doc = config_toml
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?;
|
||||
|
||||
// Remove provider-specific fields.
|
||||
let root = doc.as_table_mut();
|
||||
root.remove("model");
|
||||
root.remove("model_provider");
|
||||
// Legacy/alt formats might use a top-level base_url.
|
||||
root.remove("base_url");
|
||||
|
||||
// Remove entire model_providers table (provider-specific configuration)
|
||||
root.remove("model_providers");
|
||||
|
||||
// Clean up multiple empty lines (keep at most one blank line).
|
||||
let mut cleaned = String::new();
|
||||
let mut blank_run = 0usize;
|
||||
for line in doc.to_string().lines() {
|
||||
if line.trim().is_empty() {
|
||||
blank_run += 1;
|
||||
if blank_run <= 1 {
|
||||
cleaned.push('\n');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
blank_run = 0;
|
||||
cleaned.push_str(line);
|
||||
cleaned.push('\n');
|
||||
}
|
||||
|
||||
Ok(cleaned.trim().to_string())
|
||||
}
|
||||
|
||||
/// Extract common config for Gemini (JSON format)
|
||||
///
|
||||
/// Extracts `.env` values while excluding provider-specific credentials:
|
||||
/// - GOOGLE_GEMINI_BASE_URL
|
||||
/// - GEMINI_API_KEY
|
||||
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
let env = settings.get("env").and_then(|v| v.as_object());
|
||||
|
||||
let mut snippet = serde_json::Map::new();
|
||||
if let Some(env) = env {
|
||||
for (key, value) in env {
|
||||
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
||||
continue;
|
||||
}
|
||||
let Value::String(v) = value else {
|
||||
continue;
|
||||
};
|
||||
let trimmed = v.trim();
|
||||
if !trimmed.is_empty() {
|
||||
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if snippet.is_empty() {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&Value::Object(snippet))
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Extract common config for OpenCode (JSON format)
|
||||
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// OpenCode uses a different config structure with npm, options, models
|
||||
// For common config, we exclude provider-specific fields like apiKey
|
||||
let mut config = settings.clone();
|
||||
|
||||
// Remove provider-specific fields
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
|
||||
options.remove("apiKey");
|
||||
options.remove("baseURL");
|
||||
}
|
||||
// Keep npm and models as they might be common
|
||||
}
|
||||
|
||||
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Import default configuration from live files (re-export)
|
||||
///
|
||||
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
||||
@@ -395,6 +699,7 @@ impl ProviderService {
|
||||
base_url: Option<&str>,
|
||||
access_token: Option<&str>,
|
||||
user_id: Option<&str>,
|
||||
template_type: Option<&str>,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
usage::test_usage_script(
|
||||
state,
|
||||
@@ -406,6 +711,7 @@ impl ProviderService {
|
||||
base_url,
|
||||
access_token,
|
||||
user_id,
|
||||
template_type,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -469,6 +775,17 @@ impl ProviderService {
|
||||
use crate::gemini_config::validate_gemini_settings;
|
||||
validate_gemini_settings(&provider.settings_config)?
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses a different config structure: { npm, options, models }
|
||||
// Basic validation - must be an object
|
||||
if !provider.settings_config.is_object() {
|
||||
return Err(AppError::localized(
|
||||
"provider.opencode.settings.not_object",
|
||||
"OpenCode 配置必须是 JSON 对象",
|
||||
"OpenCode configuration must be a JSON object",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and clean UsageScript configuration (common for all app types)
|
||||
@@ -606,6 +923,40 @@ impl ProviderService {
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses options.apiKey and options.baseURL
|
||||
let options = provider
|
||||
.settings_config
|
||||
.get("options")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.opencode.options.missing",
|
||||
"配置格式错误: 缺少 options",
|
||||
"Invalid configuration: missing options section",
|
||||
)
|
||||
})?;
|
||||
|
||||
let api_key = options
|
||||
.get("apiKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.opencode.api_key.missing",
|
||||
"缺少 API Key",
|
||||
"API key is missing",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let base_url = options
|
||||
.get("baseURL")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -736,15 +1087,15 @@ impl ProviderService {
|
||||
// 删除生成的子供应商
|
||||
if let Some(p) = provider {
|
||||
if p.apps.claude {
|
||||
let claude_id = format!("universal-claude-{}", id);
|
||||
let claude_id = format!("universal-claude-{id}");
|
||||
let _ = state.db.delete_provider("claude", &claude_id);
|
||||
}
|
||||
if p.apps.codex {
|
||||
let codex_id = format!("universal-codex-{}", id);
|
||||
let codex_id = format!("universal-codex-{id}");
|
||||
let _ = state.db.delete_provider("codex", &codex_id);
|
||||
}
|
||||
if p.apps.gemini {
|
||||
let gemini_id = format!("universal-gemini-{}", id);
|
||||
let gemini_id = format!("universal-gemini-{id}");
|
||||
let _ = state.db.delete_provider("gemini", &gemini_id);
|
||||
}
|
||||
}
|
||||
@@ -757,7 +1108,7 @@ impl ProviderService {
|
||||
let provider = state
|
||||
.db
|
||||
.get_universal_provider(id)?
|
||||
.ok_or_else(|| AppError::Message(format!("统一供应商 {} 不存在", id)))?;
|
||||
.ok_or_else(|| AppError::Message(format!("统一供应商 {id} 不存在")))?;
|
||||
|
||||
// 同步到 Claude
|
||||
if let Some(mut claude_provider) = provider.to_claude_provider() {
|
||||
@@ -770,7 +1121,7 @@ impl ProviderService {
|
||||
state.db.save_provider("claude", &claude_provider)?;
|
||||
} else {
|
||||
// 如果禁用了 Claude,删除对应的子供应商
|
||||
let claude_id = format!("universal-claude-{}", id);
|
||||
let claude_id = format!("universal-claude-{id}");
|
||||
let _ = state.db.delete_provider("claude", &claude_id);
|
||||
}
|
||||
|
||||
@@ -784,7 +1135,7 @@ impl ProviderService {
|
||||
}
|
||||
state.db.save_provider("codex", &codex_provider)?;
|
||||
} else {
|
||||
let codex_id = format!("universal-codex-{}", id);
|
||||
let codex_id = format!("universal-codex-{id}");
|
||||
let _ = state.db.delete_provider("codex", &codex_id);
|
||||
}
|
||||
|
||||
@@ -798,7 +1149,7 @@ impl ProviderService {
|
||||
}
|
||||
state.db.save_provider("gemini", &gemini_provider)?;
|
||||
} else {
|
||||
let gemini_id = format!("universal-gemini-{}", id);
|
||||
let gemini_id = format!("universal-gemini-{id}");
|
||||
let _ = state.db.delete_provider("gemini", &gemini_id);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ pub(crate) async fn execute_and_format_usage_result(
|
||||
timeout: u64,
|
||||
access_token: Option<&str>,
|
||||
user_id: Option<&str>,
|
||||
template_type: Option<&str>,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
match usage_script::execute_usage_script(
|
||||
script_code,
|
||||
@@ -25,6 +26,7 @@ pub(crate) async fn execute_and_format_usage_result(
|
||||
timeout,
|
||||
access_token,
|
||||
user_id,
|
||||
template_type,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -113,7 +115,7 @@ pub async fn query_usage(
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
let (script_code, timeout, api_key, base_url, access_token, user_id) = {
|
||||
let (script_code, timeout, api_key, base_url, access_token, user_id, template_type) = {
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let provider = providers.get(provider_id).ok_or_else(|| {
|
||||
AppError::localized(
|
||||
@@ -164,6 +166,7 @@ pub async fn query_usage(
|
||||
base_url,
|
||||
usage_script.access_token.clone(),
|
||||
usage_script.user_id.clone(),
|
||||
usage_script.template_type.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -174,6 +177,7 @@ pub async fn query_usage(
|
||||
timeout,
|
||||
access_token.as_deref(),
|
||||
user_id.as_deref(),
|
||||
template_type.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -190,6 +194,7 @@ pub async fn test_usage_script(
|
||||
base_url: Option<&str>,
|
||||
access_token: Option<&str>,
|
||||
user_id: Option<&str>,
|
||||
template_type: Option<&str>,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
// Use provided credential parameters directly for testing
|
||||
execute_and_format_usage_result(
|
||||
@@ -199,6 +204,7 @@ pub async fn test_usage_script(
|
||||
timeout,
|
||||
access_token,
|
||||
user_id,
|
||||
template_type,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
+171
-16
@@ -17,6 +17,20 @@ use tokio::sync::RwLock;
|
||||
/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)
|
||||
const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
|
||||
|
||||
/// 代理接管模式下需要从 Claude Live 配置中移除的“模型覆盖”字段。
|
||||
///
|
||||
/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,
|
||||
/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。
|
||||
const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_REASONING_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
// Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型
|
||||
"ANTHROPIC_SMALL_FAST_MODEL",
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyService {
|
||||
db: Arc<Database>,
|
||||
@@ -34,6 +48,31 @@ impl ProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理接管模式下 Claude Live 配置中的模型覆盖字段。
|
||||
///
|
||||
/// 这可以避免“接管开启后切换供应商仍使用旧模型”的问题。
|
||||
/// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。
|
||||
pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {
|
||||
let mut config = self.read_claude_live()?;
|
||||
|
||||
let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut changed = false;
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
if env.remove(key).is_some() {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
self.write_claude_live(&config)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置 AppHandle(在应用初始化时调用)
|
||||
pub fn set_app_handle(&self, handle: tauri::AppHandle) {
|
||||
futures::executor::block_on(async {
|
||||
@@ -193,7 +232,7 @@ impl ProxyService {
|
||||
self.start().await?;
|
||||
}
|
||||
|
||||
// 2) 已接管则直接返回(幂等)
|
||||
// 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管
|
||||
let current_config = self
|
||||
.db
|
||||
.get_proxy_config_for_app(app_type_str)
|
||||
@@ -201,7 +240,22 @@ impl ProxyService {
|
||||
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
|
||||
|
||||
if current_config.enabled {
|
||||
return Ok(());
|
||||
let has_backup = match self.db.get_live_backup(app_type_str).await {
|
||||
Ok(v) => v.is_some(),
|
||||
Err(e) => {
|
||||
log::warn!("读取 {app_type_str} 备份失败(将继续重建接管): {e}");
|
||||
false
|
||||
}
|
||||
};
|
||||
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);
|
||||
|
||||
if has_backup || live_taken_over {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"{app_type_str} 标记为已接管,但缺少备份或占位符,正在重新接管并补齐备份"
|
||||
);
|
||||
}
|
||||
|
||||
// 3) 备份 Live 配置(严格:目标 app 不存在则报错)
|
||||
@@ -314,6 +368,10 @@ impl ProxyService {
|
||||
AppType::Claude => self.read_claude_live()?,
|
||||
AppType::Codex => self.read_codex_live()?,
|
||||
AppType::Gemini => self.read_gemini_live()?,
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
self.sync_live_config_to_provider(app_type, &live_config)
|
||||
@@ -387,8 +445,21 @@ impl ProxyService {
|
||||
}
|
||||
None => {
|
||||
// 至少写入一份可用的 Token
|
||||
provider.settings_config["env"] =
|
||||
json!({ token_key: token });
|
||||
if provider.settings_config.is_null() {
|
||||
provider.settings_config = json!({});
|
||||
}
|
||||
|
||||
if let Some(root) = provider.settings_config.as_object_mut()
|
||||
{
|
||||
root.insert(
|
||||
"env".to_string(),
|
||||
json!({ token_key: token }),
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,9 +502,20 @@ impl ProxyService {
|
||||
{
|
||||
auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token));
|
||||
} else {
|
||||
provider.settings_config["auth"] = json!({
|
||||
"OPENAI_API_KEY": token
|
||||
});
|
||||
if provider.settings_config.is_null() {
|
||||
provider.settings_config = json!({});
|
||||
}
|
||||
|
||||
if let Some(root) = provider.settings_config.as_object_mut() {
|
||||
root.insert(
|
||||
"auth".to_string(),
|
||||
json!({ "OPENAI_API_KEY": token }),
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.db.update_provider_settings_config(
|
||||
@@ -472,9 +554,20 @@ impl ProxyService {
|
||||
{
|
||||
env_obj.insert("GEMINI_API_KEY".to_string(), json!(token));
|
||||
} else {
|
||||
provider.settings_config["env"] = json!({
|
||||
"GEMINI_API_KEY": token
|
||||
});
|
||||
if provider.settings_config.is_null() {
|
||||
provider.settings_config = json!({});
|
||||
}
|
||||
|
||||
if let Some(root) = provider.settings_config.as_object_mut() {
|
||||
root.insert(
|
||||
"env".to_string(),
|
||||
json!({ "GEMINI_API_KEY": token }),
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.db.update_provider_settings_config(
|
||||
@@ -492,6 +585,9 @@ impl ProxyService {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -670,6 +766,10 @@ impl ProxyService {
|
||||
AppType::Claude => ("claude", self.read_claude_live()?),
|
||||
AppType::Codex => ("codex", self.read_codex_live()?),
|
||||
AppType::Gemini => ("gemini", self.read_gemini_live()?),
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let json_str = serde_json::to_string(&config)
|
||||
@@ -725,6 +825,10 @@ impl ProxyService {
|
||||
if let Ok(mut live_config) = self.read_claude_live() {
|
||||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
||||
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
env.remove(key);
|
||||
}
|
||||
// 仅覆盖已存在的 Token 字段,避免新增字段导致用户困惑;
|
||||
// 若完全没有 Token 字段,则写入 ANTHROPIC_AUTH_TOKEN 占位符用于避免客户端警告。
|
||||
let token_keys = [
|
||||
@@ -805,6 +909,10 @@ impl ProxyService {
|
||||
let mut live_config = self.read_claude_live()?;
|
||||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
||||
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
env.remove(key);
|
||||
}
|
||||
|
||||
let token_keys = [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
@@ -870,6 +978,10 @@ impl ProxyService {
|
||||
self.write_gemini_live(&live_config)?;
|
||||
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -884,6 +996,10 @@ impl ProxyService {
|
||||
if let Ok(mut live_config) = self.read_claude_live() {
|
||||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
||||
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
env.remove(key);
|
||||
}
|
||||
|
||||
let token_keys = [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
@@ -949,6 +1065,9 @@ impl ProxyService {
|
||||
let _ = self.write_gemini_live(&live_config);
|
||||
}
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -981,6 +1100,9 @@ impl ProxyService {
|
||||
log::info!("Gemini Live 配置已恢复");
|
||||
}
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1060,10 +1182,14 @@ impl ProxyService {
|
||||
AppType::Claude => self.write_claude_live(config),
|
||||
AppType::Codex => self.write_codex_live(config),
|
||||
AppType::Gemini => self.write_gemini_live(config),
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features
|
||||
Err("OpenCode 不支持代理功能".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {
|
||||
pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {
|
||||
match app_type {
|
||||
AppType::Claude => match self.read_claude_live() {
|
||||
Ok(config) => Self::is_claude_live_taken_over(&config),
|
||||
@@ -1077,6 +1203,10 @@ impl ProxyService {
|
||||
Ok(config) => Self::is_gemini_live_taken_over(&config),
|
||||
Err(_) => false,
|
||||
},
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy takeover
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1116,6 +1246,10 @@ impl ProxyService {
|
||||
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
|
||||
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
|
||||
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1257,10 +1391,8 @@ impl ProxyService {
|
||||
|
||||
/// 检查是否处于 Live 接管模式
|
||||
pub async fn is_takeover_active(&self) -> Result<bool, String> {
|
||||
self.db
|
||||
.is_live_takeover_active()
|
||||
.await
|
||||
.map_err(|e| format!("检查接管状态失败: {e}"))
|
||||
let status = self.get_takeover_status().await?;
|
||||
Ok(status.claude || status.codex || status.gemini)
|
||||
}
|
||||
|
||||
/// 从异常退出中恢复(启动时调用)
|
||||
@@ -1462,7 +1594,30 @@ impl ProxyService {
|
||||
if !path.exists() {
|
||||
return Err("Claude 配置文件不存在".to_string());
|
||||
}
|
||||
read_json_file(&path).map_err(|e| format!("读取 Claude 配置失败: {e}"))
|
||||
|
||||
let mut value: Value =
|
||||
read_json_file(&path).map_err(|e| format!("读取 Claude 配置失败: {e}"))?;
|
||||
|
||||
if value.is_null() {
|
||||
value = json!({});
|
||||
}
|
||||
|
||||
if !value.is_object() {
|
||||
let kind = match &value {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "boolean",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
};
|
||||
return Err(format!(
|
||||
"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn write_claude_live(&self, config: &Value) -> Result<(), String> {
|
||||
|
||||
+670
-285
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
use futures::future::join_all;
|
||||
use reqwest::{Client, Url};
|
||||
use serde::Serialize;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@@ -65,17 +65,21 @@ impl SpeedtestService {
|
||||
}
|
||||
|
||||
let timeout = Self::sanitize_timeout(timeout_secs);
|
||||
let client = Self::build_client(timeout)?;
|
||||
let (client, request_timeout) = Self::build_client(timeout)?;
|
||||
|
||||
let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
|
||||
let _ = client.get(parsed_url.clone()).send().await;
|
||||
let _ = client
|
||||
.get(parsed_url.clone())
|
||||
.timeout(request_timeout)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
// 第二次请求开始计时,并将其作为结果返回。
|
||||
let start = Instant::now();
|
||||
let latency = match client.get(parsed_url).send().await {
|
||||
let latency = match client.get(parsed_url).timeout(request_timeout).send().await {
|
||||
Ok(resp) => EndpointLatency {
|
||||
url: trimmed,
|
||||
latency: Some(start.elapsed().as_millis()),
|
||||
@@ -112,19 +116,11 @@ impl SpeedtestService {
|
||||
Ok(results.into_iter().flatten().collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.user_agent("cc-switch-speedtest/1.0")
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
AppError::localized(
|
||||
"speedtest.client_create_failed",
|
||||
format!("创建 HTTP 客户端失败: {e}"),
|
||||
format!("Failed to create HTTP client: {e}"),
|
||||
)
|
||||
})
|
||||
fn build_client(timeout_secs: u64) -> Result<(Client, std::time::Duration), AppError> {
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
// 返回 timeout Duration 供请求级别使用
|
||||
let timeout = std::time::Duration::from_secs(timeout_secs);
|
||||
Ok((crate::proxy::http_client::get(), timeout))
|
||||
}
|
||||
|
||||
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
|
||||
|
||||
@@ -7,7 +7,7 @@ use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
@@ -36,6 +36,13 @@ pub struct StreamCheckConfig {
|
||||
pub codex_model: String,
|
||||
/// Gemini 测试模型
|
||||
pub gemini_model: String,
|
||||
/// 检查提示词
|
||||
#[serde(default = "default_test_prompt")]
|
||||
pub test_prompt: String,
|
||||
}
|
||||
|
||||
fn default_test_prompt() -> String {
|
||||
"Who are you?".to_string()
|
||||
}
|
||||
|
||||
impl Default for StreamCheckConfig {
|
||||
@@ -47,6 +54,7 @@ impl Default for StreamCheckConfig {
|
||||
claude_model: "claude-haiku-4-5-20251001".to_string(),
|
||||
codex_model: "gpt-5.1-codex@low".to_string(),
|
||||
gemini_model: "gemini-3-pro-preview".to_string(),
|
||||
test_prompt: default_test_prompt(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,7 +118,7 @@ impl StreamCheckService {
|
||||
Ok(last_result.unwrap_or_else(|| StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: "检查失败".to_string(),
|
||||
message: "Check failed".to_string(),
|
||||
response_time_ms: None,
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
@@ -130,29 +138,60 @@ impl StreamCheckService {
|
||||
|
||||
let base_url = adapter
|
||||
.extract_base_url(provider)
|
||||
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
|
||||
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?;
|
||||
|
||||
let auth = adapter
|
||||
.extract_auth(provider)
|
||||
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
|
||||
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(config.timeout_secs))
|
||||
.user_agent("cc-switch/1.0")
|
||||
.build()
|
||||
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
let client = crate::proxy::http_client::get();
|
||||
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
|
||||
|
||||
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
||||
let test_prompt = &config.test_prompt;
|
||||
|
||||
let result = match app_type {
|
||||
AppType::Claude => {
|
||||
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
|
||||
Self::check_claude_stream(
|
||||
&client,
|
||||
&base_url,
|
||||
&auth,
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AppType::Codex => {
|
||||
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
|
||||
Self::check_codex_stream(
|
||||
&client,
|
||||
&base_url,
|
||||
&auth,
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AppType::Gemini => {
|
||||
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
|
||||
Self::check_gemini_stream(
|
||||
&client,
|
||||
&base_url,
|
||||
&auth,
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support stream check yet
|
||||
return Err(AppError::localized(
|
||||
"opencode_no_stream_check",
|
||||
"OpenCode 暂不支持健康检查",
|
||||
"OpenCode does not support health check yet",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -166,7 +205,7 @@ impl StreamCheckService {
|
||||
Ok(StreamCheckResult {
|
||||
status: health_status,
|
||||
success: true,
|
||||
message: "检查成功".to_string(),
|
||||
message: "Check succeeded".to_string(),
|
||||
response_time_ms: Some(response_time),
|
||||
http_status: Some(status_code),
|
||||
model_used: model,
|
||||
@@ -188,31 +227,69 @@ impl StreamCheckService {
|
||||
}
|
||||
|
||||
/// Claude 流式检查
|
||||
///
|
||||
/// 严格按照 Claude CLI 真实请求格式构建请求
|
||||
async fn check_claude_stream(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
auth: &AuthInfo,
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
// URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源)
|
||||
let url = if base.ends_with("/v1") {
|
||||
format!("{base}/messages")
|
||||
format!("{base}/messages?beta=true")
|
||||
} else {
|
||||
format!("{base}/v1/messages")
|
||||
format!("{base}/v1/messages?beta=true")
|
||||
};
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"max_tokens": 1,
|
||||
"messages": [{ "role": "user", "content": "hi" }],
|
||||
"messages": [{ "role": "user", "content": test_prompt }],
|
||||
"stream": true
|
||||
});
|
||||
|
||||
// 获取本地系统信息
|
||||
let os_name = Self::get_os_name();
|
||||
let arch_name = Self::get_arch_name();
|
||||
|
||||
// 严格按照 Claude CLI 请求格式设置 headers
|
||||
let response = client
|
||||
.post(&url)
|
||||
// 认证 headers(双重认证)
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("x-api-key", &auth.api_key)
|
||||
// Anthropic 必需 headers
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("Content-Type", "application/json")
|
||||
.header(
|
||||
"anthropic-beta",
|
||||
"claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||
)
|
||||
.header("anthropic-dangerous-direct-browser-access", "true")
|
||||
// 内容类型 headers
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json")
|
||||
.header("accept-encoding", "identity")
|
||||
.header("accept-language", "*")
|
||||
// 客户端标识 headers
|
||||
.header("user-agent", "claude-cli/2.1.2 (external, cli)")
|
||||
.header("x-app", "cli")
|
||||
// x-stainless SDK headers(动态获取本地系统信息)
|
||||
.header("x-stainless-lang", "js")
|
||||
.header("x-stainless-package-version", "0.70.0")
|
||||
.header("x-stainless-os", os_name)
|
||||
.header("x-stainless-arch", arch_name)
|
||||
.header("x-stainless-runtime", "node")
|
||||
.header("x-stainless-runtime-version", "v22.20.0")
|
||||
.header("x-stainless-retry-count", "0")
|
||||
.header("x-stainless-timeout", "600")
|
||||
// 其他 headers
|
||||
.header("sec-fetch-mode", "cors")
|
||||
.header("connection", "keep-alive")
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -230,51 +307,64 @@ impl StreamCheckService {
|
||||
if let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(_) => Ok((status, model.to_string())),
|
||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
||||
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||
}
|
||||
} else {
|
||||
Err(AppError::Message("未收到响应数据".to_string()))
|
||||
Err(AppError::Message("No response data received".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex 流式检查
|
||||
///
|
||||
/// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API)
|
||||
async fn check_codex_stream(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
auth: &AuthInfo,
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
|
||||
let url = if base.ends_with("/v1") {
|
||||
format!("{base}/chat/completions")
|
||||
format!("{base}/responses")
|
||||
} else {
|
||||
format!("{base}/v1/chat/completions")
|
||||
format!("{base}/v1/responses")
|
||||
};
|
||||
|
||||
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
|
||||
|
||||
// 获取本地系统信息
|
||||
let os_name = Self::get_os_name();
|
||||
let arch_name = Self::get_arch_name();
|
||||
|
||||
// Responses API 请求体格式 (input 必须是数组)
|
||||
let mut body = json!({
|
||||
"model": actual_model,
|
||||
"messages": [
|
||||
{ "role": "system", "content": "" },
|
||||
{ "role": "assistant", "content": "" },
|
||||
{ "role": "user", "content": "hi" }
|
||||
],
|
||||
"max_tokens": 1,
|
||||
"temperature": 0,
|
||||
"input": [{ "role": "user", "content": test_prompt }],
|
||||
"stream": true
|
||||
});
|
||||
|
||||
// 如果是推理模型,添加 reasoning_effort
|
||||
if let Some(effort) = reasoning_effort {
|
||||
body["reasoning_effort"] = json!(effort);
|
||||
body["reasoning"] = json!({ "effort": effort });
|
||||
}
|
||||
|
||||
// 严格按照 Codex CLI 请求格式设置 headers
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity")
|
||||
.header(
|
||||
"user-agent",
|
||||
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
|
||||
)
|
||||
.header("originator", "codex_cli_rs")
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -291,10 +381,10 @@ impl StreamCheckService {
|
||||
if let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(_) => Ok((status, model.to_string())),
|
||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
||||
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||
}
|
||||
} else {
|
||||
Err(AppError::Message("未收到响应数据".to_string()))
|
||||
Err(AppError::Message("No response data received".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,13 +394,15 @@ impl StreamCheckService {
|
||||
base_url: &str,
|
||||
auth: &AuthInfo,
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let url = format!("{base}/v1/chat/completions");
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"messages": [{ "role": "user", "content": "hi" }],
|
||||
"messages": [{ "role": "user", "content": test_prompt }],
|
||||
"max_tokens": 1,
|
||||
"temperature": 0,
|
||||
"stream": true
|
||||
@@ -320,6 +412,7 @@ impl StreamCheckService {
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -336,10 +429,10 @@ impl StreamCheckService {
|
||||
if let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(_) => Ok((status, model.to_string())),
|
||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
||||
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||
}
|
||||
} else {
|
||||
Err(AppError::Message("未收到响应数据".to_string()))
|
||||
Err(AppError::Message("No response data received".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,7 +447,6 @@ impl StreamCheckService {
|
||||
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||
/// 返回 (实际模型名, Option<推理等级>)
|
||||
fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
|
||||
// 查找 @ 或 # 分隔符
|
||||
if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
|
||||
let actual_model = model[..pos].to_string();
|
||||
let effort = model[pos + 1..].to_string();
|
||||
@@ -367,17 +459,14 @@ impl StreamCheckService {
|
||||
|
||||
fn should_retry(msg: &str) -> bool {
|
||||
let lower = msg.to_lowercase();
|
||||
lower.contains("timeout")
|
||||
|| lower.contains("abort")
|
||||
|| lower.contains("中断")
|
||||
|| lower.contains("超时")
|
||||
lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out")
|
||||
}
|
||||
|
||||
fn map_request_error(e: reqwest::Error) -> AppError {
|
||||
if e.is_timeout() {
|
||||
AppError::Message("请求超时".to_string())
|
||||
AppError::Message("Request timeout".to_string())
|
||||
} else if e.is_connect() {
|
||||
AppError::Message(format!("连接失败: {e}"))
|
||||
AppError::Message(format!("Connection failed: {e}"))
|
||||
} else {
|
||||
AppError::Message(e.to_string())
|
||||
}
|
||||
@@ -396,9 +485,24 @@ impl StreamCheckService {
|
||||
}
|
||||
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
|
||||
.unwrap_or_else(|| config.gemini_model.clone()),
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses models map in settings_config
|
||||
// Try to extract first model from the models object
|
||||
Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_opencode_model(provider: &Provider) -> Option<String> {
|
||||
let models = provider
|
||||
.settings_config
|
||||
.get("models")
|
||||
.and_then(|m| m.as_object())?;
|
||||
|
||||
// Return the first model ID from the models map
|
||||
models.keys().next().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
|
||||
provider
|
||||
.settings_config
|
||||
@@ -424,6 +528,26 @@ impl StreamCheckService {
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// 获取操作系统名称(映射为 Claude CLI 使用的格式)
|
||||
fn get_os_name() -> &'static str {
|
||||
match std::env::consts::OS {
|
||||
"macos" => "MacOS",
|
||||
"linux" => "Linux",
|
||||
"windows" => "Windows",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 CPU 架构名称(映射为 Claude CLI 使用的格式)
|
||||
fn get_arch_name() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"aarch64" => "arm64",
|
||||
"x86_64" => "x86_64",
|
||||
"x86" => "x86",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -448,9 +572,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_should_retry() {
|
||||
assert!(StreamCheckService::should_retry("请求超时"));
|
||||
assert!(StreamCheckService::should_retry("request timeout"));
|
||||
assert!(!StreamCheckService::should_retry("API Key 无效"));
|
||||
assert!(StreamCheckService::should_retry("Request timeout"));
|
||||
assert!(StreamCheckService::should_retry("request timed out"));
|
||||
assert!(StreamCheckService::should_retry("connection abort"));
|
||||
assert!(!StreamCheckService::should_retry("API Key invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -478,4 +603,33 @@ mod tests {
|
||||
assert_eq!(model, "gpt-4o-mini");
|
||||
assert_eq!(effort, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_os_name() {
|
||||
let os_name = StreamCheckService::get_os_name();
|
||||
// 确保返回非空字符串
|
||||
assert!(!os_name.is_empty());
|
||||
// 在 macOS 上应该返回 "MacOS"
|
||||
#[cfg(target_os = "macos")]
|
||||
assert_eq!(os_name, "MacOS");
|
||||
// 在 Linux 上应该返回 "Linux"
|
||||
#[cfg(target_os = "linux")]
|
||||
assert_eq!(os_name, "Linux");
|
||||
// 在 Windows 上应该返回 "Windows"
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(os_name, "Windows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_arch_name() {
|
||||
let arch_name = StreamCheckService::get_arch_name();
|
||||
// 确保返回非空字符串
|
||||
assert!(!arch_name.is_empty());
|
||||
// 在 ARM64 上应该返回 "arm64"
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
assert_eq!(arch_name, "arm64");
|
||||
// 在 x86_64 上应该返回 "x86_64"
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
assert_eq!(arch_name, "x86_64");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use chrono::{Duration, Utc};
|
||||
use chrono::{Local, TimeZone};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -181,129 +181,114 @@ impl Database {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 获取每日趋势
|
||||
pub fn get_daily_trends(&self, days: u32) -> Result<Vec<DailyStats>, AppError> {
|
||||
/// 获取每日趋势(滑动窗口,<=24h 按小时,>24h 按天,窗口与汇总一致)
|
||||
pub fn get_daily_trends(
|
||||
&self,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
) -> Result<Vec<DailyStats>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
if days <= 1 {
|
||||
let sql = "SELECT
|
||||
strftime('%Y-%m-%dT%H:00:00Z', datetime(created_at, 'unixepoch')) as bucket,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
|
||||
FROM proxy_request_logs
|
||||
WHERE created_at >= strftime('%s', 'now', '-1 day')
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket ASC";
|
||||
let end_ts = end_date.unwrap_or_else(|| Local::now().timestamp());
|
||||
let mut start_ts = start_date.unwrap_or_else(|| end_ts - 24 * 60 * 60);
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(DailyStats {
|
||||
date: row.get(0)?,
|
||||
request_count: row.get::<_, i64>(1)? as u64,
|
||||
total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
|
||||
total_tokens: row.get::<_, i64>(3)? as u64,
|
||||
total_input_tokens: row.get::<_, i64>(4)? as u64,
|
||||
total_output_tokens: row.get::<_, i64>(5)? as u64,
|
||||
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
|
||||
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut buckets: HashMap<String, DailyStats> = HashMap::new();
|
||||
for row in rows {
|
||||
let stat = row?;
|
||||
buckets.insert(stat.date.clone(), stat);
|
||||
}
|
||||
|
||||
let mut stats = Vec::new();
|
||||
let today = Utc::now().date_naive();
|
||||
for hour in 0..24 {
|
||||
let bucket = today
|
||||
.and_hms_opt(hour, 0, 0)
|
||||
.unwrap()
|
||||
.format("%Y-%m-%dT%H:00:00Z")
|
||||
.to_string();
|
||||
|
||||
if let Some(stat) = buckets.remove(&bucket) {
|
||||
stats.push(stat);
|
||||
} else {
|
||||
stats.push(DailyStats {
|
||||
date: bucket,
|
||||
request_count: 0,
|
||||
total_cost: "0.000000".to_string(),
|
||||
total_tokens: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_creation_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(stats)
|
||||
} else {
|
||||
let sql = "SELECT
|
||||
date(created_at, 'unixepoch') as bucket,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
|
||||
FROM proxy_request_logs
|
||||
WHERE created_at >= strftime('%s', 'now', ?)
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket ASC";
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map([format!("-{days} days")], |row| {
|
||||
Ok(DailyStats {
|
||||
date: row.get(0)?,
|
||||
request_count: row.get::<_, i64>(1)? as u64,
|
||||
total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
|
||||
total_tokens: row.get::<_, i64>(3)? as u64,
|
||||
total_input_tokens: row.get::<_, i64>(4)? as u64,
|
||||
total_output_tokens: row.get::<_, i64>(5)? as u64,
|
||||
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
|
||||
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for row in rows {
|
||||
let stat = row?;
|
||||
map.insert(stat.date.clone(), stat);
|
||||
}
|
||||
|
||||
let mut stats = Vec::new();
|
||||
let start_day =
|
||||
Utc::now().date_naive() - Duration::days((days.saturating_sub(1)) as i64);
|
||||
|
||||
for i in 0..days {
|
||||
let day = start_day + Duration::days(i as i64);
|
||||
let key = day.format("%Y-%m-%d").to_string();
|
||||
if let Some(stat) = map.remove(&key) {
|
||||
stats.push(stat);
|
||||
} else {
|
||||
stats.push(DailyStats {
|
||||
date: key,
|
||||
request_count: 0,
|
||||
total_cost: "0.000000".to_string(),
|
||||
total_tokens: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_creation_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(stats)
|
||||
if start_ts >= end_ts {
|
||||
start_ts = end_ts - 24 * 60 * 60;
|
||||
}
|
||||
|
||||
let duration = end_ts - start_ts;
|
||||
let bucket_seconds: i64 = if duration <= 24 * 60 * 60 {
|
||||
60 * 60
|
||||
} else {
|
||||
24 * 60 * 60
|
||||
};
|
||||
let mut bucket_count: i64 = if duration <= 0 {
|
||||
1
|
||||
} else {
|
||||
((duration as f64) / bucket_seconds as f64).ceil() as i64
|
||||
};
|
||||
|
||||
// 固定 24 小时窗口为 24 个小时桶,避免浮点误差
|
||||
if bucket_seconds == 60 * 60 {
|
||||
bucket_count = 24;
|
||||
}
|
||||
|
||||
if bucket_count < 1 {
|
||||
bucket_count = 1;
|
||||
}
|
||||
|
||||
let sql = "
|
||||
SELECT
|
||||
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
|
||||
FROM proxy_request_logs
|
||||
WHERE created_at >= ?1 AND created_at <= ?2
|
||||
GROUP BY bucket_idx
|
||||
ORDER BY bucket_idx ASC";
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
DailyStats {
|
||||
date: String::new(),
|
||||
request_count: row.get::<_, i64>(1)? as u64,
|
||||
total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
|
||||
total_tokens: row.get::<_, i64>(3)? as u64,
|
||||
total_input_tokens: row.get::<_, i64>(4)? as u64,
|
||||
total_output_tokens: row.get::<_, i64>(5)? as u64,
|
||||
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
|
||||
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut map: HashMap<i64, DailyStats> = HashMap::new();
|
||||
for row in rows {
|
||||
let (mut bucket_idx, stat) = row?;
|
||||
if bucket_idx < 0 {
|
||||
continue;
|
||||
}
|
||||
if bucket_idx >= bucket_count {
|
||||
bucket_idx = bucket_count - 1;
|
||||
}
|
||||
map.insert(bucket_idx, stat);
|
||||
}
|
||||
|
||||
let mut stats = Vec::with_capacity(bucket_count as usize);
|
||||
for i in 0..bucket_count {
|
||||
let bucket_start_ts = start_ts + i * bucket_seconds;
|
||||
let bucket_start = Local
|
||||
.timestamp_opt(bucket_start_ts, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Local::now);
|
||||
|
||||
let date = bucket_start.format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||
|
||||
if let Some(mut stat) = map.remove(&i) {
|
||||
stat.date = date;
|
||||
stats.push(stat);
|
||||
} else {
|
||||
stats.push(DailyStats {
|
||||
date,
|
||||
request_count: 0,
|
||||
total_cost: "0.000000".to_string(),
|
||||
total_tokens: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_creation_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// 获取 Provider 统计
|
||||
@@ -617,7 +602,7 @@ impl Database {
|
||||
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
||||
FROM proxy_request_logs
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND date(created_at, 'unixepoch') = date('now')",
|
||||
AND date(datetime(created_at, 'unixepoch', 'localtime')) = date('now', 'localtime')",
|
||||
params![provider_id, app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -629,7 +614,7 @@ impl Database {
|
||||
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
||||
FROM proxy_request_logs
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND strftime('%Y-%m', created_at, 'unixepoch') = strftime('%Y-%m', 'now')",
|
||||
AND strftime('%Y-%m', datetime(created_at, 'unixepoch', 'localtime')) = strftime('%Y-%m', 'now', 'localtime')",
|
||||
params![provider_id, app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -813,89 +798,46 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
/// 标准化模型名称:去除供应商前缀并将点号替换为短横线
|
||||
/// 例如:anthropic/claude-haiku-4.5 → claude-haiku-4-5
|
||||
fn normalize_model_id(model_id: &str) -> String {
|
||||
// 1. 去除供应商前缀(如 anthropic/、openai/)
|
||||
let stripped = if let Some(pos) = model_id.find('/') {
|
||||
&model_id[pos + 1..]
|
||||
} else {
|
||||
model_id
|
||||
};
|
||||
// 2. 将点号替换为短横线(如 claude-haiku-4.5 → claude-haiku-4-5)
|
||||
stripped.replace('.', "-")
|
||||
}
|
||||
|
||||
pub(crate) fn find_model_pricing_row(
|
||||
conn: &Connection,
|
||||
model_id: &str,
|
||||
) -> Result<Option<(String, String, String, String)>, AppError> {
|
||||
// 0. 标准化模型名称(去除前缀 + 点号转短横线)
|
||||
// 例如:anthropic/claude-haiku-4.5 → claude-haiku-4-5
|
||||
let normalized = normalize_model_id(model_id);
|
||||
// 清洗模型名称:去前缀(/)、去后缀(:)、@ 替换为 -
|
||||
// 例如 moonshotai/gpt-5.2-codex@low:v2 → gpt-5.2-codex-low
|
||||
let cleaned = model_id
|
||||
.rsplit_once('/')
|
||||
.map_or(model_id, |(_, r)| r)
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or(model_id)
|
||||
.trim()
|
||||
.replace('@', "-");
|
||||
|
||||
// 1. 精确匹配(先尝试原始名称,再尝试标准化后的名称)
|
||||
for id in [model_id, normalized.as_str()] {
|
||||
let exact = conn
|
||||
.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing
|
||||
WHERE model_id = ?1",
|
||||
[id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?;
|
||||
// 精确匹配清洗后的名称
|
||||
let exact = conn
|
||||
.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing
|
||||
WHERE model_id = ?1",
|
||||
[&cleaned],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?;
|
||||
|
||||
if exact.is_some() {
|
||||
if id != model_id {
|
||||
log::info!("模型 {model_id} 标准化后精确匹配到: {id}");
|
||||
}
|
||||
return Ok(exact);
|
||||
}
|
||||
if exact.is_none() {
|
||||
log::warn!("模型 {model_id}(清洗后: {cleaned})未找到定价信息,成本将记录为 0");
|
||||
}
|
||||
|
||||
// 2. 逐步删除后缀匹配(claude-haiku-4-5-20250929 → claude-haiku-4-5 → claude-haiku-4 → claude-haiku)
|
||||
// 使用标准化后的名称进行后缀匹配
|
||||
let mut current = normalized;
|
||||
while let Some(pos) = current.rfind('-') {
|
||||
current = current[..pos].to_string();
|
||||
|
||||
let result = conn
|
||||
.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing
|
||||
WHERE model_id = ?1",
|
||||
[¤t],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?;
|
||||
|
||||
if result.is_some() {
|
||||
log::info!("模型 {model_id} 通过删除后缀匹配到: {current}");
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!("模型 {model_id} 未找到定价信息,成本将记录为 0");
|
||||
Ok(None)
|
||||
Ok(exact)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -975,54 +917,46 @@ mod tests {
|
||||
let db = Database::memory()?;
|
||||
let conn = lock_conn!(db.conn);
|
||||
|
||||
// 测试精确匹配
|
||||
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5")?;
|
||||
assert!(result.is_some(), "应该能精确匹配 claude-sonnet-4-5");
|
||||
// 准备额外定价数据,覆盖前缀/后缀清洗场景
|
||||
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 (?, ?, ?, ?, ?, ?)",
|
||||
params![
|
||||
"claude-haiku-4.5",
|
||||
"Claude Haiku 4.5",
|
||||
"1.0",
|
||||
"2.0",
|
||||
"0.0",
|
||||
"0.0"
|
||||
],
|
||||
)?;
|
||||
|
||||
// 测试带供应商前缀的模型名称(anthropic/claude-haiku-4.5 → claude-haiku-4-5)
|
||||
let result = find_model_pricing_row(&conn, "anthropic/claude-haiku-4.5")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"应该能匹配带前缀的模型 anthropic/claude-haiku-4.5"
|
||||
);
|
||||
|
||||
// 测试带供应商前缀 + 点号的模型名称
|
||||
let result = find_model_pricing_row(&conn, "anthropic/claude-sonnet-4.5")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"应该能匹配带前缀的模型 anthropic/claude-sonnet-4.5"
|
||||
);
|
||||
|
||||
// 测试逐步删除后缀匹配 - 日期后缀
|
||||
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20241022")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"应该能通过删除后缀匹配 claude-sonnet-4-5-20241022"
|
||||
);
|
||||
|
||||
// 测试逐步删除后缀匹配 - 多个后缀
|
||||
let result = find_model_pricing_row(&conn, "claude-haiku-4-5-20240229-preview")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"应该能通过删除后缀匹配 claude-haiku-4-5-20240229-preview"
|
||||
);
|
||||
|
||||
// 测试 GPT 模型
|
||||
let result = find_model_pricing_row(&conn, "gpt-5-2024-11-20")?;
|
||||
assert!(result.is_some(), "应该能通过删除后缀匹配 gpt-5-2024-11-20");
|
||||
|
||||
// 测试 Gemini 模型
|
||||
let result = find_model_pricing_row(&conn, "gemini-2.5-flash-exp")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"应该能通过删除后缀匹配 gemini-2.5-flash-exp"
|
||||
);
|
||||
|
||||
// 测试 claude-sonnet-4-5 命名格式
|
||||
// 测试精确匹配(seed_model_pricing 已预置 claude-sonnet-4-5-20250929)
|
||||
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20250929")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"应该能通过删除后缀匹配 claude-sonnet-4-5-20250929"
|
||||
"应该能精确匹配 claude-sonnet-4-5-20250929"
|
||||
);
|
||||
|
||||
// 清洗:去除前缀和冒号后缀
|
||||
let result = find_model_pricing_row(&conn, "anthropic/claude-haiku-4.5")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"带前缀的模型 anthropic/claude-haiku-4.5 应能匹配到 claude-haiku-4.5"
|
||||
);
|
||||
let result = find_model_pricing_row(&conn, "moonshotai/kimi-k2-0905:exa")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905"
|
||||
);
|
||||
|
||||
// 清洗:@ 替换为 -(seed_model_pricing 已预置 gpt-5.2-codex-low)
|
||||
let result = find_model_pricing_row(&conn, "gpt-5.2-codex@low")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"带 @ 分隔符的模型 gpt-5.2-codex@low 应能匹配到 gpt-5.2-codex-low"
|
||||
);
|
||||
|
||||
// 测试不存在的模型
|
||||
|
||||
+47
-10
@@ -47,6 +47,8 @@ pub struct AppSettings {
|
||||
pub codex_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gemini_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode_config_dir: Option<String>,
|
||||
|
||||
// ===== 当前供应商 ID(设备级)=====
|
||||
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
@@ -58,6 +60,9 @@ pub struct AppSettings {
|
||||
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_gemini: Option<String>,
|
||||
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_opencode: Option<String>,
|
||||
}
|
||||
|
||||
fn default_show_in_tray() -> bool {
|
||||
@@ -84,20 +89,19 @@ impl Default for AppSettings {
|
||||
claude_config_dir: None,
|
||||
codex_config_dir: None,
|
||||
gemini_config_dir: None,
|
||||
opencode_config_dir: None,
|
||||
current_provider_claude: None,
|
||||
current_provider_codex: None,
|
||||
current_provider_gemini: None,
|
||||
current_provider_opencode: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
fn settings_path() -> PathBuf {
|
||||
fn settings_path() -> Option<PathBuf> {
|
||||
// settings.json 保留用于旧版本迁移和无数据库场景
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".cc-switch")
|
||||
.join("settings.json")
|
||||
dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json"))
|
||||
}
|
||||
|
||||
fn normalize_paths(&mut self) {
|
||||
@@ -122,6 +126,13 @@ impl AppSettings {
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.opencode_config_dir = self
|
||||
.opencode_config_dir
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.language = self
|
||||
.language
|
||||
.as_ref()
|
||||
@@ -131,7 +142,9 @@ impl AppSettings {
|
||||
}
|
||||
|
||||
fn load_from_file() -> Self {
|
||||
let path = Self::settings_path();
|
||||
let Some(path) = Self::settings_path() else {
|
||||
return Self::default();
|
||||
};
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
match serde_json::from_str::<AppSettings>(&content) {
|
||||
Ok(mut settings) => {
|
||||
@@ -156,7 +169,9 @@ impl AppSettings {
|
||||
fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
|
||||
let mut normalized = settings.clone();
|
||||
normalized.normalize_paths();
|
||||
let path = AppSettings::settings_path();
|
||||
let Some(path) = AppSettings::settings_path() else {
|
||||
return Err(AppError::Config("无法获取用户主目录".to_string()));
|
||||
};
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
@@ -193,14 +208,23 @@ fn resolve_override_path(raw: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
pub fn get_settings() -> AppSettings {
|
||||
settings_store().read().expect("读取设置锁失败").clone()
|
||||
settings_store()
|
||||
.read()
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
||||
new_settings.normalize_paths();
|
||||
save_settings_file(&new_settings)?;
|
||||
|
||||
let mut guard = settings_store().write().expect("写入设置锁失败");
|
||||
let mut guard = settings_store().write().unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
});
|
||||
*guard = new_settings;
|
||||
Ok(())
|
||||
}
|
||||
@@ -209,7 +233,10 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
||||
/// 用于导入配置等场景,确保内存缓存与文件同步
|
||||
pub fn reload_settings() -> Result<(), AppError> {
|
||||
let fresh_settings = AppSettings::load_from_file();
|
||||
let mut guard = settings_store().write().expect("写入设置锁失败");
|
||||
let mut guard = settings_store().write().unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
});
|
||||
*guard = fresh_settings;
|
||||
Ok(())
|
||||
}
|
||||
@@ -238,6 +265,14 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
|
||||
.map(|p| resolve_override_path(p))
|
||||
}
|
||||
|
||||
pub fn get_opencode_override_dir() -> Option<PathBuf> {
|
||||
let settings = settings_store().read().ok()?;
|
||||
settings
|
||||
.opencode_config_dir
|
||||
.as_ref()
|
||||
.map(|p| resolve_override_path(p))
|
||||
}
|
||||
|
||||
// ===== 当前供应商管理函数 =====
|
||||
|
||||
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
|
||||
@@ -250,6 +285,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
|
||||
AppType::Claude => settings.current_provider_claude.clone(),
|
||||
AppType::Codex => settings.current_provider_codex.clone(),
|
||||
AppType::Gemini => settings.current_provider_gemini.clone(),
|
||||
AppType::OpenCode => settings.current_provider_opencode.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +300,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
|
||||
AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
|
||||
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
|
||||
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
|
||||
AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()),
|
||||
}
|
||||
|
||||
update_settings(settings)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user