mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b5b3efad4 | |||
| 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 | |||
| f363bb1dd0 | |||
| a2becf0917 | |||
| debe4232bc | |||
| 49a2e52b20 | |||
| ed9d9b5436 | |||
| 390839a8d5 | |||
| 3dcbe313be |
@@ -56,7 +56,8 @@ jobs:
|
|||||||
libssl-dev \
|
libssl-dev \
|
||||||
rpm \
|
rpm \
|
||||||
flatpak \
|
flatpak \
|
||||||
flatpak-builder
|
flatpak-builder \
|
||||||
|
elfutils
|
||||||
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
|
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
|
||||||
sudo apt-get install -y --no-install-recommends \
|
sudo apt-get install -y --no-install-recommends \
|
||||||
libgtk-3-dev \
|
libgtk-3-dev \
|
||||||
@@ -156,7 +157,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Tauri App (Linux)
|
- name: Build Tauri App (Linux)
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
run: pnpm tauri build -- --bundles appimage,deb,rpm
|
run: pnpm tauri build --bundles appimage,deb,rpm
|
||||||
|
|
||||||
- name: Prepare macOS Assets
|
- name: Prepare macOS Assets
|
||||||
if: runner.os == 'macOS'
|
if: runner.os == 'macOS'
|
||||||
@@ -283,19 +284,6 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "No .rpm found (optional)"
|
echo "No .rpm found (optional)"
|
||||||
fi
|
fi
|
||||||
# 额外上传 .flatpak(跨发行版;不参与 Updater)
|
|
||||||
if [ -n "$DEB" ]; then
|
|
||||||
echo "Building Flatpak bundle from .deb..."
|
|
||||||
cp "$DEB" flatpak/cc-switch.deb
|
|
||||||
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
|
||||||
flatpak install -y --user flathub org.gnome.Platform//46 org.gnome.Sdk//46
|
|
||||||
flatpak-builder --force-clean --user --disable-cache --repo flatpak-repo flatpak-build flatpak/com.ccswitch.desktop.yml
|
|
||||||
NEW_FLATPAK="CC-Switch-${VERSION}-Linux.flatpak"
|
|
||||||
flatpak build-bundle --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo flatpak-repo "release-assets/$NEW_FLATPAK" com.ccswitch.desktop
|
|
||||||
echo "Flatpak bundle created: $NEW_FLATPAK"
|
|
||||||
else
|
|
||||||
echo "Skip Flatpak build: no .deb found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: List prepared assets
|
- name: List prepared assets
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -324,7 +312,7 @@ jobs:
|
|||||||
|
|
||||||
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`(Homebrew)
|
- **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`(绿色版)
|
- **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)或 `CC-Switch-${{ github.ref_name }}-Linux.rpm`(Fedora/RHEL/openSUSE)或 `CC-Switch-${{ github.ref_name }}-Linux.flatpak`(Flatpak)
|
- **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"`
|
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
||||||
|
|||||||
+115
-39
@@ -5,6 +5,91 @@ All notable changes to CC Switch will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [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
|
## [3.9.0-3] - 2025-12-29
|
||||||
|
|
||||||
### Beta Release
|
### Beta Release
|
||||||
@@ -63,6 +148,34 @@ Third beta release with important bug fixes for Windows compatibility, UI improv
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
## [3.9.0-1] - 2025-12-18
|
||||||
|
|
||||||
### Beta Release
|
### Beta Release
|
||||||
@@ -555,8 +668,8 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
|
|||||||
|
|
||||||
### ⚠ Breaking Changes
|
### ⚠ Breaking Changes
|
||||||
|
|
||||||
- Tauri 命令仅接受参数 `app`(取值:`claude`/`codex`);移除对 `app_type`/`appType` 的兼容。
|
- Tauri commands only accept the `app` parameter (`claude`/`codex`); removed `app_type`/`appType` compatibility.
|
||||||
- 前端类型命名统一为 `AppId`(移除 `AppType` 导出),变量命名统一为 `appId`。
|
- Frontend types are standardized to `AppId` (removed `AppType` export); variable naming is standardized to `appId`.
|
||||||
|
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
|
||||||
@@ -799,40 +912,3 @@ For users upgrading from v2.x (Electron version):
|
|||||||
- Basic provider management
|
- Basic provider management
|
||||||
- Claude Code integration
|
- Claude Code integration
|
||||||
- Configuration file handling
|
- 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
|
# 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://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](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>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</table>
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
@@ -47,7 +52,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Current Version: v3.8.3 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
|
### Current Version: v3.9.1 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
|
||||||
|
|
||||||
**v3.8.0 Major Update (2025-11-28)**
|
**v3.8.0 Major Update (2025-11-28)**
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
# 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://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](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>
|
<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>
|
</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>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</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)**
|
**v3.8.0 メジャーアップデート (2025-11-28)**
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
# 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://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](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>
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
<td width="180"><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>
|
||||||
|
|
||||||
<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 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用全球大模型。
|
<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>
|
为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>
|
||||||
|
|
||||||
|
<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>
|
</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)**
|
**v3.8.0 重大更新(2025-11-28)**
|
||||||
|
|
||||||
|
|||||||
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,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` |
|
||||||
@@ -22,6 +22,59 @@ finish-args:
|
|||||||
- --filesystem=home
|
- --filesystem=home
|
||||||
|
|
||||||
modules:
|
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
|
- name: cc-switch
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
sources:
|
sources:
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.9.0-3",
|
"version": "3.9.1",
|
||||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -87,4 +87,4 @@
|
|||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
-1
@@ -701,7 +701,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.9.0-3"
|
version = "3.9.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.9.0-3"
|
version = "3.9.1"
|
||||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||||
authors = ["Jason Young"]
|
authors = ["Jason Young"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -37,7 +37,7 @@ tauri-plugin-deep-link = "2"
|
|||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
toml_edit = "0.22"
|
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"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
@@ -77,7 +77,8 @@ objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
|||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
opt-level = "s"
|
opt-level = "s"
|
||||||
panic = "abort"
|
# 使用 unwind 以便 panic hook 能捕获 backtrace(abort 会直接终止无法捕获)
|
||||||
|
panic = "unwind"
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -65,6 +65,30 @@ fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
|
|||||||
// 非 Windows 平台不做任何处理
|
// 非 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct McpStatus {
|
pub struct McpStatus {
|
||||||
@@ -371,6 +395,11 @@ pub fn set_mcp_servers_map(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
|
// 构建 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();
|
let mut out: Map<String, Value> = Map::new();
|
||||||
for (id, spec) in servers.iter() {
|
for (id, spec) in servers.iter() {
|
||||||
let mut obj = if let Some(map) = spec.as_object() {
|
let mut obj = if let Some(map) = spec.as_object() {
|
||||||
@@ -397,8 +426,10 @@ pub fn set_mcp_servers_map(
|
|||||||
obj.remove("homepage");
|
obj.remove("homepage");
|
||||||
obj.remove("docs");
|
obj.remove("docs");
|
||||||
|
|
||||||
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
|
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式(WSL 路径除外)
|
||||||
wrap_command_for_windows(&mut obj);
|
if !is_wsl_target {
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
}
|
||||||
|
|
||||||
out.insert(id.clone(), Value::Object(obj));
|
out.insert(id.clone(), Value::Object(obj));
|
||||||
}
|
}
|
||||||
@@ -545,4 +576,68 @@ mod tests {
|
|||||||
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
|
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::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// 获取用户主目录,带回退和日志
|
||||||
|
fn get_home_dir() -> PathBuf {
|
||||||
|
dirs::home_dir().unwrap_or_else(|| {
|
||||||
|
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||||
|
PathBuf::from(".")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取 Codex 配置目录路径
|
/// 获取 Codex 配置目录路径
|
||||||
pub fn get_codex_config_dir() -> PathBuf {
|
pub fn get_codex_config_dir() -> PathBuf {
|
||||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||||||
return custom;
|
return custom;
|
||||||
}
|
}
|
||||||
|
|
||||||
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
|
get_home_dir().join(".codex")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 Codex auth.json 路径
|
/// 获取 Codex auth.json 路径
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
+426
-12
@@ -1,7 +1,14 @@
|
|||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
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::AppHandle;
|
||||||
|
use tauri::State;
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
@@ -85,15 +92,14 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
|||||||
let tools = vec!["claude", "codex", "gemini"];
|
let tools = vec!["claude", "codex", "gemini"];
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
|
|
||||||
// 用于获取远程版本的 client
|
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||||
let client = reqwest::Client::builder()
|
let client = crate::proxy::http_client::get();
|
||||||
.user_agent("cc-switch/1.0")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
for tool in tools {
|
for tool in tools {
|
||||||
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
// 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);
|
let direct_result = try_get_version(tool);
|
||||||
|
|
||||||
@@ -142,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 {
|
fn extract_version(raw: &str) -> String {
|
||||||
// 匹配 semver 格式: x.y.z 或 x.y.z-xxx
|
VERSION_RE
|
||||||
let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").unwrap();
|
.find(raw)
|
||||||
re.find(raw)
|
|
||||||
.map(|m| m.as_str().to_string())
|
.map(|m| m.as_str().to_string())
|
||||||
.unwrap_or_else(|| raw.to_string())
|
.unwrap_or_else(|| raw.to_string())
|
||||||
}
|
}
|
||||||
@@ -178,7 +187,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
if out.status.success() {
|
if out.status.success() {
|
||||||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||||
if raw.is_empty() {
|
if raw.is_empty() {
|
||||||
(None, Some("未安装或无法执行".to_string()))
|
(None, Some("not installed or not executable".to_string()))
|
||||||
} else {
|
} else {
|
||||||
(Some(extract_version(raw)), None)
|
(Some(extract_version(raw)), None)
|
||||||
}
|
}
|
||||||
@@ -187,7 +196,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
Some(if err.is_empty() {
|
Some(if err.is_empty() {
|
||||||
"未安装或无法执行".to_string()
|
"not installed or not executable".to_string()
|
||||||
} else {
|
} else {
|
||||||
err
|
err
|
||||||
}),
|
}),
|
||||||
@@ -198,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
|
/// 扫描常见路径查找 CLI
|
||||||
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -293,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 deeplink;
|
||||||
mod env;
|
mod env;
|
||||||
mod failover;
|
mod failover;
|
||||||
|
mod global_proxy;
|
||||||
mod import_export;
|
mod import_export;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod misc;
|
mod misc;
|
||||||
@@ -20,6 +21,7 @@ pub use config::*;
|
|||||||
pub use deeplink::*;
|
pub use deeplink::*;
|
||||||
pub use env::*;
|
pub use env::*;
|
||||||
pub use failover::*;
|
pub use failover::*;
|
||||||
|
pub use global_proxy::*;
|
||||||
pub use import_export::*;
|
pub use import_export::*;
|
||||||
pub use mcp::*;
|
pub use mcp::*;
|
||||||
pub use misc::*;
|
pub use misc::*;
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ pub async fn testUsageScript(
|
|||||||
#[allow(non_snake_case)] baseUrl: Option<String>,
|
#[allow(non_snake_case)] baseUrl: Option<String>,
|
||||||
#[allow(non_snake_case)] accessToken: Option<String>,
|
#[allow(non_snake_case)] accessToken: Option<String>,
|
||||||
#[allow(non_snake_case)] userId: Option<String>,
|
#[allow(non_snake_case)] userId: Option<String>,
|
||||||
|
#[allow(non_snake_case)] templateType: Option<String>,
|
||||||
) -> Result<crate::provider::UsageResult, String> {
|
) -> Result<crate::provider::UsageResult, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
ProviderService::test_usage_script(
|
ProviderService::test_usage_script(
|
||||||
@@ -145,6 +146,7 @@ pub async fn testUsageScript(
|
|||||||
baseUrl.as_deref(),
|
baseUrl.as_deref(),
|
||||||
accessToken.as_deref(),
|
accessToken.as_deref(),
|
||||||
userId.as_deref(),
|
userId.as_deref(),
|
||||||
|
templateType.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
|
|||||||
@@ -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> {
|
pub async fn get_auto_launch_status() -> Result<bool, String> {
|
||||||
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
+10
-6
@@ -5,22 +5,26 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
/// 获取用户主目录,带回退和日志
|
||||||
|
fn get_home_dir() -> PathBuf {
|
||||||
|
dirs::home_dir().unwrap_or_else(|| {
|
||||||
|
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||||
|
PathBuf::from(".")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取 Claude Code 配置目录路径
|
/// 获取 Claude Code 配置目录路径
|
||||||
pub fn get_claude_config_dir() -> PathBuf {
|
pub fn get_claude_config_dir() -> PathBuf {
|
||||||
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
||||||
return custom;
|
return custom;
|
||||||
}
|
}
|
||||||
|
|
||||||
dirs::home_dir()
|
get_home_dir().join(".claude")
|
||||||
.expect("无法获取用户主目录")
|
|
||||||
.join(".claude")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
|
/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
|
||||||
pub fn get_default_claude_mcp_path() -> PathBuf {
|
pub fn get_default_claude_mcp_path() -> PathBuf {
|
||||||
dirs::home_dir()
|
get_home_dir().join(".claude.json")
|
||||||
.expect("无法获取用户主目录")
|
|
||||||
.join(".claude.json")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
|
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
|
||||||
|
|||||||
@@ -73,11 +73,14 @@ impl Database {
|
|||||||
params![
|
params![
|
||||||
server.id,
|
server.id,
|
||||||
server.name,
|
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.description,
|
||||||
server.homepage,
|
server.homepage,
|
||||||
server.docs,
|
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.claude,
|
||||||
server.apps.codex,
|
server.apps.codex,
|
||||||
server.apps.gemini,
|
server.apps.gemini,
|
||||||
|
|||||||
@@ -220,7 +220,9 @@ impl Database {
|
|||||||
WHERE id = ?13 AND app_type = ?14",
|
WHERE id = ?13 AND app_type = ?14",
|
||||||
params![
|
params![
|
||||||
provider.name,
|
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.website_url,
|
||||||
provider.category,
|
provider.category,
|
||||||
provider.created_at,
|
provider.created_at,
|
||||||
@@ -228,7 +230,9 @@ impl Database {
|
|||||||
provider.notes,
|
provider.notes,
|
||||||
provider.icon,
|
provider.icon,
|
||||||
provider.icon_color,
|
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,
|
is_current,
|
||||||
in_failover_queue,
|
in_failover_queue,
|
||||||
provider.id,
|
provider.id,
|
||||||
@@ -247,7 +251,8 @@ impl Database {
|
|||||||
provider.id,
|
provider.id,
|
||||||
app_type,
|
app_type,
|
||||||
provider.name,
|
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.website_url,
|
||||||
provider.category,
|
provider.category,
|
||||||
provider.created_at,
|
provider.created_at,
|
||||||
@@ -255,7 +260,8 @@ impl Database {
|
|||||||
provider.notes,
|
provider.notes,
|
||||||
provider.icon,
|
provider.icon,
|
||||||
provider.icon_color,
|
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,
|
is_current,
|
||||||
in_failover_queue,
|
in_failover_queue,
|
||||||
],
|
],
|
||||||
@@ -324,7 +330,9 @@ impl Database {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
|
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||||
params![
|
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,
|
provider_id,
|
||||||
app_type
|
app_type
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ impl Database {
|
|||||||
Ok(GlobalProxyConfig {
|
Ok(GlobalProxyConfig {
|
||||||
proxy_enabled: false,
|
proxy_enabled: false,
|
||||||
listen_address: "127.0.0.1".to_string(),
|
listen_address: "127.0.0.1".to_string(),
|
||||||
listen_port: 5000,
|
listen_port: 15721,
|
||||||
enable_logging: true,
|
enable_logging: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 替代)---
|
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
|
||||||
|
|
||||||
/// 获取指定应用的代理接管状态
|
/// 获取指定应用的代理接管状态
|
||||||
@@ -128,4 +163,27 @@ impl Database {
|
|||||||
log::info!("已清除所有代理接管状态");
|
log::info!("已清除所有代理接管状态");
|
||||||
Ok(())
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ impl Database {
|
|||||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
|
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
|
||||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
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',
|
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,
|
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 60,
|
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,
|
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
|
||||||
@@ -253,7 +253,7 @@ impl Database {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
let _ = conn.execute(
|
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(
|
let _ = conn.execute(
|
||||||
@@ -469,7 +469,7 @@ impl Database {
|
|||||||
conn,
|
conn,
|
||||||
"proxy_config",
|
"proxy_config",
|
||||||
"listen_port",
|
"listen_port",
|
||||||
"INTEGER NOT NULL DEFAULT 5000",
|
"INTEGER NOT NULL DEFAULT 15721",
|
||||||
)?;
|
)?;
|
||||||
Self::add_column_if_missing(
|
Self::add_column_if_missing(
|
||||||
conn,
|
conn,
|
||||||
@@ -664,7 +664,7 @@ impl Database {
|
|||||||
conn.execute("CREATE TABLE proxy_config_new (
|
conn.execute("CREATE TABLE proxy_config_new (
|
||||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
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',
|
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,
|
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 60,
|
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,
|
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ pub struct DeepLinkImportRequest {
|
|||||||
/// Provider homepage URL
|
/// Provider homepage URL
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub homepage: Option<String>,
|
pub homepage: Option<String>,
|
||||||
/// API endpoint/base URL
|
/// API endpoint/base URL (supports comma-separated multiple URLs)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub endpoint: Option<String>,
|
pub endpoint: Option<String>,
|
||||||
/// API key
|
/// API key
|
||||||
|
|||||||
@@ -101,9 +101,13 @@ fn parse_provider_deeplink(
|
|||||||
validate_url(hp, "homepage")?;
|
validate_url(hp, "homepage")?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Validate each endpoint (supports comma-separated multiple URLs)
|
||||||
if let Some(ref ep) = endpoint {
|
if let Some(ref ep) = endpoint {
|
||||||
if !ep.is_empty() {
|
for (i, url) in ep.split(',').enumerate() {
|
||||||
validate_url(ep, "endpoint")?;
|
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+)
|
// 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)
|
// Extract required fields (now as Option)
|
||||||
let app_str = merged_request
|
let app_str = merged_request
|
||||||
.app
|
.app
|
||||||
.as_ref()
|
.clone()
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
|
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
|
||||||
|
|
||||||
let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
|
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())
|
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if endpoint.is_empty() {
|
// Parse endpoints: split by comma, first is primary
|
||||||
return Err(AppError::InvalidInput(
|
let all_endpoints: Vec<String> = endpoint_str
|
||||||
"Endpoint cannot be empty".to_string(),
|
.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(|| {
|
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
|
||||||
@@ -73,11 +88,11 @@ pub fn import_provider_from_deeplink(
|
|||||||
|
|
||||||
let name = merged_request
|
let name = merged_request
|
||||||
.name
|
.name
|
||||||
.as_ref()
|
.clone()
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
|
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
|
||||||
|
|
||||||
// Parse app type
|
// 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}")))?;
|
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
|
||||||
|
|
||||||
// Build provider configuration based on app type
|
// Build provider configuration based on app type
|
||||||
@@ -97,6 +112,21 @@ pub fn import_provider_from_deeplink(
|
|||||||
// Use ProviderService to add the provider
|
// Use ProviderService to add the provider
|
||||||
ProviderService::add(state, app_type.clone(), 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 enabled=true, set as current provider
|
||||||
if merged_request.enabled.unwrap_or(false) {
|
if merged_request.enabled.unwrap_or(false) {
|
||||||
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
||||||
@@ -138,6 +168,16 @@ pub(crate) fn build_provider_from_request(
|
|||||||
Ok(provider)
|
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
|
/// Build provider meta with usage script configuration
|
||||||
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
||||||
// Check if any usage script fields are provided
|
// Check if any usage script fields are provided
|
||||||
@@ -165,6 +205,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
|||||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||||||
|
|
||||||
// Build UsageScript - use provider's API key and endpoint as defaults
|
// 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 {
|
let usage_script = UsageScript {
|
||||||
enabled,
|
enabled,
|
||||||
language: "javascript".to_string(),
|
language: "javascript".to_string(),
|
||||||
@@ -174,12 +215,17 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
|||||||
.usage_api_key
|
.usage_api_key
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| request.api_key.clone()),
|
.or_else(|| request.api_key.clone()),
|
||||||
base_url: request
|
base_url: request.usage_base_url.clone().or_else(|| {
|
||||||
.usage_base_url
|
let primary = get_primary_endpoint(request);
|
||||||
.clone()
|
if primary.is_empty() {
|
||||||
.or_else(|| request.endpoint.clone()),
|
None
|
||||||
|
} else {
|
||||||
|
Some(primary)
|
||||||
|
}
|
||||||
|
}),
|
||||||
access_token: request.usage_access_token.clone(),
|
access_token: request.usage_access_token.clone(),
|
||||||
user_id: request.usage_user_id.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,
|
auto_query_interval: request.usage_auto_interval,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -198,7 +244,7 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|||||||
);
|
);
|
||||||
env.insert(
|
env.insert(
|
||||||
"ANTHROPIC_BASE_URL".to_string(),
|
"ANTHROPIC_BASE_URL".to_string(),
|
||||||
json!(request.endpoint.clone().unwrap_or_default()),
|
json!(get_primary_endpoint(request)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add default model if provided
|
// Add default model if provided
|
||||||
@@ -271,11 +317,8 @@ fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|||||||
.unwrap_or("gpt-5-codex")
|
.unwrap_or("gpt-5-codex")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Endpoint: normalize trailing slashes
|
// Endpoint: normalize trailing slashes (use primary endpoint only)
|
||||||
let endpoint = request
|
let endpoint = get_primary_endpoint(request)
|
||||||
.endpoint
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim()
|
.trim()
|
||||||
.trim_end_matches('/')
|
.trim_end_matches('/')
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -309,7 +352,7 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|||||||
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
|
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
|
||||||
env.insert(
|
env.insert(
|
||||||
"GOOGLE_GEMINI_BASE_URL".to_string(),
|
"GOOGLE_GEMINI_BASE_URL".to_string(),
|
||||||
json!(request.endpoint),
|
json!(get_primary_endpoint(request)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add model if provided
|
// Add model if provided
|
||||||
@@ -409,27 +452,26 @@ fn merge_claude_config(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Auto-fill API key if not provided in URL
|
// 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()) {
|
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
|
||||||
request.api_key = Some(token.to_string());
|
request.api_key = Some(token.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-fill endpoint if not provided in URL
|
// 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()) {
|
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
||||||
request.endpoint = Some(base_url.to_string());
|
request.endpoint = Some(base_url.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-fill homepage from endpoint if not provided
|
// Auto-fill homepage from endpoint if not provided
|
||||||
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
&& request.endpoint.is_some()
|
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||||||
&& !request.endpoint.as_ref().unwrap().is_empty()
|
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||||||
{
|
if request.homepage.is_none() {
|
||||||
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
request.homepage = Some("https://anthropic.com".to_string());
|
||||||
if request.homepage.is_none() {
|
}
|
||||||
request.homepage = Some("https://anthropic.com".to_string());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +510,7 @@ fn merge_codex_config(
|
|||||||
config: &serde_json::Value,
|
config: &serde_json::Value,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
// Auto-fill API key from auth.OPENAI_API_KEY
|
// 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
|
if let Some(api_key) = config
|
||||||
.get("auth")
|
.get("auth")
|
||||||
.and_then(|v| v.get("OPENAI_API_KEY"))
|
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||||||
@@ -483,7 +525,7 @@ fn merge_codex_config(
|
|||||||
// Parse TOML config string to extract base_url and model
|
// Parse TOML config string to extract base_url and model
|
||||||
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
|
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
|
||||||
// Extract base_url from model_providers section
|
// 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) {
|
if let Some(base_url) = extract_codex_base_url(&toml_value) {
|
||||||
request.endpoint = Some(base_url);
|
request.endpoint = Some(base_url);
|
||||||
}
|
}
|
||||||
@@ -499,13 +541,12 @@ fn merge_codex_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-fill homepage from endpoint
|
// Auto-fill homepage from endpoint
|
||||||
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
&& request.endpoint.is_some()
|
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||||||
&& !request.endpoint.as_ref().unwrap().is_empty()
|
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||||||
{
|
if request.homepage.is_none() {
|
||||||
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
request.homepage = Some("https://openai.com".to_string());
|
||||||
if request.homepage.is_none() {
|
}
|
||||||
request.homepage = Some("https://openai.com".to_string());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,14 +559,18 @@ fn merge_gemini_config(
|
|||||||
config: &serde_json::Value,
|
config: &serde_json::Value,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
// Gemini uses flat env structure
|
// 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()) {
|
if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
||||||
request.api_key = Some(api_key.to_string());
|
request.api_key = Some(api_key.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
|
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());
|
request.endpoint = Some(base_url.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -538,13 +583,12 @@ fn merge_gemini_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-fill homepage from endpoint
|
// Auto-fill homepage from endpoint
|
||||||
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
&& request.endpoint.is_some()
|
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||||||
&& !request.endpoint.as_ref().unwrap().is_empty()
|
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||||||
{
|
if request.homepage.is_none() {
|
||||||
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
request.homepage = Some("https://ai.google.dev".to_string());
|
||||||
if request.homepage.is_none() {
|
}
|
||||||
request.homepage = Some("https://ai.google.dev".to_string());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -404,3 +404,57 @@ fn test_parse_skill_deeplink() {
|
|||||||
assert_eq!(request.directory.unwrap(), "skills");
|
assert_eq!(request.directory.unwrap(), "skills");
|
||||||
assert_eq!(request.branch.unwrap(), "dev");
|
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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,15 +5,21 @@ use std::collections::HashMap;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// 获取用户主目录,带回退和日志
|
||||||
|
fn get_home_dir() -> PathBuf {
|
||||||
|
dirs::home_dir().unwrap_or_else(|| {
|
||||||
|
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||||
|
PathBuf::from(".")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取 Gemini 配置目录路径(支持设置覆盖)
|
/// 获取 Gemini 配置目录路径(支持设置覆盖)
|
||||||
pub fn get_gemini_dir() -> PathBuf {
|
pub fn get_gemini_dir() -> PathBuf {
|
||||||
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
||||||
return custom;
|
return custom;
|
||||||
}
|
}
|
||||||
|
|
||||||
dirs::home_dir()
|
get_home_dir().join(".gemini")
|
||||||
.expect("无法获取用户主目录")
|
|
||||||
.join(".gemini")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 Gemini .env 文件路径
|
/// 获取 Gemini .env 文件路径
|
||||||
|
|||||||
@@ -134,6 +134,33 @@ pub fn set_mcp_servers_map(
|
|||||||
obj.remove("homepage");
|
obj.remove("homepage");
|
||||||
obj.remove("docs");
|
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));
|
out.insert(id.clone(), Value::Object(obj));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+109
-9
@@ -13,6 +13,7 @@ mod gemini_config;
|
|||||||
mod gemini_mcp;
|
mod gemini_mcp;
|
||||||
mod init_status;
|
mod init_status;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
|
mod panic_hook;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod prompt_files;
|
mod prompt_files;
|
||||||
mod provider;
|
mod provider;
|
||||||
@@ -26,6 +27,7 @@ mod usage_script;
|
|||||||
|
|
||||||
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
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 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 commands::*;
|
||||||
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||||
pub use database::Database;
|
pub use database::Database;
|
||||||
@@ -54,6 +56,37 @@ use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
|||||||
use tauri::RunEvent;
|
use tauri::RunEvent;
|
||||||
use tauri::{Emitter, Manager};
|
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
|
/// 统一处理 ccswitch:// 深链接 URL
|
||||||
///
|
///
|
||||||
/// - 解析 URL
|
/// - 解析 URL
|
||||||
@@ -69,7 +102,9 @@ fn handle_deeplink_url(
|
|||||||
return false;
|
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) {
|
match crate::deeplink::parse_deeplink_url(url_str) {
|
||||||
Ok(request) => {
|
Ok(request) => {
|
||||||
@@ -150,15 +185,18 @@ fn macos_tray_icon() -> Option<Image<'static>> {
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
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();
|
let mut builder = tauri::Builder::default();
|
||||||
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||||
{
|
{
|
||||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
log::info!("=== Single Instance Callback Triggered ===");
|
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() {
|
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)
|
// Check for deep link URL in args (mainly for Windows/Linux command line)
|
||||||
@@ -212,6 +250,10 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.setup(|app| {
|
.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 插件(桌面端)
|
// 注册 Updater 插件(桌面端)
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
{
|
{
|
||||||
@@ -223,17 +265,34 @@ pub fn run() {
|
|||||||
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 初始化日志
|
// 初始化日志(Debug 和 Release 模式都启用 Info 级别)
|
||||||
if cfg!(debug_assertions) {
|
// 日志同时输出到控制台和文件(<app_config_dir>/logs/;若设置了覆盖则使用覆盖目录)
|
||||||
|
{
|
||||||
|
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
|
||||||
|
|
||||||
|
let log_dir = panic_hook::get_log_dir();
|
||||||
|
|
||||||
app.handle().plugin(
|
app.handle().plugin(
|
||||||
tauri_plugin_log::Builder::default()
|
tauri_plugin_log::Builder::default()
|
||||||
.level(log::LevelFilter::Info)
|
.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(),
|
.build(),
|
||||||
)?;
|
)?;
|
||||||
}
|
|
||||||
|
|
||||||
// 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径
|
// 清理旧日志文件,只保留最近 2 个
|
||||||
app_store::refresh_app_config_dir_override(app.handle());
|
panic_hook::cleanup_old_logs();
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化数据库
|
// 初始化数据库
|
||||||
let app_config_dir = crate::config::get_app_config_dir();
|
let app_config_dir = crate::config::get_app_config_dir();
|
||||||
@@ -529,7 +588,7 @@ pub fn run() {
|
|||||||
|
|
||||||
for (i, url) in urls.iter().enumerate() {
|
for (i, url) in urls.iter().enumerate() {
|
||||||
let url_str = url.as_str();
|
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") {
|
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
|
||||||
break; // Process only first ccswitch:// URL
|
break; // Process only first ccswitch:// URL
|
||||||
@@ -585,6 +644,37 @@ pub fn run() {
|
|||||||
let skill_service = SkillService::new();
|
let skill_service = SkillService::new();
|
||||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 异常退出恢复 + 代理状态自动恢复
|
// 异常退出恢复 + 代理状态自动恢复
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
@@ -644,6 +734,8 @@ pub fn run() {
|
|||||||
commands::read_live_provider_settings,
|
commands::read_live_provider_settings,
|
||||||
commands::get_settings,
|
commands::get_settings,
|
||||||
commands::save_settings,
|
commands::save_settings,
|
||||||
|
commands::get_rectifier_config,
|
||||||
|
commands::set_rectifier_config,
|
||||||
commands::restart_app,
|
commands::restart_app,
|
||||||
commands::check_for_updates,
|
commands::check_for_updates,
|
||||||
commands::is_portable_mode,
|
commands::is_portable_mode,
|
||||||
@@ -774,12 +866,20 @@ pub fn run() {
|
|||||||
commands::get_stream_check_config,
|
commands::get_stream_check_config,
|
||||||
commands::save_stream_check_config,
|
commands::save_stream_check_config,
|
||||||
commands::get_tool_versions,
|
commands::get_tool_versions,
|
||||||
|
// Provider terminal
|
||||||
|
commands::open_provider_terminal,
|
||||||
// Universal Provider management
|
// Universal Provider management
|
||||||
commands::get_universal_providers,
|
commands::get_universal_providers,
|
||||||
commands::get_universal_provider,
|
commands::get_universal_provider,
|
||||||
commands::upsert_universal_provider,
|
commands::upsert_universal_provider,
|
||||||
commands::delete_universal_provider,
|
commands::delete_universal_provider,
|
||||||
commands::sync_universal_provider,
|
commands::sync_universal_provider,
|
||||||
|
// 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
|
let app = builder
|
||||||
|
|||||||
@@ -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:"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,10 @@ pub struct UsageScript {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "userId")]
|
#[serde(rename = "userId")]
|
||||||
pub user_id: Option<String>,
|
pub user_id: Option<String>,
|
||||||
|
/// 模板类型(用于后端判断验证规则)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "templateType")]
|
||||||
|
pub template_type: Option<String>,
|
||||||
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "autoQueryInterval")]
|
#[serde(rename = "autoQueryInterval")]
|
||||||
@@ -147,6 +151,9 @@ pub struct ProviderMeta {
|
|||||||
/// 用量查询脚本配置
|
/// 用量查询脚本配置
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub usage_script: Option<UsageScript>,
|
pub usage_script: Option<UsageScript>,
|
||||||
|
/// 请求地址管理:测速后自动选择最佳端点
|
||||||
|
#[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub endpoint_auto_select: Option<bool>,
|
||||||
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
|
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
|
||||||
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
|
||||||
pub is_partner: Option<bool>,
|
pub is_partner: Option<bool>,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
|
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
|
||||||
|
|
||||||
|
use super::log_codes::cb as log_cb;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -106,7 +107,6 @@ impl CircuitBreaker {
|
|||||||
/// 更新熔断器配置(热更新,不重置状态)
|
/// 更新熔断器配置(热更新,不重置状态)
|
||||||
pub async fn update_config(&self, new_config: CircuitBreakerConfig) {
|
pub async fn update_config(&self, new_config: CircuitBreakerConfig) {
|
||||||
*self.config.write().await = new_config;
|
*self.config.write().await = new_config;
|
||||||
log::debug!("Circuit breaker config updated");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 判断当前 Provider 是否“可被纳入候选链路”
|
/// 判断当前 Provider 是否“可被纳入候选链路”
|
||||||
@@ -128,7 +128,8 @@ impl CircuitBreaker {
|
|||||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||||
drop(config); // 释放读锁再转换状态
|
drop(config); // 释放读锁再转换状态
|
||||||
log::info!(
|
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;
|
self.transition_to_half_open().await;
|
||||||
return true;
|
return true;
|
||||||
@@ -155,7 +156,8 @@ impl CircuitBreaker {
|
|||||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||||
drop(config); // 释放读锁再转换状态
|
drop(config); // 释放读锁再转换状态
|
||||||
log::info!(
|
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;
|
self.transition_to_half_open().await;
|
||||||
|
|
||||||
@@ -197,25 +199,17 @@ impl CircuitBreaker {
|
|||||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||||
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
||||||
|
|
||||||
match state {
|
if state == CircuitState::HalfOpen {
|
||||||
CircuitState::HalfOpen => {
|
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
|
|
||||||
log::debug!(
|
|
||||||
"Circuit breaker HalfOpen: {} consecutive successes (threshold: {})",
|
|
||||||
successes,
|
|
||||||
config.success_threshold
|
|
||||||
);
|
|
||||||
|
|
||||||
if successes >= config.success_threshold {
|
if successes >= config.success_threshold {
|
||||||
drop(config); // 释放读锁再转换状态
|
drop(config); // 释放读锁再转换状态
|
||||||
log::info!("Circuit breaker transitioning from HalfOpen to Closed (success threshold reached)");
|
log::info!(
|
||||||
self.transition_to_closed().await;
|
"[{}] 熔断器 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);
|
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||||
|
|
||||||
log::debug!(
|
|
||||||
"Circuit breaker {:?}: {} consecutive failures (threshold: {})",
|
|
||||||
state,
|
|
||||||
failures,
|
|
||||||
config.failure_threshold
|
|
||||||
);
|
|
||||||
|
|
||||||
// 检查是否应该打开熔断器
|
// 检查是否应该打开熔断器
|
||||||
match state {
|
match state {
|
||||||
CircuitState::HalfOpen => {
|
CircuitState::HalfOpen => {
|
||||||
// HalfOpen 状态下失败,立即转为 Open
|
// HalfOpen 状态下失败,立即转为 Open
|
||||||
log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open");
|
log::warn!(
|
||||||
|
"[{}] 熔断器 HalfOpen 探测失败 → Open",
|
||||||
|
log_cb::HALF_OPEN_PROBE_FAILED
|
||||||
|
);
|
||||||
drop(config);
|
drop(config);
|
||||||
self.transition_to_open().await;
|
self.transition_to_open().await;
|
||||||
}
|
}
|
||||||
@@ -255,9 +245,8 @@ impl CircuitBreaker {
|
|||||||
// 检查连续失败次数
|
// 检查连续失败次数
|
||||||
if failures >= config.failure_threshold {
|
if failures >= config.failure_threshold {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Circuit breaker opening due to {} consecutive failures (threshold: {})",
|
"[{}] 熔断器触发: 连续失败 {failures} 次 → Open",
|
||||||
failures,
|
log_cb::TRIGGERED_FAILURES
|
||||||
config.failure_threshold
|
|
||||||
);
|
);
|
||||||
drop(config); // 释放读锁再转换状态
|
drop(config); // 释放读锁再转换状态
|
||||||
self.transition_to_open().await;
|
self.transition_to_open().await;
|
||||||
@@ -268,18 +257,12 @@ impl CircuitBreaker {
|
|||||||
|
|
||||||
if total >= config.min_requests {
|
if total >= config.min_requests {
|
||||||
let error_rate = failed as f64 / total as f64;
|
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 {
|
if error_rate >= config.error_rate_threshold {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Circuit breaker opening due to high error rate: {:.2}% (threshold: {:.2}%)",
|
"[{}] 熔断器触发: 错误率 {:.1}% → Open",
|
||||||
error_rate * 100.0,
|
log_cb::TRIGGERED_ERROR_RATE,
|
||||||
config.error_rate_threshold * 100.0
|
error_rate * 100.0
|
||||||
);
|
);
|
||||||
drop(config); // 释放读锁再转换状态
|
drop(config); // 释放读锁再转换状态
|
||||||
self.transition_to_open().await;
|
self.transition_to_open().await;
|
||||||
@@ -312,22 +295,16 @@ impl CircuitBreaker {
|
|||||||
/// 重置熔断器(手动恢复)
|
/// 重置熔断器(手动恢复)
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn reset(&self) {
|
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;
|
self.transition_to_closed().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn allow_half_open_probe(&self) -> AllowResult {
|
fn allow_half_open_probe(&self) -> AllowResult {
|
||||||
// 半开状态限流:只允许有限请求通过进行探测
|
// 半开状态限流:只允许有限请求通过进行探测
|
||||||
// 默认最多允许 1 个请求(可在配置中扩展)
|
|
||||||
let max_half_open_requests = 1u32;
|
let max_half_open_requests = 1u32;
|
||||||
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
|
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
|
||||||
|
|
||||||
if current < max_half_open_requests {
|
if current < max_half_open_requests {
|
||||||
log::debug!(
|
|
||||||
"Circuit breaker HalfOpen: allowing probe request ({}/{})",
|
|
||||||
current + 1,
|
|
||||||
max_half_open_requests
|
|
||||||
);
|
|
||||||
AllowResult {
|
AllowResult {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
used_half_open_permit: true,
|
used_half_open_permit: true,
|
||||||
@@ -335,9 +312,6 @@ impl CircuitBreaker {
|
|||||||
} else {
|
} else {
|
||||||
// 超过限额,回退计数,拒绝请求
|
// 超过限额,回退计数,拒绝请求
|
||||||
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
|
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
|
||||||
log::debug!(
|
|
||||||
"Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
|
|
||||||
);
|
|
||||||
AllowResult {
|
AllowResult {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
used_half_open_permit: 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);
|
let mut current = self.half_open_requests.load(Ordering::SeqCst);
|
||||||
loop {
|
loop {
|
||||||
if current == 0 {
|
if current == 0 {
|
||||||
// 理论上不应该发生:说明调用方传入的 used_half_open_permit 与实际占用不一致
|
|
||||||
log::debug!("Circuit breaker HalfOpen permit already released (counter=0)");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ pub enum ProxyError {
|
|||||||
#[error("地址绑定失败: {0}")]
|
#[error("地址绑定失败: {0}")]
|
||||||
BindFailed(String),
|
BindFailed(String),
|
||||||
|
|
||||||
|
#[error("停止超时")]
|
||||||
|
StopTimeout,
|
||||||
|
|
||||||
|
#[error("停止失败: {0}")]
|
||||||
|
StopFailed(String),
|
||||||
|
|
||||||
#[error("请求转发失败: {0}")]
|
#[error("请求转发失败: {0}")]
|
||||||
ForwardFailed(String),
|
ForwardFailed(String),
|
||||||
|
|
||||||
@@ -113,6 +119,12 @@ impl IntoResponse for ProxyError {
|
|||||||
ProxyError::BindFailed(_) => {
|
ProxyError::BindFailed(_) => {
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
(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::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
|
||||||
ProxyError::NoAvailableProvider => {
|
ProxyError::NoAvailableProvider => {
|
||||||
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||||
|
|||||||
@@ -86,17 +86,17 @@ impl FailoverSwitchManager {
|
|||||||
let app_enabled = match self.db.get_proxy_config_for_app(app_type).await {
|
let app_enabled = match self.db.get_proxy_config_for_app(app_type).await {
|
||||||
Ok(config) => config.enabled,
|
Ok(config) => config.enabled,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("[Failover] 无法读取 {app_type} 配置: {e},跳过切换");
|
log::warn!("[FO-002] 无法读取 {app_type} 配置: {e},跳过切换");
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !app_enabled {
|
if !app_enabled {
|
||||||
log::info!("[Failover] {app_type} 未被代理接管(enabled=false),跳过切换");
|
log::debug!("[Failover] {app_type} 未启用代理,跳过切换");
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})");
|
log::info!("[FO-001] 切换: {app_type} → {provider_name}");
|
||||||
|
|
||||||
// 1. 更新数据库 is_current
|
// 1. 更新数据库 is_current
|
||||||
self.db.set_current_provider(app_type, provider_id)?;
|
self.db.set_current_provider(app_type, provider_id)?;
|
||||||
@@ -117,7 +117,7 @@ impl FailoverSwitchManager {
|
|||||||
.update_live_backup_from_provider(app_type, &provider)
|
.update_live_backup_from_provider(app_type, &provider)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
log::warn!("[Failover] 更新 Live 备份失败: {e}");
|
log::warn!("[FO-003] Live 备份更新失败: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,12 +138,10 @@ impl FailoverSwitchManager {
|
|||||||
"source": "failover" // 标识来源是故障转移
|
"source": "failover" // 标识来源是故障转移
|
||||||
});
|
});
|
||||||
if let Err(e) = app.emit("provider-switched", event_data) {
|
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)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+244
-224
@@ -7,15 +7,15 @@ use super::{
|
|||||||
error::*,
|
error::*,
|
||||||
failover_switch::FailoverSwitchManager,
|
failover_switch::FailoverSwitchManager,
|
||||||
provider_router::ProviderRouter,
|
provider_router::ProviderRouter,
|
||||||
providers::{get_adapter, ProviderAdapter},
|
providers::{get_adapter, ProviderAdapter, ProviderType},
|
||||||
types::ProxyStatus,
|
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
|
||||||
|
types::{ProxyStatus, RectifierConfig},
|
||||||
ProxyError,
|
ProxyError,
|
||||||
};
|
};
|
||||||
use crate::{app_config::AppType, provider::Provider};
|
use crate::{app_config::AppType, provider::Provider};
|
||||||
use reqwest::{Client, Response};
|
use reqwest::Response;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
/// Headers 黑名单 - 不透传到上游的 Headers
|
/// Headers 黑名单 - 不透传到上游的 Headers
|
||||||
@@ -81,7 +81,6 @@ pub struct ForwardError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct RequestForwarder {
|
pub struct RequestForwarder {
|
||||||
client: Client,
|
|
||||||
/// 共享的 ProviderRouter(持有熔断器状态)
|
/// 共享的 ProviderRouter(持有熔断器状态)
|
||||||
router: Arc<ProviderRouter>,
|
router: Arc<ProviderRouter>,
|
||||||
status: Arc<RwLock<ProxyStatus>>,
|
status: Arc<RwLock<ProxyStatus>>,
|
||||||
@@ -92,6 +91,10 @@ pub struct RequestForwarder {
|
|||||||
app_handle: Option<tauri::AppHandle>,
|
app_handle: Option<tauri::AppHandle>,
|
||||||
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
||||||
current_provider_id_at_start: String,
|
current_provider_id_at_start: String,
|
||||||
|
/// 整流器配置
|
||||||
|
rectifier_config: RectifierConfig,
|
||||||
|
/// 非流式请求超时(秒)
|
||||||
|
non_streaming_timeout: std::time::Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RequestForwarder {
|
impl RequestForwarder {
|
||||||
@@ -106,32 +109,17 @@ impl RequestForwarder {
|
|||||||
current_provider_id_at_start: String,
|
current_provider_id_at_start: String,
|
||||||
_streaming_first_byte_timeout: u64,
|
_streaming_first_byte_timeout: u64,
|
||||||
_streaming_idle_timeout: u64,
|
_streaming_idle_timeout: u64,
|
||||||
|
rectifier_config: RectifierConfig,
|
||||||
) -> Self {
|
) -> 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 {
|
Self {
|
||||||
client,
|
|
||||||
router,
|
router,
|
||||||
status,
|
status,
|
||||||
current_providers,
|
current_providers,
|
||||||
failover_manager,
|
failover_manager,
|
||||||
app_handle,
|
app_handle,
|
||||||
current_provider_id_at_start,
|
current_provider_id_at_start,
|
||||||
|
rectifier_config,
|
||||||
|
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +135,7 @@ impl RequestForwarder {
|
|||||||
&self,
|
&self,
|
||||||
app_type: &AppType,
|
app_type: &AppType,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
body: Value,
|
mut body: Value,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
providers: Vec<Provider>,
|
providers: Vec<Provider>,
|
||||||
) -> Result<ForwardResult, ForwardError> {
|
) -> Result<ForwardResult, ForwardError> {
|
||||||
@@ -162,16 +150,13 @@ impl RequestForwarder {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"[{}] 故障转移链: {} 个可用供应商",
|
|
||||||
app_type_str,
|
|
||||||
providers.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut last_error = None;
|
let mut last_error = None;
|
||||||
let mut last_provider = None;
|
let mut last_provider = None;
|
||||||
let mut attempted_providers = 0usize;
|
let mut attempted_providers = 0usize;
|
||||||
|
|
||||||
|
// 整流器重试标记:确保整流最多触发一次
|
||||||
|
let mut rectifier_retried = false;
|
||||||
|
|
||||||
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||||
let bypass_circuit_breaker = providers.len() == 1;
|
let bypass_circuit_breaker = providers.len() == 1;
|
||||||
|
|
||||||
@@ -190,25 +175,11 @@ impl RequestForwarder {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if !allowed {
|
if !allowed {
|
||||||
log::debug!(
|
|
||||||
"[{}] Provider {} 熔断器拒绝本次请求,跳过",
|
|
||||||
app_type_str,
|
|
||||||
provider.name
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
attempted_providers += 1;
|
attempted_providers += 1;
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"[{}] 尝试 {}/{} - 使用Provider: {} (sort_index: {})",
|
|
||||||
app_type_str,
|
|
||||||
attempted_providers,
|
|
||||||
providers.len(),
|
|
||||||
provider.name,
|
|
||||||
provider.sort_index.unwrap_or(999999)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 更新状态中的当前Provider信息
|
// 更新状态中的当前Provider信息
|
||||||
{
|
{
|
||||||
let mut status = self.status.write().await;
|
let mut status = self.status.write().await;
|
||||||
@@ -218,18 +189,14 @@ impl RequestForwarder {
|
|||||||
status.last_request_at = Some(chrono::Utc::now().to_rfc3339());
|
status.last_request_at = Some(chrono::Utc::now().to_rfc3339());
|
||||||
}
|
}
|
||||||
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
|
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
|
||||||
match self
|
match self
|
||||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let latency = start.elapsed().as_millis() as u64;
|
|
||||||
|
|
||||||
// 成功:记录成功并更新熔断器
|
// 成功:记录成功并更新熔断器
|
||||||
if let Err(e) = self
|
let _ = self
|
||||||
.router
|
.router
|
||||||
.record_result(
|
.record_result(
|
||||||
&provider.id,
|
&provider.id,
|
||||||
@@ -238,10 +205,7 @@ impl RequestForwarder {
|
|||||||
true,
|
true,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
{
|
|
||||||
log::warn!("Failed to record success: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新当前应用类型使用的 provider
|
// 更新当前应用类型使用的 provider
|
||||||
{
|
{
|
||||||
@@ -261,12 +225,6 @@ impl RequestForwarder {
|
|||||||
self.current_provider_id_at_start.as_str() != provider.id.as_str();
|
self.current_provider_id_at_start.as_str() != provider.id.as_str();
|
||||||
if should_switch {
|
if should_switch {
|
||||||
status.failover_count += 1;
|
status.failover_count += 1;
|
||||||
log::info!(
|
|
||||||
"[{}] 代理目标已切换到 Provider: {} (耗时: {}ms)",
|
|
||||||
app_type_str,
|
|
||||||
provider.name,
|
|
||||||
latency
|
|
||||||
);
|
|
||||||
|
|
||||||
// 异步触发供应商切换,更新 UI/托盘,并把“当前供应商”同步为实际使用的 provider
|
// 异步触发供应商切换,更新 UI/托盘,并把“当前供应商”同步为实际使用的 provider
|
||||||
let fm = self.failover_manager.clone();
|
let fm = self.failover_manager.clone();
|
||||||
@@ -276,10 +234,7 @@ impl RequestForwarder {
|
|||||||
let at = app_type_str.to_string();
|
let at = app_type_str.to_string();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await
|
let _ = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await;
|
||||||
{
|
|
||||||
log::error!("[Failover] 切换供应商失败: {e}");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 重新计算成功率
|
// 重新计算成功率
|
||||||
@@ -290,23 +245,213 @@ impl RequestForwarder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"[{}] 请求成功 - Provider: {} - {}ms",
|
|
||||||
app_type_str,
|
|
||||||
provider.name,
|
|
||||||
latency
|
|
||||||
);
|
|
||||||
|
|
||||||
return Ok(ForwardResult {
|
return Ok(ForwardResult {
|
||||||
response,
|
response,
|
||||||
provider: provider.clone(),
|
provider: provider.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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
|
.router
|
||||||
.record_result(
|
.record_result(
|
||||||
&provider.id,
|
&provider.id,
|
||||||
@@ -315,10 +460,7 @@ impl RequestForwarder {
|
|||||||
false,
|
false,
|
||||||
Some(e.to_string()),
|
Some(e.to_string()),
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
{
|
|
||||||
log::warn!("Failed to record failure: {record_err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分类错误
|
// 分类错误
|
||||||
let category = self.categorize_proxy_error(&e);
|
let category = self.categorize_proxy_error(&e);
|
||||||
@@ -333,11 +475,11 @@ impl RequestForwarder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"[{}] Provider {} 失败(可重试): {} - {}ms",
|
"[{}] [FWD-001] Provider {} 失败,切换下一个 ({}/{})",
|
||||||
app_type_str,
|
app_type_str,
|
||||||
provider.name,
|
provider.name,
|
||||||
e,
|
attempted_providers,
|
||||||
latency
|
providers.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
last_error = Some(e);
|
last_error = Some(e);
|
||||||
@@ -357,12 +499,6 @@ impl RequestForwarder {
|
|||||||
* 100.0;
|
* 100.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log::error!(
|
|
||||||
"[{}] Provider {} 失败(不可重试): {}",
|
|
||||||
app_type_str,
|
|
||||||
provider.name,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return Err(ForwardError {
|
return Err(ForwardError {
|
||||||
error: e,
|
error: e,
|
||||||
provider: Some(provider.clone()),
|
provider: Some(provider.clone()),
|
||||||
@@ -401,11 +537,7 @@ impl RequestForwarder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::error!(
|
log::warn!("[{app_type_str}] [FWD-002] 所有 Provider 均失败");
|
||||||
"[{}] 所有 {} 个供应商都失败了",
|
|
||||||
app_type_str,
|
|
||||||
providers.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
Err(ForwardError {
|
Err(ForwardError {
|
||||||
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
|
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
|
||||||
@@ -424,7 +556,6 @@ impl RequestForwarder {
|
|||||||
) -> Result<Response, ProxyError> {
|
) -> Result<Response, ProxyError> {
|
||||||
// 使用适配器提取 base_url
|
// 使用适配器提取 base_url
|
||||||
let base_url = adapter.extract_base_url(provider)?;
|
let base_url = adapter.extract_base_url(provider)?;
|
||||||
log::info!("[{}] base_url: {}", adapter.name(), base_url);
|
|
||||||
|
|
||||||
// 检查是否需要格式转换
|
// 检查是否需要格式转换
|
||||||
let needs_transform = adapter.needs_transform(provider);
|
let needs_transform = adapter.needs_transform(provider);
|
||||||
@@ -439,36 +570,13 @@ impl RequestForwarder {
|
|||||||
// 使用适配器构建 URL
|
// 使用适配器构建 URL
|
||||||
let url = adapter.build_url(&base_url, effective_endpoint);
|
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);
|
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||||
|
|
||||||
if let Some(ref mapped) = mapped_model {
|
|
||||||
log::info!(
|
|
||||||
"[{}] >>> 模型映射后的请求 JSON:\n{}",
|
|
||||||
adapter.name(),
|
|
||||||
serde_json::to_string_pretty(&mapped_body).unwrap_or_default()
|
|
||||||
);
|
|
||||||
log::info!("[{}] 模型已映射到: {}", adapter.name(), mapped);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换请求体(如果需要)
|
// 转换请求体(如果需要)
|
||||||
let request_body = if needs_transform {
|
let request_body = if needs_transform {
|
||||||
log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name());
|
adapter.transform_request(mapped_body, provider)?
|
||||||
let transformed = adapter.transform_request(mapped_body, provider)?;
|
|
||||||
log::info!(
|
|
||||||
"[{}] >>> 转换后的请求 JSON:\n{}",
|
|
||||||
adapter.name(),
|
|
||||||
serde_json::to_string_pretty(&transformed).unwrap_or_default()
|
|
||||||
);
|
|
||||||
transformed
|
|
||||||
} else {
|
} else {
|
||||||
mapped_body
|
mapped_body
|
||||||
};
|
};
|
||||||
@@ -477,71 +585,28 @@ impl RequestForwarder {
|
|||||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||||
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
||||||
|
|
||||||
// ========== 请求体日志(截断显示) ==========
|
// 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置)
|
||||||
let body_str = serde_json::to_string_pretty(&filtered_body)
|
let client = super::http_client::get();
|
||||||
.unwrap_or_else(|_| filtered_body.to_string());
|
let mut request = client.post(&url);
|
||||||
let body_preview = if body_str.len() > 2000 {
|
|
||||||
format!(
|
|
||||||
"{}...\n[截断,总长度: {} 字符]",
|
|
||||||
&body_str[..2000],
|
|
||||||
body_str.len()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
body_str
|
|
||||||
};
|
|
||||||
log::info!(
|
|
||||||
"[{}] ====== 最终请求体 ======\n{}",
|
|
||||||
adapter.name(),
|
|
||||||
body_preview
|
|
||||||
);
|
|
||||||
|
|
||||||
log::info!(
|
// 只有当 timeout > 0 时才设置请求超时
|
||||||
"[{}] 转发请求: {} -> {}",
|
// Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时"
|
||||||
adapter.name(),
|
// 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒)
|
||||||
provider.name,
|
if !self.non_streaming_timeout.is_zero() {
|
||||||
url
|
request = request.timeout(self.non_streaming_timeout);
|
||||||
);
|
|
||||||
|
|
||||||
// 构建请求
|
|
||||||
let mut request = self.client.post(&url);
|
|
||||||
|
|
||||||
// ========== 详细 Headers 日志 ==========
|
|
||||||
log::info!("[{}] ====== 客户端原始 Headers ======", adapter.name());
|
|
||||||
for (key, value) in headers {
|
|
||||||
log::info!(
|
|
||||||
"[{}] {}: {:?}",
|
|
||||||
adapter.name(),
|
|
||||||
key.as_str(),
|
|
||||||
value.to_str().unwrap_or("<binary>")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤黑名单 Headers,保护隐私并避免冲突
|
// 过滤黑名单 Headers,保护隐私并避免冲突
|
||||||
let mut filtered_headers: Vec<String> = Vec::new();
|
|
||||||
let mut passed_headers: Vec<(String, String)> = Vec::new();
|
|
||||||
|
|
||||||
for (key, value) in headers {
|
for (key, value) in headers {
|
||||||
let key_str = key.as_str().to_lowercase();
|
if HEADER_BLACKLIST
|
||||||
if HEADER_BLACKLIST.contains(&key_str.as_str()) {
|
.iter()
|
||||||
filtered_headers.push(key_str);
|
.any(|h| key.as_str().eq_ignore_ascii_case(h))
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let value_str = value.to_str().unwrap_or("<binary>").to_string();
|
|
||||||
passed_headers.push((key.as_str().to_string(), value_str.clone()));
|
|
||||||
request = request.header(key, value);
|
request = request.header(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !filtered_headers.is_empty() {
|
|
||||||
log::info!(
|
|
||||||
"[{}] ====== 被过滤的 Headers ({}) ======",
|
|
||||||
adapter.name(),
|
|
||||||
filtered_headers.len()
|
|
||||||
);
|
|
||||||
for h in &filtered_headers {
|
|
||||||
log::info!("[{}] - {}", adapter.name(), h);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理 anthropic-beta Header(仅 Claude)
|
// 处理 anthropic-beta Header(仅 Claude)
|
||||||
// 关键:确保包含 claude-code-20250219 标记,这是上游服务验证请求来源的依据
|
// 关键:确保包含 claude-code-20250219 标记,这是上游服务验证请求来源的依据
|
||||||
// 如果客户端发送的 beta 标记中没有包含 claude-code-20250219,需要补充
|
// 如果客户端发送的 beta 标记中没有包含 claude-code-20250219,需要补充
|
||||||
@@ -564,55 +629,27 @@ impl RequestForwarder {
|
|||||||
CLAUDE_CODE_BETA.to_string()
|
CLAUDE_CODE_BETA.to_string()
|
||||||
};
|
};
|
||||||
request = request.header("anthropic-beta", &beta_value);
|
request = request.header("anthropic-beta", &beta_value);
|
||||||
passed_headers.push(("anthropic-beta".to_string(), beta_value.clone()));
|
|
||||||
log::info!("[{}] 设置 anthropic-beta: {}", adapter.name(), beta_value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 客户端 IP 透传(默认开启)
|
// 客户端 IP 透传(默认开启)
|
||||||
if let Some(xff) = headers.get("x-forwarded-for") {
|
if let Some(xff) = headers.get("x-forwarded-for") {
|
||||||
if let Ok(xff_str) = xff.to_str() {
|
if let Ok(xff_str) = xff.to_str() {
|
||||||
request = request.header("x-forwarded-for", xff_str);
|
request = request.header("x-forwarded-for", xff_str);
|
||||||
passed_headers.push(("x-forwarded-for".to_string(), xff_str.to_string()));
|
|
||||||
log::debug!("[{}] 透传 x-forwarded-for: {}", adapter.name(), xff_str);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(real_ip) = headers.get("x-real-ip") {
|
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||||
if let Ok(real_ip_str) = real_ip.to_str() {
|
if let Ok(real_ip_str) = real_ip.to_str() {
|
||||||
request = request.header("x-real-ip", real_ip_str);
|
request = request.header("x-real-ip", real_ip_str);
|
||||||
passed_headers.push(("x-real-ip".to_string(), real_ip_str.to_string()));
|
|
||||||
log::debug!("[{}] 透传 x-real-ip: {}", adapter.name(), real_ip_str);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 禁用压缩,避免 gzip 流式响应解析错误
|
// 禁用压缩,避免 gzip 流式响应解析错误
|
||||||
// 参考 CCH: undici 在连接提前关闭时会对不完整的 gzip 流抛出错误
|
// 参考 CCH: undici 在连接提前关闭时会对不完整的 gzip 流抛出错误
|
||||||
request = request.header("accept-encoding", "identity");
|
request = request.header("accept-encoding", "identity");
|
||||||
passed_headers.push(("accept-encoding".to_string(), "identity".to_string()));
|
|
||||||
|
|
||||||
// 使用适配器添加认证头
|
// 使用适配器添加认证头
|
||||||
if let Some(auth) = adapter.extract_auth(provider) {
|
if let Some(auth) = adapter.extract_auth(provider) {
|
||||||
log::debug!(
|
|
||||||
"[{}] 使用认证: {:?} (key: {})",
|
|
||||||
adapter.name(),
|
|
||||||
auth.strategy,
|
|
||||||
auth.masked_key()
|
|
||||||
);
|
|
||||||
request = adapter.add_auth_headers(request, &auth);
|
request = adapter.add_auth_headers(request, &auth);
|
||||||
// 记录认证头(脱敏)
|
|
||||||
passed_headers.push((
|
|
||||||
"authorization".to_string(),
|
|
||||||
format!("Bearer {}...", &auth.api_key[..8.min(auth.api_key.len())]),
|
|
||||||
));
|
|
||||||
passed_headers.push((
|
|
||||||
"x-api-key".to_string(),
|
|
||||||
format!("{}...", &auth.api_key[..8.min(auth.api_key.len())]),
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
log::error!(
|
|
||||||
"[{}] 未找到 API Key!Provider: {}",
|
|
||||||
adapter.name(),
|
|
||||||
provider.name
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// anthropic-version 统一处理(仅 Claude):优先使用客户端的版本号,否则使用默认值
|
// anthropic-version 统一处理(仅 Claude):优先使用客户端的版本号,否则使用默认值
|
||||||
@@ -623,28 +660,10 @@ impl RequestForwarder {
|
|||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.unwrap_or("2023-06-01");
|
.unwrap_or("2023-06-01");
|
||||||
request = request.header("anthropic-version", version_str);
|
request = request.header("anthropic-version", version_str);
|
||||||
passed_headers.push(("anthropic-version".to_string(), version_str.to_string()));
|
|
||||||
log::info!(
|
|
||||||
"[{}] 设置 anthropic-version: {}",
|
|
||||||
adapter.name(),
|
|
||||||
version_str
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 最终发送的 Headers 日志 ==========
|
|
||||||
log::info!(
|
|
||||||
"[{}] ====== 最终发送的 Headers ({}) ======",
|
|
||||||
adapter.name(),
|
|
||||||
passed_headers.len()
|
|
||||||
);
|
|
||||||
for (k, v) in &passed_headers {
|
|
||||||
log::info!("[{}] {}: {}", adapter.name(), k, v);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送请求
|
// 发送请求
|
||||||
log::info!("[{}] 发送请求到: {}", adapter.name(), url);
|
|
||||||
let response = request.json(&filtered_body).send().await.map_err(|e| {
|
let response = request.json(&filtered_body).send().await.map_err(|e| {
|
||||||
log::error!("[{}] 请求失败: {}", adapter.name(), e);
|
|
||||||
if e.is_timeout() {
|
if e.is_timeout() {
|
||||||
ProxyError::Timeout(format!("请求超时: {e}"))
|
ProxyError::Timeout(format!("请求超时: {e}"))
|
||||||
} else if e.is_connect() {
|
} else if e.is_connect() {
|
||||||
@@ -656,19 +675,12 @@ impl RequestForwarder {
|
|||||||
|
|
||||||
// 检查响应状态
|
// 检查响应状态
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
log::info!("[{}] 响应状态: {}", adapter.name(), status);
|
|
||||||
|
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
Ok(response)
|
Ok(response)
|
||||||
} else {
|
} else {
|
||||||
let status_code = status.as_u16();
|
let status_code = status.as_u16();
|
||||||
let body_text = response.text().await.ok();
|
let body_text = response.text().await.ok();
|
||||||
log::error!(
|
|
||||||
"[{}] 上游错误 ({}): {:?}",
|
|
||||||
adapter.name(),
|
|
||||||
status_code,
|
|
||||||
body_text
|
|
||||||
);
|
|
||||||
|
|
||||||
Err(ProxyError::UpstreamError {
|
Err(ProxyError::UpstreamError {
|
||||||
status: status_code,
|
status: status_code,
|
||||||
@@ -699,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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::{
|
use crate::proxy::{
|
||||||
extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig,
|
extract_session_id,
|
||||||
|
forwarder::RequestForwarder,
|
||||||
|
server::ProxyState,
|
||||||
|
types::{AppProxyConfig, RectifierConfig},
|
||||||
ProxyError,
|
ProxyError,
|
||||||
};
|
};
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
@@ -54,6 +57,8 @@ pub struct RequestContext {
|
|||||||
pub app_type: AppType,
|
pub app_type: AppType,
|
||||||
/// Session ID(从客户端请求提取或新生成)
|
/// Session ID(从客户端请求提取或新生成)
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
|
/// 整流器配置
|
||||||
|
pub rectifier_config: RectifierConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RequestContext {
|
impl RequestContext {
|
||||||
@@ -86,6 +91,9 @@ impl RequestContext {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||||
|
|
||||||
|
// 从数据库读取整流器配置
|
||||||
|
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
|
||||||
|
|
||||||
let current_provider_id =
|
let current_provider_id =
|
||||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||||
|
|
||||||
@@ -127,7 +135,7 @@ impl RequestContext {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.ok_or(ProxyError::NoAvailableProvider)?;
|
.ok_or(ProxyError::NoAvailableProvider)?;
|
||||||
|
|
||||||
log::info!(
|
log::debug!(
|
||||||
"[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}",
|
"[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}",
|
||||||
tag,
|
tag,
|
||||||
provider.name,
|
provider.name,
|
||||||
@@ -147,6 +155,7 @@ impl RequestContext {
|
|||||||
app_type_str,
|
app_type_str,
|
||||||
app_type,
|
app_type,
|
||||||
session_id,
|
session_id,
|
||||||
|
rectifier_config,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +177,6 @@ impl RequestContext {
|
|||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
log::info!("[{}] 从 URI 提取模型: {}", self.tag, self.request_model);
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +198,7 @@ impl RequestContext {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// 故障转移关闭:不启用超时配置
|
// 故障转移关闭:不启用超时配置
|
||||||
log::info!(
|
log::debug!(
|
||||||
"[{}] Failover disabled, timeout configs are bypassed",
|
"[{}] Failover disabled, timeout configs are bypassed",
|
||||||
self.tag
|
self.tag
|
||||||
);
|
);
|
||||||
@@ -207,6 +215,7 @@ impl RequestContext {
|
|||||||
self.current_provider_id.clone(),
|
self.current_provider_id.clone(),
|
||||||
first_byte_timeout,
|
first_byte_timeout,
|
||||||
idle_timeout,
|
idle_timeout,
|
||||||
|
self.rectifier_config.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,16 +98,6 @@ pub async fn handle_messages(
|
|||||||
let adapter = get_adapter(&AppType::Claude);
|
let adapter = get_adapter(&AppType::Claude);
|
||||||
let needs_transform = adapter.needs_transform(&ctx.provider);
|
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 特有:格式转换处理
|
// Claude 特有:格式转换处理
|
||||||
if needs_transform {
|
if needs_transform {
|
||||||
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
|
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
|
||||||
@@ -131,8 +121,6 @@ async fn handle_claude_transform(
|
|||||||
|
|
||||||
if is_stream {
|
if is_stream {
|
||||||
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
|
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
|
||||||
log::info!("[Claude] 开始流式响应转换 (OpenAI SSE → Anthropic SSE)");
|
|
||||||
|
|
||||||
let stream = response.bytes_stream();
|
let stream = response.bytes_stream();
|
||||||
let sse_stream = create_anthropic_sse_stream(stream);
|
let sse_stream = create_anthropic_sse_stream(stream);
|
||||||
|
|
||||||
@@ -196,13 +184,10 @@ async fn handle_claude_transform(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let body = axum::body::Body::from_stream(logged_stream);
|
let body = axum::body::Body::from_stream(logged_stream);
|
||||||
log::info!("[Claude] ====== 请求结束 (流式转换) ======");
|
|
||||||
return Ok((headers, body).into_response());
|
return Ok((headers, body).into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 非流式响应转换 (OpenAI → Anthropic)
|
// 非流式响应转换 (OpenAI → Anthropic)
|
||||||
log::info!("[Claude] 开始转换响应 (OpenAI → Anthropic)");
|
|
||||||
|
|
||||||
let response_headers = response.headers().clone();
|
let response_headers = response.headers().clone();
|
||||||
|
|
||||||
let body_bytes = response.bytes().await.map_err(|e| {
|
let body_bytes = response.bytes().await.map_err(|e| {
|
||||||
@@ -211,31 +196,17 @@ async fn handle_claude_transform(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
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| {
|
let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||||
log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}");
|
log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}");
|
||||||
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
|
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| {
|
let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| {
|
||||||
log::error!("[Claude] 转换响应失败: {e}");
|
log::error!("[Claude] 转换响应失败: {e}");
|
||||||
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) {
|
if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {
|
||||||
let model = anthropic_response
|
let model = anthropic_response
|
||||||
@@ -265,8 +236,6 @@ async fn handle_claude_transform(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("[Claude] ====== 请求结束 ======");
|
|
||||||
|
|
||||||
// 构建响应
|
// 构建响应
|
||||||
let mut builder = axum::response::Response::builder().status(status);
|
let mut builder = axum::response::Response::builder().status(status);
|
||||||
|
|
||||||
@@ -285,13 +254,11 @@ async fn handle_claude_transform(
|
|||||||
ProxyError::TransformError(format!("Failed to serialize response: {e}"))
|
ProxyError::TransformError(format!("Failed to serialize response: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"[Claude] 返回转换后的响应, 长度: {} bytes",
|
|
||||||
response_body.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
let body = axum::body::Body::from(response_body);
|
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}"))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -304,8 +271,6 @@ pub async fn handle_chat_completions(
|
|||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
Json(body): Json<Value>,
|
Json(body): Json<Value>,
|
||||||
) -> Result<axum::response::Response, ProxyError> {
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
|
|
||||||
|
|
||||||
let mut ctx =
|
let mut ctx =
|
||||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||||
|
|
||||||
@@ -314,12 +279,6 @@ pub async fn handle_chat_completions(
|
|||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"[Codex] 请求模型: {}, 流式: {}",
|
|
||||||
ctx.request_model,
|
|
||||||
is_stream
|
|
||||||
);
|
|
||||||
|
|
||||||
let forwarder = ctx.create_forwarder(&state);
|
let forwarder = ctx.create_forwarder(&state);
|
||||||
let result = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
@@ -344,8 +303,6 @@ pub async fn handle_chat_completions(
|
|||||||
ctx.provider = result.provider;
|
ctx.provider = result.provider;
|
||||||
let response = result.response;
|
let response = result.response;
|
||||||
|
|
||||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
|
||||||
|
|
||||||
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
|
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,8 +344,6 @@ pub async fn handle_responses(
|
|||||||
ctx.provider = result.provider;
|
ctx.provider = result.provider;
|
||||||
let response = result.response;
|
let response = result.response;
|
||||||
|
|
||||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
|
||||||
|
|
||||||
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,8 +369,6 @@ pub async fn handle_gemini(
|
|||||||
.map(|pq| pq.as_str())
|
.map(|pq| pq.as_str())
|
||||||
.unwrap_or(uri.path());
|
.unwrap_or(uri.path());
|
||||||
|
|
||||||
log::info!("[Gemini] 请求端点: {endpoint}");
|
|
||||||
|
|
||||||
let is_stream = body
|
let is_stream = body
|
||||||
.get("stream")
|
.get("stream")
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
@@ -445,8 +398,6 @@ pub async fn handle_gemini(
|
|||||||
ctx.provider = result.provider;
|
ctx.provider = result.provider;
|
||||||
let response = result.response;
|
let response = result.response;
|
||||||
|
|
||||||
log::info!("[Gemini] 上游响应状态: {}", response.status());
|
|
||||||
|
|
||||||
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
|
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,7 +456,12 @@ async fn log_usage(
|
|||||||
Ok(Some(p)) => {
|
Ok(Some(p)) => {
|
||||||
if let Some(meta) = p.meta {
|
if let Some(meta) = p.meta {
|
||||||
if let Some(cm) = meta.cost_multiplier {
|
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 {
|
} else {
|
||||||
Decimal::from(1)
|
Decimal::from(1)
|
||||||
}
|
}
|
||||||
@@ -532,6 +488,6 @@ async fn log_usage(
|
|||||||
None, // provider_type
|
None, // provider_type
|
||||||
is_streaming,
|
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";
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ pub mod handler_config;
|
|||||||
pub mod handler_context;
|
pub mod handler_context;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod health;
|
mod health;
|
||||||
|
pub mod http_client;
|
||||||
|
pub mod log_codes;
|
||||||
pub mod model_mapper;
|
pub mod model_mapper;
|
||||||
pub mod provider_router;
|
pub mod provider_router;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
@@ -19,6 +21,7 @@ pub mod response_handler;
|
|||||||
pub mod response_processor;
|
pub mod response_processor;
|
||||||
pub(crate) mod server;
|
pub(crate) mod server;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod thinking_rectifier;
|
||||||
pub(crate) mod types;
|
pub(crate) mod types;
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ pub fn apply_model_mapping(
|
|||||||
let mapped = mapping.map_model(original, has_thinking);
|
let mapped = mapping.map_model(original, has_thinking);
|
||||||
|
|
||||||
if mapped != *original {
|
if mapped != *original {
|
||||||
log::info!("[ModelMapper] 模型映射: {original} → {mapped}");
|
log::debug!("[ModelMapper] 模型映射: {original} → {mapped}");
|
||||||
body["model"] = serde_json::json!(mapped);
|
body["model"] = serde_json::json!(mapped);
|
||||||
return (body, Some(original.clone()), Some(mapped));
|
return (body, Some(original.clone()), Some(mapped));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,15 +39,9 @@ impl ProviderRouter {
|
|||||||
|
|
||||||
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
|
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
|
||||||
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
|
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
|
||||||
Ok(config) => {
|
Ok(config) => config.auto_failover_enabled,
|
||||||
let enabled = config.auto_failover_enabled;
|
|
||||||
log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}");
|
|
||||||
enabled
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!(
|
log::error!("[{app_type}] 读取 proxy_config 失败: {e},默认禁用故障转移");
|
||||||
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
|
|
||||||
);
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -56,85 +50,37 @@ impl ProviderRouter {
|
|||||||
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
|
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
|
||||||
let failover_providers = self.db.get_failover_providers(app_type)?;
|
let failover_providers = self.db.get_failover_providers(app_type)?;
|
||||||
total_providers = failover_providers.len();
|
total_providers = failover_providers.len();
|
||||||
log::debug!("[{app_type}] Found {total_providers} failover queue provider(s)");
|
|
||||||
log::info!(
|
|
||||||
"[{app_type}] Failover enabled, using queue order ({total_providers} items)"
|
|
||||||
);
|
|
||||||
|
|
||||||
for provider in failover_providers {
|
for provider in failover_providers {
|
||||||
// 检查熔断器状态
|
|
||||||
let circuit_key = format!("{}:{}", app_type, provider.id);
|
let circuit_key = format!("{}:{}", app_type, provider.id);
|
||||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||||
let state = breaker.get_state().await;
|
|
||||||
|
|
||||||
if breaker.is_available().await {
|
if breaker.is_available().await {
|
||||||
log::debug!(
|
|
||||||
"[{}] Queue provider available: {} ({}) (state: {:?})",
|
|
||||||
app_type,
|
|
||||||
provider.name,
|
|
||||||
provider.id,
|
|
||||||
state
|
|
||||||
);
|
|
||||||
log::info!(
|
|
||||||
"[{}] Queue provider available: {} ({}) at sort_index {:?}",
|
|
||||||
app_type,
|
|
||||||
provider.name,
|
|
||||||
provider.id,
|
|
||||||
provider.sort_index
|
|
||||||
);
|
|
||||||
result.push(provider);
|
result.push(provider);
|
||||||
} else {
|
} else {
|
||||||
circuit_open_count += 1;
|
circuit_open_count += 1;
|
||||||
log::debug!(
|
|
||||||
"[{}] Queue provider {} circuit breaker open (state: {:?}), skipping",
|
|
||||||
app_type,
|
|
||||||
provider.name,
|
|
||||||
state
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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_id) = self.db.get_current_provider(app_type)? {
|
||||||
if let Some(current) = self.db.get_provider_by_id(¤t_id, 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;
|
total_providers = 1;
|
||||||
result.push(current);
|
result.push(current);
|
||||||
} else {
|
|
||||||
log::debug!(
|
|
||||||
"[{app_type}] Current provider id {current_id} not found in database"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
log::debug!("[{app_type}] No current provider configured");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.is_empty() {
|
if result.is_empty() {
|
||||||
// 区分两种情况:全部熔断 vs 未配置供应商
|
|
||||||
if total_providers > 0 && circuit_open_count == total_providers {
|
if total_providers > 0 && circuit_open_count == total_providers {
|
||||||
log::warn!("[{app_type}] 所有 {total_providers} 个供应商均已熔断,无可用渠道");
|
log::warn!("[{app_type}] [FO-004] 所有供应商均已熔断");
|
||||||
return Err(AppError::AllProvidersCircuitOpen);
|
return Err(AppError::AllProvidersCircuitOpen);
|
||||||
} else {
|
} else {
|
||||||
log::warn!("[{app_type}] 未配置供应商或故障转移队列为空");
|
log::warn!("[{app_type}] [FO-005] 未配置供应商");
|
||||||
return Err(AppError::NoProvidersConfigured);
|
return Err(AppError::NoProvidersConfigured);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"[{}] Provider chain: {} provider(s) available",
|
|
||||||
app_type,
|
|
||||||
result.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,15 +107,10 @@ impl ProviderRouter {
|
|||||||
success: bool,
|
success: bool,
|
||||||
error_msg: Option<String>,
|
error_msg: Option<String>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
// 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用)
|
// 1. 按应用独立获取熔断器配置
|
||||||
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
|
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
|
||||||
Ok(app_config) => app_config.circuit_failure_threshold,
|
Ok(app_config) => app_config.circuit_failure_threshold,
|
||||||
Err(e) => {
|
Err(_) => 5, // 默认值
|
||||||
log::warn!(
|
|
||||||
"Failed to load circuit config for {app_type}, using default threshold: {e}"
|
|
||||||
);
|
|
||||||
5 // 默认值
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. 更新熔断器状态
|
// 2. 更新熔断器状态
|
||||||
@@ -178,14 +119,8 @@ impl ProviderRouter {
|
|||||||
|
|
||||||
if success {
|
if success {
|
||||||
breaker.record_success(used_half_open_permit).await;
|
breaker.record_success(used_half_open_permit).await;
|
||||||
log::debug!("Provider {provider_id} request succeeded");
|
|
||||||
} else {
|
} else {
|
||||||
breaker.record_failure(used_half_open_permit).await;
|
breaker.record_failure(used_half_open_permit).await;
|
||||||
log::warn!(
|
|
||||||
"Provider {} request failed: {}",
|
|
||||||
provider_id,
|
|
||||||
error_msg.as_deref().unwrap_or("Unknown error")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 更新数据库健康状态(使用配置的阈值)
|
// 3. 更新数据库健康状态(使用配置的阈值)
|
||||||
@@ -206,7 +141,6 @@ impl ProviderRouter {
|
|||||||
pub async fn reset_circuit_breaker(&self, circuit_key: &str) {
|
pub async fn reset_circuit_breaker(&self, circuit_key: &str) {
|
||||||
let breakers = self.circuit_breakers.read().await;
|
let breakers = self.circuit_breakers.read().await;
|
||||||
if let Some(breaker) = breakers.get(circuit_key) {
|
if let Some(breaker) = breakers.get(circuit_key) {
|
||||||
log::info!("Manually resetting circuit breaker for {circuit_key}");
|
|
||||||
breaker.reset().await;
|
breaker.reset().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,19 +151,30 @@ impl ProviderRouter {
|
|||||||
self.reset_circuit_breaker(&circuit_key).await;
|
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) {
|
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
|
||||||
let breakers = self.circuit_breakers.read().await;
|
let breakers = self.circuit_breakers.read().await;
|
||||||
let count = breakers.len();
|
|
||||||
|
|
||||||
for breaker in breakers.values() {
|
for breaker in breakers.values() {
|
||||||
breaker.update_config(config.clone()).await;
|
breaker.update_config(config.clone()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("已更新 {count} 个熔断器的配置");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取熔断器状态
|
/// 获取熔断器状态
|
||||||
@@ -272,32 +217,16 @@ impl ProviderRouter {
|
|||||||
|
|
||||||
// 按应用独立读取熔断器配置
|
// 按应用独立读取熔断器配置
|
||||||
let config = match self.db.get_proxy_config_for_app(app_type).await {
|
let config = match self.db.get_proxy_config_for_app(app_type).await {
|
||||||
Ok(app_config) => {
|
Ok(app_config) => crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
||||||
log::debug!(
|
failure_threshold: app_config.circuit_failure_threshold,
|
||||||
"Loading circuit breaker config for {key} (app={app_type}): \
|
success_threshold: app_config.circuit_success_threshold,
|
||||||
failure_threshold={}, success_threshold={}, timeout={}s",
|
timeout_seconds: app_config.circuit_timeout_seconds as u64,
|
||||||
app_config.circuit_failure_threshold,
|
error_rate_threshold: app_config.circuit_error_rate_threshold,
|
||||||
app_config.circuit_success_threshold,
|
min_requests: app_config.circuit_min_requests,
|
||||||
app_config.circuit_timeout_seconds
|
},
|
||||||
);
|
Err(_) => crate::proxy::circuit_breaker::CircuitBreakerConfig::default(),
|
||||||
crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
|
||||||
failure_threshold: app_config.circuit_failure_threshold,
|
|
||||||
success_threshold: app_config.circuit_success_threshold,
|
|
||||||
timeout_seconds: app_config.circuit_timeout_seconds as u64,
|
|
||||||
error_rate_threshold: app_config.circuit_error_rate_threshold,
|
|
||||||
min_requests: app_config.circuit_min_requests,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!(
|
|
||||||
"Failed to load circuit breaker config for {key} (app={app_type}): {e}, using default"
|
|
||||||
);
|
|
||||||
crate::proxy::circuit_breaker::CircuitBreakerConfig::default()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
log::debug!("Creating new circuit breaker for {key} with config: {config:?}");
|
|
||||||
|
|
||||||
let breaker = Arc::new(CircuitBreaker::new(config));
|
let breaker = Arc::new(CircuitBreaker::new(config));
|
||||||
breakers.insert(key.to_string(), breaker.clone());
|
breakers.insert(key.to_string(), breaker.clone());
|
||||||
|
|
||||||
@@ -414,4 +343,55 @@ mod tests {
|
|||||||
|
|
||||||
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
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位,中间用 `...` 代替
|
/// 显示前4位和后4位,中间用 `...` 代替
|
||||||
/// 如果 key 长度不足8位,则返回 `***`
|
/// 如果 key 长度不足8位,则返回 `***`
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn masked_key(&self) -> String {
|
pub fn masked_key(&self) -> String {
|
||||||
if self.api_key.len() > 8 {
|
if self.api_key.chars().count() > 8 {
|
||||||
format!(
|
let prefix: String = self.api_key.chars().take(4).collect();
|
||||||
"{}...{}",
|
let suffix: String = self
|
||||||
&self.api_key[..4],
|
.api_key
|
||||||
&self.api_key[self.api_key.len() - 4..]
|
.chars()
|
||||||
)
|
.rev()
|
||||||
|
.take(4)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.collect();
|
||||||
|
format!("{prefix}...{suffix}")
|
||||||
} else {
|
} else {
|
||||||
"***".to_string()
|
"***".to_string()
|
||||||
}
|
}
|
||||||
@@ -54,8 +61,17 @@ impl AuthInfo {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn masked_access_token(&self) -> Option<String> {
|
pub fn masked_access_token(&self) -> Option<String> {
|
||||||
self.access_token.as_ref().map(|token| {
|
self.access_token.as_ref().map(|token| {
|
||||||
if token.len() > 8 {
|
if token.chars().count() > 8 {
|
||||||
format!("{}...{}", &token[..4], &token[token.len() - 4..])
|
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 {
|
} else {
|
||||||
"***".to_string()
|
"***".to_string()
|
||||||
}
|
}
|
||||||
@@ -126,6 +142,13 @@ mod tests {
|
|||||||
assert_eq!(auth.masked_key(), "1234...6789");
|
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]
|
#[test]
|
||||||
fn test_auth_strategy_equality() {
|
fn test_auth_strategy_equality() {
|
||||||
assert_eq!(AuthStrategy::Anthropic, AuthStrategy::Anthropic);
|
assert_eq!(AuthStrategy::Anthropic, AuthStrategy::Anthropic);
|
||||||
@@ -160,6 +183,14 @@ mod tests {
|
|||||||
assert_eq!(auth.masked_access_token(), Some("ya29...cdef".to_string()));
|
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]
|
#[test]
|
||||||
fn test_masked_access_token_short() {
|
fn test_masked_access_token_short() {
|
||||||
let auth = AuthInfo::with_access_token("refresh".to_string(), "short".to_string());
|
let auth = AuthInfo::with_access_token("refresh".to_string(), "short".to_string());
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ impl ClaudeAdapter {
|
|||||||
let normalized = value.trim().to_lowercase();
|
let normalized = value.trim().to_lowercase();
|
||||||
normalized == "true" || normalized == "1"
|
normalized == "true" || normalized == "1"
|
||||||
}
|
}
|
||||||
_ => true,
|
// OpenRouter now supports Claude Code compatible API, default to passthrough
|
||||||
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,12 +466,22 @@ mod tests {
|
|||||||
}));
|
}));
|
||||||
assert!(!adapter.needs_transform(&anthropic_provider));
|
assert!(!adapter.needs_transform(&anthropic_provider));
|
||||||
|
|
||||||
|
// OpenRouter provider without explicit setting now defaults to passthrough (no transform)
|
||||||
let openrouter_provider = create_provider(json!({
|
let openrouter_provider = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
"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!({
|
let openrouter_disabled = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
|
|||||||
@@ -75,8 +75,6 @@ pub fn create_anthropic_sse_stream(
|
|||||||
let mut current_block_type: Option<String> = None;
|
let mut current_block_type: Option<String> = None;
|
||||||
let mut tool_call_id = None;
|
let mut tool_call_id = None;
|
||||||
|
|
||||||
log::info!("[Claude/OpenRouter] ====== 开始流式响应转换 ======");
|
|
||||||
|
|
||||||
tokio::pin!(stream);
|
tokio::pin!(stream);
|
||||||
|
|
||||||
while let Some(chunk) = stream.next().await {
|
while let Some(chunk) = stream.next().await {
|
||||||
@@ -96,25 +94,18 @@ pub fn create_anthropic_sse_stream(
|
|||||||
for l in line.lines() {
|
for l in line.lines() {
|
||||||
if let Some(data) = l.strip_prefix("data: ") {
|
if let Some(data) = l.strip_prefix("data: ") {
|
||||||
if data.trim() == "[DONE]" {
|
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 event = json!({"type": "message_stop"});
|
||||||
let sse_data = format!("event: message_stop\ndata: {}\n\n",
|
let sse_data = format!("event: message_stop\ndata: {}\n\n",
|
||||||
serde_json::to_string(&event).unwrap_or_default());
|
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));
|
yield Ok(Bytes::from(sse_data));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
|
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
|
||||||
// 记录原始 OpenAI 事件(格式化显示)
|
// 仅在 DEBUG 级别简短记录 SSE 事件
|
||||||
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(data) {
|
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
|
||||||
log::info!(
|
|
||||||
"[Claude/OpenRouter] <<< OpenAI SSE 事件:\n{}",
|
|
||||||
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
log::info!("[Claude/OpenRouter] <<< OpenAI SSE 数据: {data}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if message_id.is_none() {
|
if message_id.is_none() {
|
||||||
message_id = Some(chunk.id.clone());
|
message_id = Some(chunk.id.clone());
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use super::{
|
|||||||
usage::parser::TokenUsage,
|
usage::parser::TokenUsage,
|
||||||
ProxyError,
|
ProxyError,
|
||||||
};
|
};
|
||||||
use axum::response::Response;
|
use axum::response::{IntoResponse, Response};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures::stream::{Stream, StreamExt};
|
use futures::stream::{Stream, StreamExt};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
@@ -46,8 +46,6 @@ pub async fn handle_streaming(
|
|||||||
state: &ProxyState,
|
state: &ProxyState,
|
||||||
parser_config: &UsageParserConfig,
|
parser_config: &UsageParserConfig,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
log::info!("[{}] 流式透传响应 (SSE)", ctx.tag);
|
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let mut builder = axum::response::Response::builder().status(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);
|
create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector), timeout_config);
|
||||||
|
|
||||||
let body = axum::body::Body::from_stream(logged_stream);
|
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) {
|
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) {
|
if let Some(usage) = (parser_config.response_parser)(&json_value) {
|
||||||
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
|
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
|
||||||
@@ -131,7 +129,7 @@ pub async fn handle_non_streaming(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log::info!(
|
log::debug!(
|
||||||
"[{}] <<< 响应 (非 JSON): {} bytes",
|
"[{}] <<< 响应 (非 JSON): {} bytes",
|
||||||
ctx.tag,
|
ctx.tag,
|
||||||
body_bytes.len()
|
body_bytes.len()
|
||||||
@@ -146,8 +144,6 @@ pub async fn handle_non_streaming(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("[{}] ====== 请求结束 ======", ctx.tag);
|
|
||||||
|
|
||||||
// 构建响应
|
// 构建响应
|
||||||
let mut builder = axum::response::Response::builder().status(status);
|
let mut builder = axum::response::Response::builder().status(status);
|
||||||
for (key, value) in response_headers.iter() {
|
for (key, value) in response_headers.iter() {
|
||||||
@@ -155,7 +151,10 @@ pub async fn handle_non_streaming(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let body = axum::body::Body::from(body_bytes);
|
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}"))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 通用响应处理入口
|
/// 通用响应处理入口
|
||||||
@@ -373,7 +372,12 @@ async fn log_usage_internal(
|
|||||||
Ok(Some(p)) => {
|
Ok(Some(p)) => {
|
||||||
if let Some(meta) = p.meta {
|
if let Some(meta) = p.meta {
|
||||||
if let Some(cm) = meta.cost_multiplier {
|
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 {
|
} else {
|
||||||
Decimal::from(1)
|
Decimal::from(1)
|
||||||
}
|
}
|
||||||
@@ -409,7 +413,7 @@ async fn log_usage_internal(
|
|||||||
None, // provider_type
|
None, // provider_type
|
||||||
is_streaming,
|
is_streaming,
|
||||||
) {
|
) {
|
||||||
log::warn!("记录使用量失败: {e}");
|
log::warn!("[USG-001] 记录使用量失败: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,16 +488,16 @@ pub fn create_logged_passthrough_stream(
|
|||||||
if let Some(c) = &collector {
|
if let Some(c) = &collector {
|
||||||
c.push(json_value.clone()).await;
|
c.push(json_value.clone()).await;
|
||||||
}
|
}
|
||||||
log::info!(
|
log::debug!(
|
||||||
"[{}] <<< SSE 事件:\n{}",
|
"[{}] <<< SSE 事件: {}",
|
||||||
tag,
|
tag,
|
||||||
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
|
data.chars().take(100).collect::<String>()
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
log::info!("[{tag}] <<< SSE 数据: {data}");
|
log::debug!("[{tag}] <<< SSE 数据: {}", data.chars().take(100).collect::<String>());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log::info!("[{tag}] <<< SSE: [DONE]");
|
log::debug!("[{tag}] <<< SSE: [DONE]");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -514,8 +518,6 @@ pub fn create_logged_passthrough_stream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("[{}] ====== 流结束 ======", tag);
|
|
||||||
|
|
||||||
if let Some(c) = collector.take() {
|
if let Some(c) = collector.take() {
|
||||||
c.finish().await;
|
c.finish().await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
//! 基于Axum的HTTP服务器,处理代理请求
|
//! 基于Axum的HTTP服务器,处理代理请求
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
failover_switch::FailoverSwitchManager, handlers, provider_router::ProviderRouter, types::*,
|
failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
|
||||||
ProxyError,
|
provider_router::ProviderRouter, types::*, ProxyError,
|
||||||
};
|
};
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -95,7 +95,7 @@ impl ProxyServer {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ProxyError::BindFailed(e.to_string()))?;
|
.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);
|
*self.shutdown_tx.write().await = Some(shutdown_tx);
|
||||||
@@ -146,13 +146,25 @@ impl ProxyServer {
|
|||||||
// 2. 等待服务器任务结束(带 5 秒超时保护)
|
// 2. 等待服务器任务结束(带 5 秒超时保护)
|
||||||
if let Some(handle) = self.server_handle.write().await.take() {
|
if let Some(handle) = self.server_handle.write().await.take() {
|
||||||
match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await {
|
match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await {
|
||||||
Ok(Ok(())) => log::info!("代理服务器已完全停止"),
|
Ok(Ok(())) => {
|
||||||
Ok(Err(e)) => log::warn!("代理服务器任务异常终止: {e}"),
|
log::info!("[{}] 代理服务器已完全停止", log_srv::STOPPED);
|
||||||
Err(_) => log::warn!("代理服务器停止超时(5秒),强制继续"),
|
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 {
|
pub async fn get_status(&self) -> ProxyStatus {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -191,3 +191,67 @@ pub struct AppProxyConfig {
|
|||||||
/// 计算错误率的最小请求数
|
/// 计算错误率的最小请求数
|
||||||
pub circuit_min_requests: u32,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,8 +65,11 @@ impl<'a> UsageLogger<'a> {
|
|||||||
|
|
||||||
let created_at = SystemTime::now()
|
let created_at = SystemTime::now()
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
.unwrap()
|
.map(|d| d.as_secs() as i64)
|
||||||
.as_secs() as i64;
|
.unwrap_or_else(|e| {
|
||||||
|
log::warn!("SystemTime is before UNIX_EPOCH, falling back to 0: {e}");
|
||||||
|
0
|
||||||
|
});
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO proxy_request_logs (
|
"INSERT INTO proxy_request_logs (
|
||||||
@@ -211,7 +214,7 @@ impl<'a> UsageLogger<'a> {
|
|||||||
let pricing = self.get_model_pricing(&model)?;
|
let pricing = self.get_model_pricing(&model)?;
|
||||||
|
|
||||||
if pricing.is_none() {
|
if pricing.is_none() {
|
||||||
log::warn!("模型 {model} 的定价信息未找到,成本将记录为 0");
|
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0");
|
||||||
}
|
}
|
||||||
|
|
||||||
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
|
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ impl PromptService {
|
|||||||
if !content_exists {
|
if !content_exists {
|
||||||
let timestamp = std::time::SystemTime::now()
|
let timestamp = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap_or_default()
|
||||||
.as_secs() as i64;
|
.as_secs() as i64;
|
||||||
let backup_id = format!("backup-{timestamp}");
|
let backup_id = format!("backup-{timestamp}");
|
||||||
let backup_prompt = Prompt {
|
let backup_prompt = Prompt {
|
||||||
|
|||||||
@@ -148,6 +148,15 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
|||||||
|
|
||||||
// MCP sync
|
// MCP sync
|
||||||
McpService::sync_all_enabled(state)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -615,6 +615,7 @@ impl ProviderService {
|
|||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
usage::test_usage_script(
|
usage::test_usage_script(
|
||||||
state,
|
state,
|
||||||
@@ -626,6 +627,7 @@ impl ProviderService {
|
|||||||
base_url,
|
base_url,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
|
template_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub(crate) async fn execute_and_format_usage_result(
|
|||||||
timeout: u64,
|
timeout: u64,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
match usage_script::execute_usage_script(
|
match usage_script::execute_usage_script(
|
||||||
script_code,
|
script_code,
|
||||||
@@ -25,6 +26,7 @@ pub(crate) async fn execute_and_format_usage_result(
|
|||||||
timeout,
|
timeout,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
|
template_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -113,7 +115,7 @@ pub async fn query_usage(
|
|||||||
app_type: AppType,
|
app_type: AppType,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> 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 providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
let provider = providers.get(provider_id).ok_or_else(|| {
|
let provider = providers.get(provider_id).ok_or_else(|| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
@@ -164,6 +166,7 @@ pub async fn query_usage(
|
|||||||
base_url,
|
base_url,
|
||||||
usage_script.access_token.clone(),
|
usage_script.access_token.clone(),
|
||||||
usage_script.user_id.clone(),
|
usage_script.user_id.clone(),
|
||||||
|
usage_script.template_type.clone(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,6 +177,7 @@ pub async fn query_usage(
|
|||||||
timeout,
|
timeout,
|
||||||
access_token.as_deref(),
|
access_token.as_deref(),
|
||||||
user_id.as_deref(),
|
user_id.as_deref(),
|
||||||
|
template_type.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -190,6 +194,7 @@ pub async fn test_usage_script(
|
|||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
// Use provided credential parameters directly for testing
|
// Use provided credential parameters directly for testing
|
||||||
execute_and_format_usage_result(
|
execute_and_format_usage_result(
|
||||||
@@ -199,6 +204,7 @@ pub async fn test_usage_script(
|
|||||||
timeout,
|
timeout,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
|
template_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,8 +441,21 @@ impl ProxyService {
|
|||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// 至少写入一份可用的 Token
|
// 至少写入一份可用的 Token
|
||||||
provider.settings_config["env"] =
|
if provider.settings_config.is_null() {
|
||||||
json!({ token_key: token });
|
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})"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,9 +498,20 @@ impl ProxyService {
|
|||||||
{
|
{
|
||||||
auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token));
|
auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token));
|
||||||
} else {
|
} else {
|
||||||
provider.settings_config["auth"] = json!({
|
if provider.settings_config.is_null() {
|
||||||
"OPENAI_API_KEY": token
|
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(
|
if let Err(e) = self.db.update_provider_settings_config(
|
||||||
@@ -526,9 +550,20 @@ impl ProxyService {
|
|||||||
{
|
{
|
||||||
env_obj.insert("GEMINI_API_KEY".to_string(), json!(token));
|
env_obj.insert("GEMINI_API_KEY".to_string(), json!(token));
|
||||||
} else {
|
} else {
|
||||||
provider.settings_config["env"] = json!({
|
if provider.settings_config.is_null() {
|
||||||
"GEMINI_API_KEY": token
|
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(
|
if let Err(e) = self.db.update_provider_settings_config(
|
||||||
@@ -1526,7 +1561,30 @@ impl ProxyService {
|
|||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err("Claude 配置文件不存在".to_string());
|
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> {
|
fn write_claude_live(&self, config: &Value) -> Result<(), String> {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use reqwest::Client;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -143,9 +142,7 @@ pub struct SkillMetadata {
|
|||||||
|
|
||||||
// ========== SkillService ==========
|
// ========== SkillService ==========
|
||||||
|
|
||||||
pub struct SkillService {
|
pub struct SkillService;
|
||||||
http_client: Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SkillService {
|
impl Default for SkillService {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -155,13 +152,7 @@ impl Default for SkillService {
|
|||||||
|
|
||||||
impl SkillService {
|
impl SkillService {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self
|
||||||
http_client: Client::builder()
|
|
||||||
.user_agent("cc-switch")
|
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
|
||||||
.build()
|
|
||||||
.expect("Failed to create HTTP client"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 路径管理 ==========
|
// ========== 路径管理 ==========
|
||||||
@@ -863,7 +854,8 @@ impl SkillService {
|
|||||||
|
|
||||||
/// 下载并解压 ZIP
|
/// 下载并解压 ZIP
|
||||||
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
|
||||||
let response = self.http_client.get(url).send().await?;
|
let client = crate::proxy::http_client::get();
|
||||||
|
let response = client.get(url).send().await?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status().as_u16().to_string();
|
let status = response.status().as_u16().to_string();
|
||||||
return Err(anyhow::anyhow!(format_skill_error(
|
return Err(anyhow::anyhow!(format_skill_error(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
use reqwest::{Client, Url};
|
use reqwest::{Client, Url};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Instant;
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
@@ -65,17 +65,21 @@ impl SpeedtestService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let timeout = Self::sanitize_timeout(timeout_secs);
|
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 tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
async move {
|
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 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 {
|
Ok(resp) => EndpointLatency {
|
||||||
url: trimmed,
|
url: trimmed,
|
||||||
latency: Some(start.elapsed().as_millis()),
|
latency: Some(start.elapsed().as_millis()),
|
||||||
@@ -112,19 +116,11 @@ impl SpeedtestService {
|
|||||||
Ok(results.into_iter().flatten().collect::<Vec<_>>())
|
Ok(results.into_iter().flatten().collect::<Vec<_>>())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
|
fn build_client(timeout_secs: u64) -> Result<(Client, std::time::Duration), AppError> {
|
||||||
Client::builder()
|
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||||
.timeout(Duration::from_secs(timeout_secs))
|
// 返回 timeout Duration 供请求级别使用
|
||||||
.redirect(reqwest::redirect::Policy::limited(5))
|
let timeout = std::time::Duration::from_secs(timeout_secs);
|
||||||
.user_agent("cc-switch-speedtest/1.0")
|
Ok((crate::proxy::http_client::get(), timeout))
|
||||||
.build()
|
|
||||||
.map_err(|e| {
|
|
||||||
AppError::localized(
|
|
||||||
"speedtest.client_create_failed",
|
|
||||||
format!("创建 HTTP 客户端失败: {e}"),
|
|
||||||
format!("Failed to create HTTP client: {e}"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
|
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use regex::Regex;
|
|||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Instant;
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@@ -36,6 +36,13 @@ pub struct StreamCheckConfig {
|
|||||||
pub codex_model: String,
|
pub codex_model: String,
|
||||||
/// Gemini 测试模型
|
/// Gemini 测试模型
|
||||||
pub gemini_model: String,
|
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 {
|
impl Default for StreamCheckConfig {
|
||||||
@@ -47,6 +54,7 @@ impl Default for StreamCheckConfig {
|
|||||||
claude_model: "claude-haiku-4-5-20251001".to_string(),
|
claude_model: "claude-haiku-4-5-20251001".to_string(),
|
||||||
codex_model: "gpt-5.1-codex@low".to_string(),
|
codex_model: "gpt-5.1-codex@low".to_string(),
|
||||||
gemini_model: "gemini-3-pro-preview".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 {
|
Ok(last_result.unwrap_or_else(|| StreamCheckResult {
|
||||||
status: HealthStatus::Failed,
|
status: HealthStatus::Failed,
|
||||||
success: false,
|
success: false,
|
||||||
message: "检查失败".to_string(),
|
message: "Check failed".to_string(),
|
||||||
response_time_ms: None,
|
response_time_ms: None,
|
||||||
http_status: None,
|
http_status: None,
|
||||||
model_used: String::new(),
|
model_used: String::new(),
|
||||||
@@ -130,29 +138,52 @@ impl StreamCheckService {
|
|||||||
|
|
||||||
let base_url = adapter
|
let base_url = adapter
|
||||||
.extract_base_url(provider)
|
.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
|
let auth = adapter
|
||||||
.extract_auth(provider)
|
.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()
|
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||||
.timeout(Duration::from_secs(config.timeout_secs))
|
let client = crate::proxy::http_client::get();
|
||||||
.user_agent("cc-switch/1.0")
|
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
|
||||||
.build()
|
|
||||||
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
|
|
||||||
|
|
||||||
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
||||||
|
let test_prompt = &config.test_prompt;
|
||||||
|
|
||||||
let result = match app_type {
|
let result = match app_type {
|
||||||
AppType::Claude => {
|
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 => {
|
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 => {
|
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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,7 +197,7 @@ impl StreamCheckService {
|
|||||||
Ok(StreamCheckResult {
|
Ok(StreamCheckResult {
|
||||||
status: health_status,
|
status: health_status,
|
||||||
success: true,
|
success: true,
|
||||||
message: "检查成功".to_string(),
|
message: "Check succeeded".to_string(),
|
||||||
response_time_ms: Some(response_time),
|
response_time_ms: Some(response_time),
|
||||||
http_status: Some(status_code),
|
http_status: Some(status_code),
|
||||||
model_used: model,
|
model_used: model,
|
||||||
@@ -188,31 +219,69 @@ impl StreamCheckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Claude 流式检查
|
/// Claude 流式检查
|
||||||
|
///
|
||||||
|
/// 严格按照 Claude CLI 真实请求格式构建请求
|
||||||
async fn check_claude_stream(
|
async fn check_claude_stream(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
auth: &AuthInfo,
|
auth: &AuthInfo,
|
||||||
model: &str,
|
model: &str,
|
||||||
|
test_prompt: &str,
|
||||||
|
timeout: std::time::Duration,
|
||||||
) -> Result<(u16, String), AppError> {
|
) -> Result<(u16, String), AppError> {
|
||||||
let base = base_url.trim_end_matches('/');
|
let base = base_url.trim_end_matches('/');
|
||||||
|
// URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源)
|
||||||
let url = if base.ends_with("/v1") {
|
let url = if base.ends_with("/v1") {
|
||||||
format!("{base}/messages")
|
format!("{base}/messages?beta=true")
|
||||||
} else {
|
} else {
|
||||||
format!("{base}/v1/messages")
|
format!("{base}/v1/messages?beta=true")
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"max_tokens": 1,
|
"max_tokens": 1,
|
||||||
"messages": [{ "role": "user", "content": "hi" }],
|
"messages": [{ "role": "user", "content": test_prompt }],
|
||||||
"stream": true
|
"stream": true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 获取本地系统信息
|
||||||
|
let os_name = Self::get_os_name();
|
||||||
|
let arch_name = Self::get_arch_name();
|
||||||
|
|
||||||
|
// 严格按照 Claude CLI 请求格式设置 headers
|
||||||
let response = client
|
let response = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
|
// 认证 headers(双重认证)
|
||||||
|
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||||
.header("x-api-key", &auth.api_key)
|
.header("x-api-key", &auth.api_key)
|
||||||
|
// Anthropic 必需 headers
|
||||||
.header("anthropic-version", "2023-06-01")
|
.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)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -230,51 +299,64 @@ impl StreamCheckService {
|
|||||||
if let Some(chunk) = stream.next().await {
|
if let Some(chunk) = stream.next().await {
|
||||||
match chunk {
|
match chunk {
|
||||||
Ok(_) => Ok((status, model.to_string())),
|
Ok(_) => Ok((status, model.to_string())),
|
||||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::Message("未收到响应数据".to_string()))
|
Err(AppError::Message("No response data received".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Codex 流式检查
|
/// Codex 流式检查
|
||||||
|
///
|
||||||
|
/// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API)
|
||||||
async fn check_codex_stream(
|
async fn check_codex_stream(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
auth: &AuthInfo,
|
auth: &AuthInfo,
|
||||||
model: &str,
|
model: &str,
|
||||||
|
test_prompt: &str,
|
||||||
|
timeout: std::time::Duration,
|
||||||
) -> Result<(u16, String), AppError> {
|
) -> Result<(u16, String), AppError> {
|
||||||
let base = base_url.trim_end_matches('/');
|
let base = base_url.trim_end_matches('/');
|
||||||
|
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
|
||||||
let url = if base.ends_with("/v1") {
|
let url = if base.ends_with("/v1") {
|
||||||
format!("{base}/chat/completions")
|
format!("{base}/responses")
|
||||||
} else {
|
} else {
|
||||||
format!("{base}/v1/chat/completions")
|
format!("{base}/v1/responses")
|
||||||
};
|
};
|
||||||
|
|
||||||
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||||
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
|
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!({
|
let mut body = json!({
|
||||||
"model": actual_model,
|
"model": actual_model,
|
||||||
"messages": [
|
"input": [{ "role": "user", "content": test_prompt }],
|
||||||
{ "role": "system", "content": "" },
|
|
||||||
{ "role": "assistant", "content": "" },
|
|
||||||
{ "role": "user", "content": "hi" }
|
|
||||||
],
|
|
||||||
"max_tokens": 1,
|
|
||||||
"temperature": 0,
|
|
||||||
"stream": true
|
"stream": true
|
||||||
});
|
});
|
||||||
|
|
||||||
// 如果是推理模型,添加 reasoning_effort
|
// 如果是推理模型,添加 reasoning_effort
|
||||||
if let Some(effort) = reasoning_effort {
|
if let Some(effort) = reasoning_effort {
|
||||||
body["reasoning_effort"] = json!(effort);
|
body["reasoning"] = json!({ "effort": effort });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 严格按照 Codex CLI 请求格式设置 headers
|
||||||
let response = client
|
let response = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||||
.header("Content-Type", "application/json")
|
.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)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -291,10 +373,10 @@ impl StreamCheckService {
|
|||||||
if let Some(chunk) = stream.next().await {
|
if let Some(chunk) = stream.next().await {
|
||||||
match chunk {
|
match chunk {
|
||||||
Ok(_) => Ok((status, model.to_string())),
|
Ok(_) => Ok((status, model.to_string())),
|
||||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::Message("未收到响应数据".to_string()))
|
Err(AppError::Message("No response data received".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,13 +386,15 @@ impl StreamCheckService {
|
|||||||
base_url: &str,
|
base_url: &str,
|
||||||
auth: &AuthInfo,
|
auth: &AuthInfo,
|
||||||
model: &str,
|
model: &str,
|
||||||
|
test_prompt: &str,
|
||||||
|
timeout: std::time::Duration,
|
||||||
) -> Result<(u16, String), AppError> {
|
) -> Result<(u16, String), AppError> {
|
||||||
let base = base_url.trim_end_matches('/');
|
let base = base_url.trim_end_matches('/');
|
||||||
let url = format!("{base}/v1/chat/completions");
|
let url = format!("{base}/v1/chat/completions");
|
||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": [{ "role": "user", "content": "hi" }],
|
"messages": [{ "role": "user", "content": test_prompt }],
|
||||||
"max_tokens": 1,
|
"max_tokens": 1,
|
||||||
"temperature": 0,
|
"temperature": 0,
|
||||||
"stream": true
|
"stream": true
|
||||||
@@ -320,6 +404,7 @@ impl StreamCheckService {
|
|||||||
.post(&url)
|
.post(&url)
|
||||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
|
.timeout(timeout)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -336,10 +421,10 @@ impl StreamCheckService {
|
|||||||
if let Some(chunk) = stream.next().await {
|
if let Some(chunk) = stream.next().await {
|
||||||
match chunk {
|
match chunk {
|
||||||
Ok(_) => Ok((status, model.to_string())),
|
Ok(_) => Ok((status, model.to_string())),
|
||||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::Message("未收到响应数据".to_string()))
|
Err(AppError::Message("No response data received".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,7 +439,6 @@ impl StreamCheckService {
|
|||||||
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||||
/// 返回 (实际模型名, Option<推理等级>)
|
/// 返回 (实际模型名, Option<推理等级>)
|
||||||
fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
|
fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
|
||||||
// 查找 @ 或 # 分隔符
|
|
||||||
if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
|
if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
|
||||||
let actual_model = model[..pos].to_string();
|
let actual_model = model[..pos].to_string();
|
||||||
let effort = model[pos + 1..].to_string();
|
let effort = model[pos + 1..].to_string();
|
||||||
@@ -367,17 +451,14 @@ impl StreamCheckService {
|
|||||||
|
|
||||||
fn should_retry(msg: &str) -> bool {
|
fn should_retry(msg: &str) -> bool {
|
||||||
let lower = msg.to_lowercase();
|
let lower = msg.to_lowercase();
|
||||||
lower.contains("timeout")
|
lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out")
|
||||||
|| lower.contains("abort")
|
|
||||||
|| lower.contains("中断")
|
|
||||||
|| lower.contains("超时")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_request_error(e: reqwest::Error) -> AppError {
|
fn map_request_error(e: reqwest::Error) -> AppError {
|
||||||
if e.is_timeout() {
|
if e.is_timeout() {
|
||||||
AppError::Message("请求超时".to_string())
|
AppError::Message("Request timeout".to_string())
|
||||||
} else if e.is_connect() {
|
} else if e.is_connect() {
|
||||||
AppError::Message(format!("连接失败: {e}"))
|
AppError::Message(format!("Connection failed: {e}"))
|
||||||
} else {
|
} else {
|
||||||
AppError::Message(e.to_string())
|
AppError::Message(e.to_string())
|
||||||
}
|
}
|
||||||
@@ -424,6 +505,26 @@ impl StreamCheckService {
|
|||||||
.map(|m| m.as_str().trim().to_string())
|
.map(|m| m.as_str().trim().to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.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)]
|
#[cfg(test)]
|
||||||
@@ -448,9 +549,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_retry() {
|
fn test_should_retry() {
|
||||||
assert!(StreamCheckService::should_retry("请求超时"));
|
assert!(StreamCheckService::should_retry("Request timeout"));
|
||||||
assert!(StreamCheckService::should_retry("request timeout"));
|
assert!(StreamCheckService::should_retry("request timed out"));
|
||||||
assert!(!StreamCheckService::should_retry("API Key 无效"));
|
assert!(StreamCheckService::should_retry("connection abort"));
|
||||||
|
assert!(!StreamCheckService::should_retry("API Key invalid"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -478,4 +580,33 @@ mod tests {
|
|||||||
assert_eq!(model, "gpt-4o-mini");
|
assert_eq!(model, "gpt-4o-mini");
|
||||||
assert_eq!(effort, None);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -802,25 +802,25 @@ pub(crate) fn find_model_pricing_row(
|
|||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
model_id: &str,
|
model_id: &str,
|
||||||
) -> Result<Option<(String, String, String, String)>, AppError> {
|
) -> Result<Option<(String, String, String, String)>, AppError> {
|
||||||
// 1) 去除供应商前缀(/ 之前)与冒号后缀(: 之后),例如 moonshotai/kimi-k2-0905:exa → kimi-k2-0905
|
// 清洗模型名称:去前缀(/)、去后缀(:)、@ 替换为 -
|
||||||
let without_prefix = model_id
|
// 例如 moonshotai/gpt-5.2-codex@low:v2 → gpt-5.2-codex-low
|
||||||
|
let cleaned = model_id
|
||||||
.rsplit_once('/')
|
.rsplit_once('/')
|
||||||
.map(|(_, rest)| rest)
|
.map_or(model_id, |(_, r)| r)
|
||||||
.unwrap_or(model_id);
|
|
||||||
let cleaned = without_prefix
|
|
||||||
.split(':')
|
.split(':')
|
||||||
.next()
|
.next()
|
||||||
.map(str::trim)
|
.unwrap_or(model_id)
|
||||||
.unwrap_or(without_prefix);
|
.trim()
|
||||||
|
.replace('@', "-");
|
||||||
|
|
||||||
// 2) 精确匹配清洗后的名称
|
// 精确匹配清洗后的名称
|
||||||
let exact = conn
|
let exact = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT input_cost_per_million, output_cost_per_million,
|
"SELECT input_cost_per_million, output_cost_per_million,
|
||||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||||
FROM model_pricing
|
FROM model_pricing
|
||||||
WHERE model_id = ?1",
|
WHERE model_id = ?1",
|
||||||
[cleaned],
|
[&cleaned],
|
||||||
|row| {
|
|row| {
|
||||||
Ok((
|
Ok((
|
||||||
row.get::<_, String>(0)?,
|
row.get::<_, String>(0)?,
|
||||||
@@ -952,6 +952,13 @@ mod tests {
|
|||||||
"带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905"
|
"带前缀+冒号后缀的模型应清洗后匹配到 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"
|
||||||
|
);
|
||||||
|
|
||||||
// 测试不存在的模型
|
// 测试不存在的模型
|
||||||
let result = find_model_pricing_row(&conn, "unknown-model-123")?;
|
let result = find_model_pricing_row(&conn, "unknown-model-123")?;
|
||||||
assert!(result.is_none(), "不应该匹配不存在的模型");
|
assert!(result.is_none(), "不应该匹配不存在的模型");
|
||||||
|
|||||||
+23
-10
@@ -92,12 +92,9 @@ impl Default for AppSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppSettings {
|
impl AppSettings {
|
||||||
fn settings_path() -> PathBuf {
|
fn settings_path() -> Option<PathBuf> {
|
||||||
// settings.json 保留用于旧版本迁移和无数据库场景
|
// settings.json 保留用于旧版本迁移和无数据库场景
|
||||||
dirs::home_dir()
|
dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json"))
|
||||||
.expect("无法获取用户主目录")
|
|
||||||
.join(".cc-switch")
|
|
||||||
.join("settings.json")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_paths(&mut self) {
|
fn normalize_paths(&mut self) {
|
||||||
@@ -131,7 +128,9 @@ impl AppSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn load_from_file() -> Self {
|
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) {
|
if let Ok(content) = fs::read_to_string(&path) {
|
||||||
match serde_json::from_str::<AppSettings>(&content) {
|
match serde_json::from_str::<AppSettings>(&content) {
|
||||||
Ok(mut settings) => {
|
Ok(mut settings) => {
|
||||||
@@ -156,7 +155,9 @@ impl AppSettings {
|
|||||||
fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
|
fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
|
||||||
let mut normalized = settings.clone();
|
let mut normalized = settings.clone();
|
||||||
normalized.normalize_paths();
|
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() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
@@ -193,14 +194,23 @@ fn resolve_override_path(raw: &str) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_settings() -> AppSettings {
|
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> {
|
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
||||||
new_settings.normalize_paths();
|
new_settings.normalize_paths();
|
||||||
save_settings_file(&new_settings)?;
|
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;
|
*guard = new_settings;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -209,7 +219,10 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
|||||||
/// 用于导入配置等场景,确保内存缓存与文件同步
|
/// 用于导入配置等场景,确保内存缓存与文件同步
|
||||||
pub fn reload_settings() -> Result<(), AppError> {
|
pub fn reload_settings() -> Result<(), AppError> {
|
||||||
let fresh_settings = AppSettings::load_from_file();
|
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;
|
*guard = fresh_settings;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-78
@@ -1,8 +1,6 @@
|
|||||||
use reqwest::Client;
|
|
||||||
use rquickjs::{Context, Function, Runtime};
|
use rquickjs::{Context, Function, Runtime};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
|
||||||
use url::{Host, Url};
|
use url::{Host, Url};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@@ -15,13 +13,21 @@ pub async fn execute_usage_script(
|
|||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<Value, AppError> {
|
) -> Result<Value, AppError> {
|
||||||
|
// 检测是否为自定义模板模式
|
||||||
|
// 优先使用前端传递的 template_type
|
||||||
|
let is_custom_template = template_type.map(|t| t == "custom").unwrap_or(false);
|
||||||
|
|
||||||
// 1. 替换模板变量,避免泄露敏感信息
|
// 1. 替换模板变量,避免泄露敏感信息
|
||||||
let script_with_vars =
|
let script_with_vars =
|
||||||
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
|
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
|
||||||
|
|
||||||
// 2. 验证 base_url 的安全性
|
// 2. 验证 base_url 的安全性(仅当提供了 base_url 时)
|
||||||
validate_base_url(base_url)?;
|
// 自定义模板模式下,用户可能不使用模板变量,而是直接在脚本中写完整 URL
|
||||||
|
if !base_url.is_empty() {
|
||||||
|
validate_base_url(base_url)?;
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
||||||
let request_config = {
|
let request_config = {
|
||||||
@@ -99,7 +105,8 @@ pub async fn execute_usage_script(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 5. 验证请求 URL 是否安全(防止 SSRF)
|
// 5. 验证请求 URL 是否安全(防止 SSRF)
|
||||||
validate_request_url(&request.url, base_url)?;
|
// 如果提供了 base_url,则验证同源;否则只做基本安全检查
|
||||||
|
validate_request_url(&request.url, base_url, is_custom_template)?;
|
||||||
|
|
||||||
// 6. 发送 HTTP 请求
|
// 6. 发送 HTTP 请求
|
||||||
let response_data = send_http_request(&request, timeout_secs).await?;
|
let response_data = send_http_request(&request, timeout_secs).await?;
|
||||||
@@ -215,18 +222,10 @@ struct RequestConfig {
|
|||||||
|
|
||||||
/// 发送 HTTP 请求
|
/// 发送 HTTP 请求
|
||||||
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
|
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
|
||||||
// 约束超时范围,防止异常配置导致长时间阻塞
|
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||||
let timeout = timeout_secs.clamp(2, 30);
|
let client = crate::proxy::http_client::get();
|
||||||
let client = Client::builder()
|
// 约束超时范围,防止异常配置导致长时间阻塞(最小 2 秒,最大 30 秒)
|
||||||
.timeout(Duration::from_secs(timeout))
|
let request_timeout = std::time::Duration::from_secs(timeout_secs.clamp(2, 30));
|
||||||
.build()
|
|
||||||
.map_err(|e| {
|
|
||||||
AppError::localized(
|
|
||||||
"usage_script.client_create_failed",
|
|
||||||
format!("创建客户端失败: {e}"),
|
|
||||||
format!("Failed to create client: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// 严格校验 HTTP 方法,非法值不回退为 GET
|
// 严格校验 HTTP 方法,非法值不回退为 GET
|
||||||
let method: reqwest::Method = config.method.parse().map_err(|_| {
|
let method: reqwest::Method = config.method.parse().map_err(|_| {
|
||||||
@@ -237,7 +236,9 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut req = client.request(method.clone(), &config.url);
|
let mut req = client
|
||||||
|
.request(method.clone(), &config.url)
|
||||||
|
.timeout(request_timeout);
|
||||||
|
|
||||||
// 添加请求头
|
// 添加请求头
|
||||||
for (k, v) in &config.headers {
|
for (k, v) in &config.headers {
|
||||||
@@ -269,7 +270,11 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
|
|||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let preview = if text.len() > 200 {
|
let preview = if text.len() > 200 {
|
||||||
format!("{}...", &text[..200])
|
let mut safe_cut = 200usize;
|
||||||
|
while !text.is_char_boundary(safe_cut) {
|
||||||
|
safe_cut = safe_cut.saturating_sub(1);
|
||||||
|
}
|
||||||
|
format!("{}...", &text[..safe_cut])
|
||||||
} else {
|
} else {
|
||||||
text.clone()
|
text.clone()
|
||||||
};
|
};
|
||||||
@@ -476,7 +481,11 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 验证请求 URL 是否安全(防止 SSRF)
|
/// 验证请求 URL 是否安全(防止 SSRF)
|
||||||
fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppError> {
|
fn validate_request_url(
|
||||||
|
request_url: &str,
|
||||||
|
base_url: &str,
|
||||||
|
is_custom_template: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
// 解析请求 URL
|
// 解析请求 URL
|
||||||
let parsed_request = Url::parse(request_url).map_err(|e| {
|
let parsed_request = Url::parse(request_url).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
@@ -486,19 +495,11 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 解析 base URL
|
|
||||||
let parsed_base = Url::parse(base_url).map_err(|e| {
|
|
||||||
AppError::localized(
|
|
||||||
"usage_script.base_url_invalid",
|
|
||||||
format!("无效的 base_url: {e}"),
|
|
||||||
format!("Invalid base_url: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let is_request_loopback = is_loopback_host(&parsed_request);
|
let is_request_loopback = is_loopback_host(&parsed_request);
|
||||||
|
|
||||||
// 必须使用 HTTPS(允许 localhost 用于开发)
|
// 必须使用 HTTPS(允许 localhost 用于开发)
|
||||||
if parsed_request.scheme() != "https" && !is_request_loopback {
|
// 自定义模板模式下,允许用户自行决定是否使用 HTTP(用户需自行承担安全风险)
|
||||||
|
if !is_custom_template && parsed_request.scheme() != "https" && !is_request_loopback {
|
||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"usage_script.request_https_required",
|
"usage_script.request_https_required",
|
||||||
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
|
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
|
||||||
@@ -506,60 +507,85 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
|
// 如果提供了 base_url(非空),则进行同源检查
|
||||||
if parsed_request.host_str() != parsed_base.host_str() {
|
// 🔧 自定义模板模式下,用户可以自由访问任意 HTTPS 域名,跳过同源检查
|
||||||
return Err(AppError::localized(
|
if !base_url.is_empty() && !is_custom_template {
|
||||||
"usage_script.request_host_mismatch",
|
// 解析 base URL
|
||||||
format!(
|
let parsed_base = Url::parse(base_url).map_err(|e| {
|
||||||
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
|
AppError::localized(
|
||||||
parsed_request.host_str().unwrap_or("unknown"),
|
"usage_script.base_url_invalid",
|
||||||
parsed_base.host_str().unwrap_or("unknown")
|
format!("无效的 base_url: {e}"),
|
||||||
),
|
format!("Invalid base_url: {e}"),
|
||||||
format!(
|
)
|
||||||
"Request host {} must match base_url host {} (same-origin required)",
|
})?;
|
||||||
parsed_request.host_str().unwrap_or("unknown"),
|
|
||||||
parsed_base.host_str().unwrap_or("unknown")
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查端口是否匹配(考虑默认端口)
|
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
|
||||||
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443)
|
if parsed_request.host_str() != parsed_base.host_str() {
|
||||||
match (
|
|
||||||
parsed_request.port_or_known_default(),
|
|
||||||
parsed_base.port_or_known_default(),
|
|
||||||
) {
|
|
||||||
(Some(request_port), Some(base_port)) if request_port == base_port => {
|
|
||||||
// 端口匹配,继续执行
|
|
||||||
}
|
|
||||||
(Some(request_port), Some(base_port)) => {
|
|
||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"usage_script.request_port_mismatch",
|
"usage_script.request_host_mismatch",
|
||||||
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
|
format!(
|
||||||
format!("Request port {request_port} must match base_url port {base_port}"),
|
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
|
||||||
|
parsed_request.host_str().unwrap_or("unknown"),
|
||||||
|
parsed_base.host_str().unwrap_or("unknown")
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"Request host {} must match base_url host {} (same-origin required)",
|
||||||
|
parsed_request.host_str().unwrap_or("unknown"),
|
||||||
|
parsed_base.host_str().unwrap_or("unknown")
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
_ => {
|
|
||||||
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
|
// 检查端口是否匹配(考虑默认端口)
|
||||||
return Err(AppError::localized(
|
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443)
|
||||||
"usage_script.request_port_unknown",
|
match (
|
||||||
"无法确定端口号",
|
parsed_request.port_or_known_default(),
|
||||||
"Unable to determine port number",
|
parsed_base.port_or_known_default(),
|
||||||
));
|
) {
|
||||||
|
(Some(request_port), Some(base_port)) if request_port == base_port => {
|
||||||
|
// 端口匹配,继续执行
|
||||||
|
}
|
||||||
|
(Some(request_port), Some(base_port)) => {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.request_port_mismatch",
|
||||||
|
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
|
||||||
|
format!("Request port {request_port} must match base_url port {base_port}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.request_port_unknown",
|
||||||
|
"无法确定端口号",
|
||||||
|
"Unable to determine port number",
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
|
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
|
||||||
if let Some(host) = parsed_request.host_str() {
|
if let Some(host) = parsed_request.host_str() {
|
||||||
let base_host = parsed_base.host_str().unwrap_or("");
|
let base_host = parsed_base.host_str().unwrap_or("");
|
||||||
|
|
||||||
// 如果 base_url 不是私有地址,则禁止访问私有IP
|
// 如果 base_url 不是私有地址,则禁止访问私有IP
|
||||||
if !is_private_ip(base_host) && is_private_ip(host) {
|
if !is_private_ip(base_host) && is_private_ip(host) {
|
||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"usage_script.private_ip_blocked",
|
"usage_script.private_ip_blocked",
|
||||||
"禁止访问私有 IP 地址",
|
"禁止访问私有 IP 地址",
|
||||||
"Access to private IP addresses is blocked",
|
"Access to private IP addresses is blocked",
|
||||||
));
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 自定义模板模式:没有 base_url,需要额外的安全检查
|
||||||
|
// 禁止访问私有 IP 地址(SSRF 防护)
|
||||||
|
if let Some(host) = parsed_request.host_str() {
|
||||||
|
if is_private_ip(host) && !is_request_loopback {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.private_ip_blocked",
|
||||||
|
"禁止访问私有 IP 地址(localhost 除外)",
|
||||||
|
"Access to private IP addresses is blocked (localhost allowed)",
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,7 +873,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (base_url, request_url, should_match) in test_cases {
|
for (base_url, request_url, should_match) in test_cases {
|
||||||
let result = validate_request_url(request_url, base_url);
|
let result = validate_request_url(request_url, base_url, false);
|
||||||
|
|
||||||
if should_match {
|
if should_match {
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CC Switch",
|
"productName": "CC Switch",
|
||||||
"version": "3.9.0-3",
|
"version": "3.9.1",
|
||||||
"identifier": "com.ccswitch.desktop",
|
"identifier": "com.ccswitch.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
|
"title": "CC Switch",
|
||||||
"titleBarStyle": "Visible",
|
"titleBarStyle": "Visible",
|
||||||
"minWidth": 900,
|
"minWidth": 900,
|
||||||
"minHeight": 600
|
"minHeight": 600
|
||||||
|
|||||||
+46
-17
@@ -31,6 +31,7 @@ import { useProxyStatus } from "@/hooks/useProxyStatus";
|
|||||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { isWindows, isLinux } from "@/lib/platform";
|
||||||
import { AppSwitcher } from "@/components/AppSwitcher";
|
import { AppSwitcher } from "@/components/AppSwitcher";
|
||||||
import { ProviderList } from "@/components/providers/ProviderList";
|
import { ProviderList } from "@/components/providers/ProviderList";
|
||||||
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
|
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
|
||||||
@@ -60,7 +61,8 @@ type View =
|
|||||||
| "agents"
|
| "agents"
|
||||||
| "universal";
|
| "universal";
|
||||||
|
|
||||||
const DRAG_BAR_HEIGHT = 28; // px
|
// macOS Overlay mode needs space for traffic light buttons, Windows/Linux use native titlebar
|
||||||
|
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
|
||||||
const HEADER_HEIGHT = 64; // px
|
const HEADER_HEIGHT = 64; // px
|
||||||
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
|
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
|
||||||
|
|
||||||
@@ -380,6 +382,26 @@ function App() {
|
|||||||
await addProvider(duplicatedProvider);
|
await addProvider(duplicatedProvider);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 打开提供商终端
|
||||||
|
const handleOpenTerminal = async (provider: Provider) => {
|
||||||
|
try {
|
||||||
|
await providersApi.openTerminal(provider.id, activeApp);
|
||||||
|
toast.success(
|
||||||
|
t("provider.terminalOpened", {
|
||||||
|
defaultValue: "终端已打开",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[App] Failed to open terminal", error);
|
||||||
|
const errorMessage = extractErrorMessage(error);
|
||||||
|
toast.error(
|
||||||
|
t("provider.terminalOpenFailed", {
|
||||||
|
defaultValue: "打开终端失败",
|
||||||
|
}) + (errorMessage ? `: ${errorMessage}` : ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 导入配置成功后刷新
|
// 导入配置成功后刷新
|
||||||
const handleImportSuccess = async () => {
|
const handleImportSuccess = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -480,6 +502,9 @@ function App() {
|
|||||||
onDuplicate={handleDuplicateProvider}
|
onDuplicate={handleDuplicateProvider}
|
||||||
onConfigureUsage={setUsageProvider}
|
onConfigureUsage={setUsageProvider}
|
||||||
onOpenWebsite={handleOpenWebsite}
|
onOpenWebsite={handleOpenWebsite}
|
||||||
|
onOpenTerminal={
|
||||||
|
activeApp === "claude" ? handleOpenTerminal : undefined
|
||||||
|
}
|
||||||
onCreate={() => setIsAddOpen(true)}
|
onCreate={() => setIsAddOpen(true)}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -594,8 +619,8 @@ function App() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative inline-flex items-center">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/farion1231/cc-switch"
|
href="https://github.com/farion1231/cc-switch"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -609,24 +634,27 @@ function App() {
|
|||||||
>
|
>
|
||||||
CC Switch
|
CC Switch
|
||||||
</a>
|
</a>
|
||||||
<Button
|
<UpdateBadge
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSettingsDefaultTab("general");
|
setSettingsDefaultTab("about");
|
||||||
setCurrentView("settings");
|
setCurrentView("settings");
|
||||||
}}
|
}}
|
||||||
title={t("common.settings")}
|
className="absolute -top-4 -right-4"
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
/>
|
||||||
>
|
|
||||||
<Settings className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<UpdateBadge onClick={() => {
|
<Button
|
||||||
setSettingsDefaultTab("about");
|
variant="ghost"
|
||||||
setCurrentView("settings");
|
size="icon"
|
||||||
}} />
|
onClick={() => {
|
||||||
</>
|
setSettingsDefaultTab("general");
|
||||||
|
setCurrentView("settings");
|
||||||
|
}}
|
||||||
|
title={t("common.settings")}
|
||||||
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -804,6 +832,7 @@ function App() {
|
|||||||
|
|
||||||
{effectiveUsageProvider && (
|
{effectiveUsageProvider && (
|
||||||
<UsageScriptModal
|
<UsageScriptModal
|
||||||
|
key={effectiveUsageProvider.id}
|
||||||
provider={effectiveUsageProvider}
|
provider={effectiveUsageProvider}
|
||||||
appId={activeApp}
|
appId={activeApp}
|
||||||
isOpen={Boolean(usageProvider)}
|
isOpen={Boolean(usageProvider)}
|
||||||
|
|||||||
@@ -389,12 +389,27 @@ export function DeepLinkImportDialog() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Endpoint */}
|
{/* API Endpoint */}
|
||||||
<div className="grid grid-cols-3 items-center gap-4">
|
<div className="grid grid-cols-3 items-start gap-4">
|
||||||
<div className="font-medium text-sm text-muted-foreground">
|
<div className="font-medium text-sm text-muted-foreground pt-0.5">
|
||||||
{t("deeplink.endpoint")}
|
{t("deeplink.endpoint")}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 text-sm break-all">
|
<div className="col-span-2 text-sm break-all space-y-1">
|
||||||
{request.endpoint}
|
{request.endpoint?.split(",").map((ep, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={
|
||||||
|
idx === 0 ? "font-medium" : "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{idx === 0 ? "🔹 " : "└ "}
|
||||||
|
{ep.trim()}
|
||||||
|
{idx === 0 && request.endpoint?.includes(",") && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
({t("deeplink.primaryEndpoint")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { X, Download } from "lucide-react";
|
|
||||||
import { useUpdate } from "@/contexts/UpdateContext";
|
import { useUpdate } from "@/contexts/UpdateContext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface UpdateBadgeProps {
|
interface UpdateBadgeProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -8,56 +8,39 @@ interface UpdateBadgeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
||||||
const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate();
|
const { hasUpdate, updateInfo } = useUpdate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isActive = hasUpdate && updateInfo;
|
||||||
|
const title = isActive
|
||||||
|
? t("settings.updateAvailable", {
|
||||||
|
version: updateInfo?.availableVersion ?? "",
|
||||||
|
})
|
||||||
|
: t("settings.checkForUpdates");
|
||||||
|
|
||||||
// 如果没有更新或已关闭,不显示
|
if (!isActive) {
|
||||||
if (!hasUpdate || isDismissed || !updateInfo) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
onClick={onClick}
|
||||||
className={`
|
className={`
|
||||||
flex items-center gap-1.5 px-2.5 py-1
|
relative h-6 w-6 rounded-full
|
||||||
bg-white dark:bg-gray-800
|
${isActive ? "text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-500/10" : "text-muted-foreground hover:bg-muted/60"}
|
||||||
border border-border-default
|
|
||||||
rounded-lg text-xs
|
|
||||||
shadow-sm
|
|
||||||
transition-all duration-200
|
|
||||||
${onClick ? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750" : ""}
|
|
||||||
${className}
|
${className}
|
||||||
`}
|
`}
|
||||||
role={onClick ? "button" : undefined}
|
|
||||||
tabIndex={onClick ? 0 : -1}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (!onClick) return;
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Download className="w-3 h-3 text-blue-500 dark:text-blue-400" />
|
<span
|
||||||
<span className="text-gray-700 dark:text-gray-300 font-medium">
|
className={`
|
||||||
{t("settings.updateBadge")}
|
absolute inset-0 m-auto h-2 w-2 rounded-full ring-1 ring-background
|
||||||
</span>
|
${isActive ? "bg-blue-500 dark:bg-blue-400" : "bg-blue-300/70 dark:bg-blue-300/60"}
|
||||||
<button
|
`}
|
||||||
onClick={(e) => {
|
/>
|
||||||
e.stopPropagation();
|
</Button>
|
||||||
dismissUpdate();
|
|
||||||
}}
|
|
||||||
className="
|
|
||||||
ml-1 -mr-0.5 p-0.5 rounded
|
|
||||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
|
||||||
transition-colors
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500/20
|
|
||||||
"
|
|
||||||
aria-label={t("common.close")}
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import React, { useState } from "react";
|
|||||||
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
|
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Provider, UsageScript, UsageData } from "@/types";
|
import { Provider, UsageScript, UsageData } from "@/types";
|
||||||
import { usageApi, type AppId } from "@/lib/api";
|
import { usageApi, type AppId } from "@/lib/api";
|
||||||
|
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
||||||
import JsonEditor from "./JsonEditor";
|
import JsonEditor from "./JsonEditor";
|
||||||
import * as prettier from "prettier/standalone";
|
import * as prettier from "prettier/standalone";
|
||||||
import * as parserBabel from "prettier/parser-babel";
|
import * as parserBabel from "prettier/parser-babel";
|
||||||
@@ -109,19 +111,67 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// 生成带国际化的预设模板
|
// 生成带国际化的预设模板
|
||||||
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
||||||
|
|
||||||
const [script, setScript] = useState<UsageScript>(() => {
|
// 从 provider 的 settingsConfig 中提取 API Key 和 Base URL
|
||||||
return (
|
const getProviderCredentials = (): {
|
||||||
provider.meta?.usage_script || {
|
apiKey: string | undefined;
|
||||||
enabled: false,
|
baseUrl: string | undefined;
|
||||||
language: "javascript",
|
} => {
|
||||||
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
|
try {
|
||||||
timeout: 10,
|
const config = provider.settingsConfig;
|
||||||
|
if (!config) return { apiKey: undefined, baseUrl: undefined };
|
||||||
|
|
||||||
|
// 处理不同应用的配置格式
|
||||||
|
if (appId === "claude") {
|
||||||
|
// Claude: { env: { ANTHROPIC_AUTH_TOKEN | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL } }
|
||||||
|
const env = (config as any).env || {};
|
||||||
|
return {
|
||||||
|
apiKey: env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY,
|
||||||
|
baseUrl: env.ANTHROPIC_BASE_URL,
|
||||||
|
};
|
||||||
|
} else if (appId === "codex") {
|
||||||
|
// Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url }
|
||||||
|
const auth = (config as any).auth || {};
|
||||||
|
const configToml = (config as any).config || "";
|
||||||
|
return {
|
||||||
|
apiKey: auth.OPENAI_API_KEY,
|
||||||
|
baseUrl: extractCodexBaseUrl(configToml),
|
||||||
|
};
|
||||||
|
} else if (appId === "gemini") {
|
||||||
|
// Gemini: { env: { GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL } }
|
||||||
|
const env = (config as any).env || {};
|
||||||
|
return {
|
||||||
|
apiKey: env.GEMINI_API_KEY,
|
||||||
|
baseUrl: env.GOOGLE_GEMINI_BASE_URL,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
);
|
return { apiKey: undefined, baseUrl: undefined };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to extract provider credentials:", error);
|
||||||
|
return { apiKey: undefined, baseUrl: undefined };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerCredentials = getProviderCredentials();
|
||||||
|
|
||||||
|
const [script, setScript] = useState<UsageScript>(() => {
|
||||||
|
const savedScript = provider.meta?.usage_script;
|
||||||
|
const defaultScript = {
|
||||||
|
enabled: false,
|
||||||
|
language: "javascript" as const,
|
||||||
|
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
|
||||||
|
timeout: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!savedScript) {
|
||||||
|
return defaultScript;
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedScript;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
@@ -176,6 +226,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
|
||||||
() => {
|
() => {
|
||||||
const existingScript = provider.meta?.usage_script;
|
const existingScript = provider.meta?.usage_script;
|
||||||
|
// 优先使用保存的 templateType
|
||||||
|
if (existingScript?.templateType) {
|
||||||
|
return existingScript.templateType;
|
||||||
|
}
|
||||||
|
// 向后兼容:根据字段推断模板类型
|
||||||
// 检测 NEW_API 模板(有 accessToken 或 userId)
|
// 检测 NEW_API 模板(有 accessToken 或 userId)
|
||||||
if (existingScript?.accessToken || existingScript?.userId) {
|
if (existingScript?.accessToken || existingScript?.userId) {
|
||||||
return TEMPLATE_KEYS.NEW_API;
|
return TEMPLATE_KEYS.NEW_API;
|
||||||
@@ -201,7 +256,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
|
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSave(script);
|
// 保存时记录当前选择的模板类型
|
||||||
|
const scriptWithTemplate = {
|
||||||
|
...script,
|
||||||
|
templateType: selectedTemplate as
|
||||||
|
| "custom"
|
||||||
|
| "general"
|
||||||
|
| "newapi"
|
||||||
|
| undefined,
|
||||||
|
};
|
||||||
|
onSave(scriptWithTemplate);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -217,6 +281,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
script.baseUrl,
|
script.baseUrl,
|
||||||
script.accessToken,
|
script.accessToken,
|
||||||
script.userId,
|
script.userId,
|
||||||
|
selectedTemplate as "custom" | "general" | "newapi" | undefined,
|
||||||
);
|
);
|
||||||
if (result.success && result.data && result.data.length > 0) {
|
if (result.success && result.data && result.data.length > 0) {
|
||||||
const summary = result.data
|
const summary = result.data
|
||||||
@@ -229,6 +294,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔧 测试成功后,更新主界面列表的用量查询缓存
|
||||||
|
queryClient.setQueryData(["usage", provider.id, appId], result);
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
|
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
|
||||||
@@ -278,9 +346,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
const preset = PRESET_TEMPLATES[presetName];
|
const preset = PRESET_TEMPLATES[presetName];
|
||||||
if (preset) {
|
if (preset) {
|
||||||
if (presetName === TEMPLATE_KEYS.CUSTOM) {
|
if (presetName === TEMPLATE_KEYS.CUSTOM) {
|
||||||
|
// 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换
|
||||||
|
// 这样可以避免同源检查导致的问题
|
||||||
|
// 如果用户想使用变量,需要手动在配置中设置 baseUrl/apiKey
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
code: preset,
|
code: preset,
|
||||||
|
// 清除凭证,用户可选择手动输入或保持空
|
||||||
apiKey: undefined,
|
apiKey: undefined,
|
||||||
baseUrl: undefined,
|
baseUrl: undefined,
|
||||||
accessToken: undefined,
|
accessToken: undefined,
|
||||||
@@ -401,6 +473,74 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 自定义模式:变量提示和具体值 */}
|
||||||
|
{selectedTemplate === TEMPLATE_KEYS.CUSTOM && (
|
||||||
|
<div className="space-y-2 border-t border-white/10 pt-3">
|
||||||
|
<h4 className="text-sm font-medium text-foreground">
|
||||||
|
{t("usageScript.supportedVariables")}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
{/* baseUrl */}
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
|
||||||
|
{"{{baseUrl}}"}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground/50">=</span>
|
||||||
|
{providerCredentials.baseUrl ? (
|
||||||
|
<code className="text-foreground/70 break-all font-mono">
|
||||||
|
{providerCredentials.baseUrl}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/50 italic">
|
||||||
|
{t("common.notSet") || "未设置"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* apiKey */}
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
|
||||||
|
{"{{apiKey}}"}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground/50">=</span>
|
||||||
|
{providerCredentials.apiKey ? (
|
||||||
|
<>
|
||||||
|
{showApiKey ? (
|
||||||
|
<code className="text-foreground/70 break-all font-mono">
|
||||||
|
{providerCredentials.apiKey}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<code className="text-foreground/70 font-mono">
|
||||||
|
••••••••
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowApiKey(!showApiKey)}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors ml-1"
|
||||||
|
aria-label={
|
||||||
|
showApiKey
|
||||||
|
? t("apiKeyInput.hide")
|
||||||
|
: t("apiKeyInput.show")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showApiKey ? (
|
||||||
|
<EyeOff size={12} />
|
||||||
|
) : (
|
||||||
|
<Eye size={12} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/50 italic">
|
||||||
|
{t("common.notSet") || "未设置"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 凭证配置 */}
|
{/* 凭证配置 */}
|
||||||
{shouldShowCredentialsConfig && (
|
{shouldShowCredentialsConfig && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -601,11 +741,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={1440}
|
max={1440}
|
||||||
value={script.autoIntervalMinutes ?? 0}
|
value={
|
||||||
|
script.autoQueryInterval ?? script.autoIntervalMinutes ?? 0
|
||||||
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
autoIntervalMinutes: validateAndClampInterval(
|
autoQueryInterval: validateAndClampInterval(
|
||||||
e.target.value,
|
e.target.value,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -613,7 +755,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
onBlur={(e) =>
|
onBlur={(e) =>
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
autoIntervalMinutes: validateAndClampInterval(
|
autoQueryInterval: validateAndClampInterval(
|
||||||
e.target.value,
|
e.target.value,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { isWindows, isLinux } from "@/lib/platform";
|
||||||
|
|
||||||
interface FullScreenPanelProps {
|
interface FullScreenPanelProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -12,7 +13,7 @@ interface FullScreenPanelProps {
|
|||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DRAG_BAR_HEIGHT = 28; // px - match App.tsx
|
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px - match App.tsx
|
||||||
const HEADER_HEIGHT = 64; // px - match App.tsx
|
const HEADER_HEIGHT = 64; // px - match App.tsx
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
|
Terminal,
|
||||||
TestTube2,
|
TestTube2,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -23,6 +24,7 @@ interface ProviderActionsProps {
|
|||||||
onTest?: () => void;
|
onTest?: () => void;
|
||||||
onConfigureUsage: () => void;
|
onConfigureUsage: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onOpenTerminal?: () => void;
|
||||||
// 故障转移相关
|
// 故障转移相关
|
||||||
isAutoFailoverEnabled?: boolean;
|
isAutoFailoverEnabled?: boolean;
|
||||||
isInFailoverQueue?: boolean;
|
isInFailoverQueue?: boolean;
|
||||||
@@ -39,6 +41,7 @@ export function ProviderActions({
|
|||||||
onTest,
|
onTest,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onOpenTerminal,
|
||||||
// 故障转移相关
|
// 故障转移相关
|
||||||
isAutoFailoverEnabled = false,
|
isAutoFailoverEnabled = false,
|
||||||
isInFailoverQueue = false,
|
isInFailoverQueue = false,
|
||||||
@@ -171,6 +174,21 @@ export function ProviderActions({
|
|||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{onOpenTerminal && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onOpenTerminal}
|
||||||
|
title={t("provider.openTerminal", "打开终端")}
|
||||||
|
className={cn(
|
||||||
|
iconButtonClass,
|
||||||
|
"hover:text-emerald-600 dark:hover:text-emerald-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Terminal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState, useEffect } from "react";
|
import { useMemo, useState, useEffect, useRef } from "react";
|
||||||
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type {
|
||||||
@@ -33,6 +33,7 @@ interface ProviderCardProps {
|
|||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onTest?: (provider: Provider) => void;
|
onTest?: (provider: Provider) => void;
|
||||||
|
onOpenTerminal?: (provider: Provider) => void;
|
||||||
isTesting?: boolean;
|
isTesting?: boolean;
|
||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
||||||
@@ -91,6 +92,7 @@ export function ProviderCard({
|
|||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onTest,
|
onTest,
|
||||||
|
onOpenTerminal,
|
||||||
isTesting,
|
isTesting,
|
||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
isProxyTakeover = false,
|
isProxyTakeover = false,
|
||||||
@@ -147,6 +149,10 @@ export function ProviderCard({
|
|||||||
// 多套餐默认展开
|
// 多套餐默认展开
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
// 操作按钮容器 ref,用于动态计算宽度
|
||||||
|
const actionsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [actionsWidth, setActionsWidth] = useState(0);
|
||||||
|
|
||||||
// 当检测到多套餐时自动展开
|
// 当检测到多套餐时自动展开
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasMultiplePlans) {
|
if (hasMultiplePlans) {
|
||||||
@@ -154,6 +160,20 @@ export function ProviderCard({
|
|||||||
}
|
}
|
||||||
}, [hasMultiplePlans]);
|
}, [hasMultiplePlans]);
|
||||||
|
|
||||||
|
// 动态获取操作按钮宽度
|
||||||
|
useEffect(() => {
|
||||||
|
if (actionsRef.current) {
|
||||||
|
const updateWidth = () => {
|
||||||
|
const width = actionsRef.current?.offsetWidth || 0;
|
||||||
|
setActionsWidth(width);
|
||||||
|
};
|
||||||
|
updateWidth();
|
||||||
|
// 监听窗口大小变化
|
||||||
|
window.addEventListener("resize", updateWidth);
|
||||||
|
return () => window.removeEventListener("resize", updateWidth);
|
||||||
|
}
|
||||||
|
}, [onTest, onOpenTerminal]); // 按钮数量可能变化时重新计算
|
||||||
|
|
||||||
const handleOpenWebsite = () => {
|
const handleOpenWebsite = () => {
|
||||||
if (!isClickableUrl) {
|
if (!isClickableUrl) {
|
||||||
return;
|
return;
|
||||||
@@ -279,10 +299,17 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex items-center ml-auto min-w-0">
|
<div
|
||||||
|
className="relative flex items-center ml-auto min-w-0 gap-3"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--actions-width": `${actionsWidth || 320}px`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
||||||
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[14.5rem] group-focus-within:-translate-x-[14.5rem] sm:group-hover:-translate-x-[16rem] sm:group-focus-within:-translate-x-[16rem]">
|
<div className="ml-auto">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]">
|
||||||
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
|
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
|
||||||
{hasMultiplePlans ? (
|
{hasMultiplePlans ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
@@ -327,8 +354,11 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入 */}
|
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
|
||||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
|
<div
|
||||||
|
ref={actionsRef}
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
|
||||||
|
>
|
||||||
<ProviderActions
|
<ProviderActions
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
@@ -339,6 +369,9 @@ export function ProviderCard({
|
|||||||
onTest={onTest ? () => onTest(provider) : undefined}
|
onTest={onTest ? () => onTest(provider) : undefined}
|
||||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||||
onDelete={() => onDelete(provider)}
|
onDelete={() => onDelete(provider)}
|
||||||
|
onOpenTerminal={
|
||||||
|
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
|
||||||
|
}
|
||||||
// 故障转移相关
|
// 故障转移相关
|
||||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||||
isInFailoverQueue={isInFailoverQueue}
|
isInFailoverQueue={isInFailoverQueue}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface ProviderListProps {
|
|||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onConfigureUsage?: (provider: Provider) => void;
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
|
onOpenTerminal?: (provider: Provider) => void;
|
||||||
onCreate?: () => void;
|
onCreate?: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isProxyRunning?: boolean; // 代理服务运行状态
|
isProxyRunning?: boolean; // 代理服务运行状态
|
||||||
@@ -58,6 +59,7 @@ export function ProviderList({
|
|||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
|
onOpenTerminal,
|
||||||
onCreate,
|
onCreate,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isProxyRunning = false,
|
isProxyRunning = false,
|
||||||
@@ -203,6 +205,7 @@ export function ProviderList({
|
|||||||
onDuplicate={onDuplicate}
|
onDuplicate={onDuplicate}
|
||||||
onConfigureUsage={onConfigureUsage}
|
onConfigureUsage={onConfigureUsage}
|
||||||
onOpenWebsite={onOpenWebsite}
|
onOpenWebsite={onOpenWebsite}
|
||||||
|
onOpenTerminal={onOpenTerminal}
|
||||||
onTest={handleTest}
|
onTest={handleTest}
|
||||||
isTesting={isChecking(provider.id)}
|
isTesting={isChecking(provider.id)}
|
||||||
isProxyRunning={isProxyRunning}
|
isProxyRunning={isProxyRunning}
|
||||||
@@ -311,6 +314,7 @@ interface SortableProviderCardProps {
|
|||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onConfigureUsage?: (provider: Provider) => void;
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
|
onOpenTerminal?: (provider: Provider) => void;
|
||||||
onTest: (provider: Provider) => void;
|
onTest: (provider: Provider) => void;
|
||||||
isTesting: boolean;
|
isTesting: boolean;
|
||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
@@ -333,6 +337,7 @@ function SortableProviderCard({
|
|||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
|
onOpenTerminal,
|
||||||
onTest,
|
onTest,
|
||||||
isTesting,
|
isTesting,
|
||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
@@ -371,6 +376,7 @@ function SortableProviderCard({
|
|||||||
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
||||||
}
|
}
|
||||||
onOpenWebsite={onOpenWebsite}
|
onOpenWebsite={onOpenWebsite}
|
||||||
|
onOpenTerminal={onOpenTerminal}
|
||||||
onTest={onTest}
|
onTest={onTest}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
isProxyRunning={isProxyRunning}
|
isProxyRunning={isProxyRunning}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ interface ClaudeFormFieldsProps {
|
|||||||
isEndpointModalOpen: boolean;
|
isEndpointModalOpen: boolean;
|
||||||
onEndpointModalToggle: (open: boolean) => void;
|
onEndpointModalToggle: (open: boolean) => void;
|
||||||
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||||
|
autoSelect: boolean;
|
||||||
|
onAutoSelectChange: (checked: boolean) => void;
|
||||||
|
|
||||||
// Model Selector
|
// Model Selector
|
||||||
shouldShowModelSelector: boolean;
|
shouldShowModelSelector: boolean;
|
||||||
@@ -83,6 +85,8 @@ export function ClaudeFormFields({
|
|||||||
isEndpointModalOpen,
|
isEndpointModalOpen,
|
||||||
onEndpointModalToggle,
|
onEndpointModalToggle,
|
||||||
onCustomEndpointsChange,
|
onCustomEndpointsChange,
|
||||||
|
autoSelect,
|
||||||
|
onAutoSelectChange,
|
||||||
shouldShowModelSelector,
|
shouldShowModelSelector,
|
||||||
claudeModel,
|
claudeModel,
|
||||||
reasoningModel,
|
reasoningModel,
|
||||||
@@ -170,6 +174,8 @@ export function ClaudeFormFields({
|
|||||||
initialEndpoints={speedTestEndpoints}
|
initialEndpoints={speedTestEndpoints}
|
||||||
visible={isEndpointModalOpen}
|
visible={isEndpointModalOpen}
|
||||||
onClose={() => onEndpointModalToggle(false)}
|
onClose={() => onEndpointModalToggle(false)}
|
||||||
|
autoSelect={autoSelect}
|
||||||
|
onAutoSelectChange={onAutoSelectChange}
|
||||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface CodexFormFieldsProps {
|
|||||||
isEndpointModalOpen: boolean;
|
isEndpointModalOpen: boolean;
|
||||||
onEndpointModalToggle: (open: boolean) => void;
|
onEndpointModalToggle: (open: boolean) => void;
|
||||||
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||||
|
autoSelect: boolean;
|
||||||
|
onAutoSelectChange: (checked: boolean) => void;
|
||||||
|
|
||||||
// Model Name
|
// Model Name
|
||||||
shouldShowModelField?: boolean;
|
shouldShowModelField?: boolean;
|
||||||
@@ -50,6 +52,8 @@ export function CodexFormFields({
|
|||||||
isEndpointModalOpen,
|
isEndpointModalOpen,
|
||||||
onEndpointModalToggle,
|
onEndpointModalToggle,
|
||||||
onCustomEndpointsChange,
|
onCustomEndpointsChange,
|
||||||
|
autoSelect,
|
||||||
|
onAutoSelectChange,
|
||||||
shouldShowModelField = true,
|
shouldShowModelField = true,
|
||||||
modelName = "",
|
modelName = "",
|
||||||
onModelNameChange,
|
onModelNameChange,
|
||||||
@@ -130,6 +134,8 @@ export function CodexFormFields({
|
|||||||
initialEndpoints={speedTestEndpoints}
|
initialEndpoints={speedTestEndpoints}
|
||||||
visible={isEndpointModalOpen}
|
visible={isEndpointModalOpen}
|
||||||
onClose={() => onEndpointModalToggle(false)}
|
onClose={() => onEndpointModalToggle(false)}
|
||||||
|
autoSelect={autoSelect}
|
||||||
|
onAutoSelectChange={onAutoSelectChange}
|
||||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ interface EndpointSpeedTestProps {
|
|||||||
initialEndpoints: EndpointCandidate[];
|
initialEndpoints: EndpointCandidate[];
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
autoSelect: boolean;
|
||||||
|
onAutoSelectChange: (checked: boolean) => void;
|
||||||
// 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目)
|
// 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目)
|
||||||
// 编辑模式:不使用此回调,端点直接保存到后端
|
// 编辑模式:不使用此回调,端点直接保存到后端
|
||||||
onCustomEndpointsChange?: (urls: string[]) => void;
|
onCustomEndpointsChange?: (urls: string[]) => void;
|
||||||
@@ -85,6 +87,8 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
initialEndpoints,
|
initialEndpoints,
|
||||||
visible = true,
|
visible = true,
|
||||||
onClose,
|
onClose,
|
||||||
|
autoSelect,
|
||||||
|
onAutoSelectChange,
|
||||||
onCustomEndpointsChange,
|
onCustomEndpointsChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -93,7 +97,6 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
);
|
);
|
||||||
const [customUrl, setCustomUrl] = useState("");
|
const [customUrl, setCustomUrl] = useState("");
|
||||||
const [addError, setAddError] = useState<string | null>(null);
|
const [addError, setAddError] = useState<string | null>(null);
|
||||||
const [autoSelect, setAutoSelect] = useState(true);
|
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [lastError, setLastError] = useState<string | null>(null);
|
const [lastError, setLastError] = useState<string | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -488,7 +491,9 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={autoSelect}
|
checked={autoSelect}
|
||||||
onChange={(event) => setAutoSelect(event.target.checked)}
|
onChange={(event) => {
|
||||||
|
onAutoSelectChange(event.target.checked);
|
||||||
|
}}
|
||||||
className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20"
|
className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20"
|
||||||
/>
|
/>
|
||||||
{t("endpointTest.autoSelect")}
|
{t("endpointTest.autoSelect")}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ interface GeminiFormFieldsProps {
|
|||||||
isEndpointModalOpen: boolean;
|
isEndpointModalOpen: boolean;
|
||||||
onEndpointModalToggle: (open: boolean) => void;
|
onEndpointModalToggle: (open: boolean) => void;
|
||||||
onCustomEndpointsChange: (endpoints: string[]) => void;
|
onCustomEndpointsChange: (endpoints: string[]) => void;
|
||||||
|
autoSelect: boolean;
|
||||||
|
onAutoSelectChange: (checked: boolean) => void;
|
||||||
|
|
||||||
// Model
|
// Model
|
||||||
shouldShowModelField: boolean;
|
shouldShowModelField: boolean;
|
||||||
@@ -55,6 +57,8 @@ export function GeminiFormFields({
|
|||||||
isEndpointModalOpen,
|
isEndpointModalOpen,
|
||||||
onEndpointModalToggle,
|
onEndpointModalToggle,
|
||||||
onCustomEndpointsChange,
|
onCustomEndpointsChange,
|
||||||
|
autoSelect,
|
||||||
|
onAutoSelectChange,
|
||||||
shouldShowModelField,
|
shouldShowModelField,
|
||||||
model,
|
model,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
@@ -142,6 +146,8 @@ export function GeminiFormFields({
|
|||||||
initialEndpoints={speedTestEndpoints}
|
initialEndpoints={speedTestEndpoints}
|
||||||
visible={isEndpointModalOpen}
|
visible={isEndpointModalOpen}
|
||||||
onClose={() => onEndpointModalToggle(false)}
|
onClose={() => onEndpointModalToggle(false)}
|
||||||
|
autoSelect={autoSelect}
|
||||||
|
onAutoSelectChange={onAutoSelectChange}
|
||||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ export function ProviderForm({
|
|||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
|
||||||
|
() => initialData?.meta?.endpointAutoSelect ?? true,
|
||||||
|
);
|
||||||
|
|
||||||
// 使用 category hook
|
// 使用 category hook
|
||||||
const { category } = useProviderCategory({
|
const { category } = useProviderCategory({
|
||||||
@@ -141,6 +144,7 @@ export function ProviderForm({
|
|||||||
if (!initialData) {
|
if (!initialData) {
|
||||||
setDraftCustomEndpoints([]);
|
setDraftCustomEndpoints([]);
|
||||||
}
|
}
|
||||||
|
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
|
||||||
}, [appId, initialData]);
|
}, [appId, initialData]);
|
||||||
|
|
||||||
const defaultValues: ProviderFormData = useMemo(
|
const defaultValues: ProviderFormData = useMemo(
|
||||||
@@ -236,7 +240,7 @@ export function ProviderForm({
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
return true;
|
return false; // OpenRouter now supports Claude Code compatible API, no need for transform
|
||||||
}, [isOpenRouterProvider, settingsConfigValue]);
|
}, [isOpenRouterProvider, settingsConfigValue]);
|
||||||
|
|
||||||
const handleOpenRouterCompatChange = useCallback(
|
const handleOpenRouterCompatChange = useCallback(
|
||||||
@@ -647,6 +651,13 @@ export function ProviderForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseMeta: ProviderMeta | undefined =
|
||||||
|
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
|
||||||
|
payload.meta = {
|
||||||
|
...(baseMeta ?? {}),
|
||||||
|
endpointAutoSelect,
|
||||||
|
};
|
||||||
|
|
||||||
onSubmit(payload);
|
onSubmit(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -856,6 +867,8 @@ export function ProviderForm({
|
|||||||
onCustomEndpointsChange={
|
onCustomEndpointsChange={
|
||||||
isEditMode ? undefined : setDraftCustomEndpoints
|
isEditMode ? undefined : setDraftCustomEndpoints
|
||||||
}
|
}
|
||||||
|
autoSelect={endpointAutoSelect}
|
||||||
|
onAutoSelectChange={setEndpointAutoSelect}
|
||||||
shouldShowModelSelector={category !== "official"}
|
shouldShowModelSelector={category !== "official"}
|
||||||
claudeModel={claudeModel}
|
claudeModel={claudeModel}
|
||||||
reasoningModel={reasoningModel}
|
reasoningModel={reasoningModel}
|
||||||
@@ -864,7 +877,7 @@ export function ProviderForm({
|
|||||||
defaultOpusModel={defaultOpusModel}
|
defaultOpusModel={defaultOpusModel}
|
||||||
onModelChange={handleModelChange}
|
onModelChange={handleModelChange}
|
||||||
speedTestEndpoints={speedTestEndpoints}
|
speedTestEndpoints={speedTestEndpoints}
|
||||||
showOpenRouterCompatToggle={isOpenRouterProvider}
|
showOpenRouterCompatToggle={false}
|
||||||
openRouterCompatEnabled={openRouterCompatEnabled}
|
openRouterCompatEnabled={openRouterCompatEnabled}
|
||||||
onOpenRouterCompatChange={handleOpenRouterCompatChange}
|
onOpenRouterCompatChange={handleOpenRouterCompatChange}
|
||||||
/>
|
/>
|
||||||
@@ -889,6 +902,8 @@ export function ProviderForm({
|
|||||||
onCustomEndpointsChange={
|
onCustomEndpointsChange={
|
||||||
isEditMode ? undefined : setDraftCustomEndpoints
|
isEditMode ? undefined : setDraftCustomEndpoints
|
||||||
}
|
}
|
||||||
|
autoSelect={endpointAutoSelect}
|
||||||
|
onAutoSelectChange={setEndpointAutoSelect}
|
||||||
shouldShowModelField={category !== "official"}
|
shouldShowModelField={category !== "official"}
|
||||||
modelName={codexModelName}
|
modelName={codexModelName}
|
||||||
onModelNameChange={handleCodexModelNameChange}
|
onModelNameChange={handleCodexModelNameChange}
|
||||||
@@ -917,6 +932,8 @@ export function ProviderForm({
|
|||||||
isEndpointModalOpen={isEndpointModalOpen}
|
isEndpointModalOpen={isEndpointModalOpen}
|
||||||
onEndpointModalToggle={setIsEndpointModalOpen}
|
onEndpointModalToggle={setIsEndpointModalOpen}
|
||||||
onCustomEndpointsChange={setDraftCustomEndpoints}
|
onCustomEndpointsChange={setDraftCustomEndpoints}
|
||||||
|
autoSelect={endpointAutoSelect}
|
||||||
|
onAutoSelectChange={setEndpointAutoSelect}
|
||||||
shouldShowModelField={true}
|
shouldShowModelField={true}
|
||||||
model={geminiModel}
|
model={geminiModel}
|
||||||
onModelChange={handleGeminiModelChange}
|
onModelChange={handleGeminiModelChange}
|
||||||
|
|||||||
@@ -43,11 +43,7 @@ export function useApiKeyState({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仅当配置确实包含 API Key 字段时才同步(避免无意清空用户正在输入的 key)
|
// 从配置中提取 API Key(如果不存在则返回空字符串)
|
||||||
if (!hasApiKeyField(initialConfig, appType)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const extracted = getApiKeyFromConfig(initialConfig, appType);
|
const extracted = getApiKeyFromConfig(initialConfig, appType);
|
||||||
if (extracted !== apiKey) {
|
if (extracted !== apiKey) {
|
||||||
setApiKey(extracted);
|
setApiKey(extracted);
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ export function useBaseUrlState({
|
|||||||
try {
|
try {
|
||||||
const config = JSON.parse(settingsConfig || "{}");
|
const config = JSON.parse(settingsConfig || "{}");
|
||||||
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
|
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
|
||||||
if (typeof envUrl === "string" && envUrl && envUrl.trim() !== baseUrl) {
|
const nextUrl = typeof envUrl === "string" ? envUrl.trim() : "";
|
||||||
setBaseUrl(envUrl.trim());
|
if (nextUrl !== baseUrl) {
|
||||||
|
setBaseUrl(nextUrl);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|||||||
@@ -21,52 +21,157 @@ export function AutoFailoverConfigPanel({
|
|||||||
const { data: config, isLoading, error } = useAppProxyConfig(appType);
|
const { data: config, isLoading, error } = useAppProxyConfig(appType);
|
||||||
const updateConfig = useUpdateAppProxyConfig();
|
const updateConfig = useUpdateAppProxyConfig();
|
||||||
|
|
||||||
|
// 使用字符串状态以支持完全清空数字输入框
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
autoFailoverEnabled: false,
|
autoFailoverEnabled: false,
|
||||||
maxRetries: 3,
|
maxRetries: "3",
|
||||||
streamingFirstByteTimeout: 30,
|
streamingFirstByteTimeout: "30",
|
||||||
streamingIdleTimeout: 60,
|
streamingIdleTimeout: "60",
|
||||||
nonStreamingTimeout: 300,
|
nonStreamingTimeout: "300",
|
||||||
circuitFailureThreshold: 5,
|
circuitFailureThreshold: "5",
|
||||||
circuitSuccessThreshold: 2,
|
circuitSuccessThreshold: "2",
|
||||||
circuitTimeoutSeconds: 60,
|
circuitTimeoutSeconds: "60",
|
||||||
circuitErrorRateThreshold: 0.5,
|
circuitErrorRateThreshold: "50", // 存储百分比值
|
||||||
circuitMinRequests: 10,
|
circuitMinRequests: "10",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config) {
|
if (config) {
|
||||||
setFormData({
|
setFormData({
|
||||||
autoFailoverEnabled: config.autoFailoverEnabled,
|
autoFailoverEnabled: config.autoFailoverEnabled,
|
||||||
maxRetries: config.maxRetries,
|
maxRetries: String(config.maxRetries),
|
||||||
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
|
streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),
|
||||||
streamingIdleTimeout: config.streamingIdleTimeout,
|
streamingIdleTimeout: String(config.streamingIdleTimeout),
|
||||||
nonStreamingTimeout: config.nonStreamingTimeout,
|
nonStreamingTimeout: String(config.nonStreamingTimeout),
|
||||||
circuitFailureThreshold: config.circuitFailureThreshold,
|
circuitFailureThreshold: String(config.circuitFailureThreshold),
|
||||||
circuitSuccessThreshold: config.circuitSuccessThreshold,
|
circuitSuccessThreshold: String(config.circuitSuccessThreshold),
|
||||||
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
|
circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),
|
||||||
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
|
circuitErrorRateThreshold: String(
|
||||||
circuitMinRequests: config.circuitMinRequests,
|
Math.round(config.circuitErrorRateThreshold * 100),
|
||||||
|
),
|
||||||
|
circuitMinRequests: String(config.circuitMinRequests),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
|
// 解析数字,返回 NaN 表示无效输入
|
||||||
|
const parseNum = (val: string) => {
|
||||||
|
const trimmed = val.trim();
|
||||||
|
// 必须是纯数字
|
||||||
|
if (!/^-?\d+$/.test(trimmed)) return NaN;
|
||||||
|
return parseInt(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 定义各字段的有效范围
|
||||||
|
const ranges = {
|
||||||
|
maxRetries: { min: 0, max: 10 },
|
||||||
|
streamingFirstByteTimeout: { min: 0, max: 180 },
|
||||||
|
streamingIdleTimeout: { min: 0, max: 600 },
|
||||||
|
nonStreamingTimeout: { min: 0, max: 1800 },
|
||||||
|
circuitFailureThreshold: { min: 1, max: 20 },
|
||||||
|
circuitSuccessThreshold: { min: 1, max: 10 },
|
||||||
|
circuitTimeoutSeconds: { min: 0, max: 300 },
|
||||||
|
circuitErrorRateThreshold: { min: 0, max: 100 },
|
||||||
|
circuitMinRequests: { min: 5, max: 100 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解析原始值
|
||||||
|
const raw = {
|
||||||
|
maxRetries: parseNum(formData.maxRetries),
|
||||||
|
streamingFirstByteTimeout: parseNum(formData.streamingFirstByteTimeout),
|
||||||
|
streamingIdleTimeout: parseNum(formData.streamingIdleTimeout),
|
||||||
|
nonStreamingTimeout: parseNum(formData.nonStreamingTimeout),
|
||||||
|
circuitFailureThreshold: parseNum(formData.circuitFailureThreshold),
|
||||||
|
circuitSuccessThreshold: parseNum(formData.circuitSuccessThreshold),
|
||||||
|
circuitTimeoutSeconds: parseNum(formData.circuitTimeoutSeconds),
|
||||||
|
circuitErrorRateThreshold: parseNum(formData.circuitErrorRateThreshold),
|
||||||
|
circuitMinRequests: parseNum(formData.circuitMinRequests),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 校验是否超出范围(NaN 也视为无效)
|
||||||
|
const errors: string[] = [];
|
||||||
|
const checkRange = (
|
||||||
|
value: number,
|
||||||
|
range: { min: number; max: number },
|
||||||
|
label: string,
|
||||||
|
) => {
|
||||||
|
if (isNaN(value) || value < range.min || value > range.max) {
|
||||||
|
errors.push(`${label}: ${range.min}-${range.max}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkRange(
|
||||||
|
raw.maxRetries,
|
||||||
|
ranges.maxRetries,
|
||||||
|
t("proxy.autoFailover.maxRetries", "最大重试次数"),
|
||||||
|
);
|
||||||
|
checkRange(
|
||||||
|
raw.streamingFirstByteTimeout,
|
||||||
|
ranges.streamingFirstByteTimeout,
|
||||||
|
t("proxy.autoFailover.streamingFirstByte", "流式首字节超时"),
|
||||||
|
);
|
||||||
|
checkRange(
|
||||||
|
raw.streamingIdleTimeout,
|
||||||
|
ranges.streamingIdleTimeout,
|
||||||
|
t("proxy.autoFailover.streamingIdle", "流式静默超时"),
|
||||||
|
);
|
||||||
|
checkRange(
|
||||||
|
raw.nonStreamingTimeout,
|
||||||
|
ranges.nonStreamingTimeout,
|
||||||
|
t("proxy.autoFailover.nonStreaming", "非流式超时"),
|
||||||
|
);
|
||||||
|
checkRange(
|
||||||
|
raw.circuitFailureThreshold,
|
||||||
|
ranges.circuitFailureThreshold,
|
||||||
|
t("proxy.autoFailover.failureThreshold", "失败阈值"),
|
||||||
|
);
|
||||||
|
checkRange(
|
||||||
|
raw.circuitSuccessThreshold,
|
||||||
|
ranges.circuitSuccessThreshold,
|
||||||
|
t("proxy.autoFailover.successThreshold", "恢复成功阈值"),
|
||||||
|
);
|
||||||
|
checkRange(
|
||||||
|
raw.circuitTimeoutSeconds,
|
||||||
|
ranges.circuitTimeoutSeconds,
|
||||||
|
t("proxy.autoFailover.timeout", "恢复等待时间"),
|
||||||
|
);
|
||||||
|
checkRange(
|
||||||
|
raw.circuitErrorRateThreshold,
|
||||||
|
ranges.circuitErrorRateThreshold,
|
||||||
|
t("proxy.autoFailover.errorRate", "错误率阈值"),
|
||||||
|
);
|
||||||
|
checkRange(
|
||||||
|
raw.circuitMinRequests,
|
||||||
|
ranges.circuitMinRequests,
|
||||||
|
t("proxy.autoFailover.minRequests", "最小请求数"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
toast.error(
|
||||||
|
t("proxy.autoFailover.validationFailed", {
|
||||||
|
fields: errors.join("; "),
|
||||||
|
defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateConfig.mutateAsync({
|
await updateConfig.mutateAsync({
|
||||||
appType,
|
appType,
|
||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
autoFailoverEnabled: formData.autoFailoverEnabled,
|
autoFailoverEnabled: formData.autoFailoverEnabled,
|
||||||
maxRetries: formData.maxRetries,
|
maxRetries: raw.maxRetries,
|
||||||
streamingFirstByteTimeout: formData.streamingFirstByteTimeout,
|
streamingFirstByteTimeout: raw.streamingFirstByteTimeout,
|
||||||
streamingIdleTimeout: formData.streamingIdleTimeout,
|
streamingIdleTimeout: raw.streamingIdleTimeout,
|
||||||
nonStreamingTimeout: formData.nonStreamingTimeout,
|
nonStreamingTimeout: raw.nonStreamingTimeout,
|
||||||
circuitFailureThreshold: formData.circuitFailureThreshold,
|
circuitFailureThreshold: raw.circuitFailureThreshold,
|
||||||
circuitSuccessThreshold: formData.circuitSuccessThreshold,
|
circuitSuccessThreshold: raw.circuitSuccessThreshold,
|
||||||
circuitTimeoutSeconds: formData.circuitTimeoutSeconds,
|
circuitTimeoutSeconds: raw.circuitTimeoutSeconds,
|
||||||
circuitErrorRateThreshold: formData.circuitErrorRateThreshold,
|
circuitErrorRateThreshold: raw.circuitErrorRateThreshold / 100,
|
||||||
circuitMinRequests: formData.circuitMinRequests,
|
circuitMinRequests: raw.circuitMinRequests,
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
|
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
|
||||||
@@ -83,15 +188,17 @@ export function AutoFailoverConfigPanel({
|
|||||||
if (config) {
|
if (config) {
|
||||||
setFormData({
|
setFormData({
|
||||||
autoFailoverEnabled: config.autoFailoverEnabled,
|
autoFailoverEnabled: config.autoFailoverEnabled,
|
||||||
maxRetries: config.maxRetries,
|
maxRetries: String(config.maxRetries),
|
||||||
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
|
streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),
|
||||||
streamingIdleTimeout: config.streamingIdleTimeout,
|
streamingIdleTimeout: String(config.streamingIdleTimeout),
|
||||||
nonStreamingTimeout: config.nonStreamingTimeout,
|
nonStreamingTimeout: String(config.nonStreamingTimeout),
|
||||||
circuitFailureThreshold: config.circuitFailureThreshold,
|
circuitFailureThreshold: String(config.circuitFailureThreshold),
|
||||||
circuitSuccessThreshold: config.circuitSuccessThreshold,
|
circuitSuccessThreshold: String(config.circuitSuccessThreshold),
|
||||||
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
|
circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),
|
||||||
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
|
circuitErrorRateThreshold: String(
|
||||||
circuitMinRequests: config.circuitMinRequests,
|
Math.round(config.circuitErrorRateThreshold * 100),
|
||||||
|
),
|
||||||
|
circuitMinRequests: String(config.circuitMinRequests),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -142,13 +249,9 @@ export function AutoFailoverConfigPanel({
|
|||||||
min="0"
|
min="0"
|
||||||
max="10"
|
max="10"
|
||||||
value={formData.maxRetries}
|
value={formData.maxRetries}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
const val = parseInt(e.target.value);
|
setFormData({ ...formData, maxRetries: e.target.value })
|
||||||
setFormData({
|
}
|
||||||
...formData,
|
|
||||||
maxRetries: isNaN(val) ? 0 : val,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -169,13 +272,12 @@ export function AutoFailoverConfigPanel({
|
|||||||
min="1"
|
min="1"
|
||||||
max="20"
|
max="20"
|
||||||
value={formData.circuitFailureThreshold}
|
value={formData.circuitFailureThreshold}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
const val = parseInt(e.target.value);
|
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
circuitFailureThreshold: isNaN(val) ? 1 : Math.max(1, val),
|
circuitFailureThreshold: e.target.value,
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -208,13 +310,12 @@ export function AutoFailoverConfigPanel({
|
|||||||
min="0"
|
min="0"
|
||||||
max="180"
|
max="180"
|
||||||
value={formData.streamingFirstByteTimeout}
|
value={formData.streamingFirstByteTimeout}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
const val = parseInt(e.target.value);
|
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
streamingFirstByteTimeout: isNaN(val) ? 0 : val,
|
streamingFirstByteTimeout: e.target.value,
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -235,13 +336,12 @@ export function AutoFailoverConfigPanel({
|
|||||||
min="0"
|
min="0"
|
||||||
max="600"
|
max="600"
|
||||||
value={formData.streamingIdleTimeout}
|
value={formData.streamingIdleTimeout}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
const val = parseInt(e.target.value);
|
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
streamingIdleTimeout: isNaN(val) ? 0 : val,
|
streamingIdleTimeout: e.target.value,
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -262,13 +362,12 @@ export function AutoFailoverConfigPanel({
|
|||||||
min="0"
|
min="0"
|
||||||
max="1800"
|
max="1800"
|
||||||
value={formData.nonStreamingTimeout}
|
value={formData.nonStreamingTimeout}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
const val = parseInt(e.target.value);
|
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
nonStreamingTimeout: isNaN(val) ? 0 : val,
|
nonStreamingTimeout: e.target.value,
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -298,13 +397,12 @@ export function AutoFailoverConfigPanel({
|
|||||||
min="1"
|
min="1"
|
||||||
max="10"
|
max="10"
|
||||||
value={formData.circuitSuccessThreshold}
|
value={formData.circuitSuccessThreshold}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
const val = parseInt(e.target.value);
|
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
circuitSuccessThreshold: isNaN(val) ? 1 : Math.max(1, val),
|
circuitSuccessThreshold: e.target.value,
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -322,16 +420,15 @@ export function AutoFailoverConfigPanel({
|
|||||||
<Input
|
<Input
|
||||||
id={`timeoutSeconds-${appType}`}
|
id={`timeoutSeconds-${appType}`}
|
||||||
type="number"
|
type="number"
|
||||||
min="10"
|
min="0"
|
||||||
max="300"
|
max="300"
|
||||||
value={formData.circuitTimeoutSeconds}
|
value={formData.circuitTimeoutSeconds}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
const val = parseInt(e.target.value);
|
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
circuitTimeoutSeconds: isNaN(val) ? 10 : Math.max(10, val),
|
circuitTimeoutSeconds: e.target.value,
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -352,14 +449,13 @@ export function AutoFailoverConfigPanel({
|
|||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
step="5"
|
step="5"
|
||||||
value={Math.round(formData.circuitErrorRateThreshold * 100)}
|
value={formData.circuitErrorRateThreshold}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
const val = parseInt(e.target.value);
|
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
circuitErrorRateThreshold: isNaN(val) ? 0.5 : val / 100,
|
circuitErrorRateThreshold: e.target.value,
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -380,13 +476,12 @@ export function AutoFailoverConfigPanel({
|
|||||||
min="5"
|
min="5"
|
||||||
max="100"
|
max="100"
|
||||||
value={formData.circuitMinRequests}
|
value={formData.circuitMinRequests}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
const val = parseInt(e.target.value);
|
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
circuitMinRequests: isNaN(val) ? 5 : Math.max(5, val),
|
circuitMinRequests: e.target.value,
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -7,42 +7,141 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 熔断器配置面板
|
* 熔断器配置面板
|
||||||
* 允许用户调整熔断器参数
|
* 允许用户调整熔断器参数
|
||||||
*/
|
*/
|
||||||
export function CircuitBreakerConfigPanel() {
|
export function CircuitBreakerConfigPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: config, isLoading } = useCircuitBreakerConfig();
|
const { data: config, isLoading } = useCircuitBreakerConfig();
|
||||||
const updateConfig = useUpdateCircuitBreakerConfig();
|
const updateConfig = useUpdateCircuitBreakerConfig();
|
||||||
|
|
||||||
|
// 使用字符串状态以支持完全清空输入框
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
failureThreshold: 5,
|
failureThreshold: "5",
|
||||||
successThreshold: 2,
|
successThreshold: "2",
|
||||||
timeoutSeconds: 60,
|
timeoutSeconds: "60",
|
||||||
errorRateThreshold: 0.5,
|
errorRateThreshold: "50", // 存储百分比值
|
||||||
minRequests: 10,
|
minRequests: "10",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 当配置加载完成时更新表单数据
|
// 当配置加载完成时更新表单数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config) {
|
if (config) {
|
||||||
setFormData(config);
|
setFormData({
|
||||||
|
failureThreshold: String(config.failureThreshold),
|
||||||
|
successThreshold: String(config.successThreshold),
|
||||||
|
timeoutSeconds: String(config.timeoutSeconds),
|
||||||
|
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
|
||||||
|
minRequests: String(config.minRequests),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
// 解析数字,返回 NaN 表示无效输入
|
||||||
|
const parseNum = (val: string) => {
|
||||||
|
const trimmed = val.trim();
|
||||||
|
// 必须是纯数字
|
||||||
|
if (!/^-?\d+$/.test(trimmed)) return NaN;
|
||||||
|
return parseInt(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 定义各字段的有效范围
|
||||||
|
const ranges = {
|
||||||
|
failureThreshold: { min: 1, max: 20 },
|
||||||
|
successThreshold: { min: 1, max: 10 },
|
||||||
|
timeoutSeconds: { min: 0, max: 300 },
|
||||||
|
errorRateThreshold: { min: 0, max: 100 },
|
||||||
|
minRequests: { min: 5, max: 100 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解析原始值
|
||||||
|
const raw = {
|
||||||
|
failureThreshold: parseNum(formData.failureThreshold),
|
||||||
|
successThreshold: parseNum(formData.successThreshold),
|
||||||
|
timeoutSeconds: parseNum(formData.timeoutSeconds),
|
||||||
|
errorRateThreshold: parseNum(formData.errorRateThreshold),
|
||||||
|
minRequests: parseNum(formData.minRequests),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 校验是否超出范围(NaN 也视为无效)
|
||||||
|
const errors: string[] = [];
|
||||||
|
const checkRange = (
|
||||||
|
value: number,
|
||||||
|
range: { min: number; max: number },
|
||||||
|
label: string,
|
||||||
|
) => {
|
||||||
|
if (isNaN(value) || value < range.min || value > range.max) {
|
||||||
|
errors.push(`${label}: ${range.min}-${range.max}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkRange(
|
||||||
|
raw.failureThreshold,
|
||||||
|
ranges.failureThreshold,
|
||||||
|
t("circuitBreaker.failureThreshold", "失败阈值"),
|
||||||
|
);
|
||||||
|
checkRange(
|
||||||
|
raw.successThreshold,
|
||||||
|
ranges.successThreshold,
|
||||||
|
t("circuitBreaker.successThreshold", "成功阈值"),
|
||||||
|
);
|
||||||
|
checkRange(
|
||||||
|
raw.timeoutSeconds,
|
||||||
|
ranges.timeoutSeconds,
|
||||||
|
t("circuitBreaker.timeoutSeconds", "超时时间"),
|
||||||
|
);
|
||||||
|
checkRange(
|
||||||
|
raw.errorRateThreshold,
|
||||||
|
ranges.errorRateThreshold,
|
||||||
|
t("circuitBreaker.errorRateThreshold", "错误率阈值"),
|
||||||
|
);
|
||||||
|
checkRange(
|
||||||
|
raw.minRequests,
|
||||||
|
ranges.minRequests,
|
||||||
|
t("circuitBreaker.minRequests", "最小请求数"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
toast.error(
|
||||||
|
t("circuitBreaker.validationFailed", {
|
||||||
|
fields: errors.join("; "),
|
||||||
|
defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateConfig.mutateAsync(formData);
|
await updateConfig.mutateAsync({
|
||||||
toast.success("熔断器配置已保存", { closeButton: true });
|
failureThreshold: raw.failureThreshold,
|
||||||
|
successThreshold: raw.successThreshold,
|
||||||
|
timeoutSeconds: raw.timeoutSeconds,
|
||||||
|
errorRateThreshold: raw.errorRateThreshold / 100,
|
||||||
|
minRequests: raw.minRequests,
|
||||||
|
});
|
||||||
|
toast.success(t("circuitBreaker.configSaved", "熔断器配置已保存"), {
|
||||||
|
closeButton: true,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("保存失败: " + String(error));
|
toast.error(
|
||||||
|
t("circuitBreaker.saveFailed", "保存失败") + ": " + String(error),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
if (config) {
|
if (config) {
|
||||||
setFormData(config);
|
setFormData({
|
||||||
|
failureThreshold: String(config.failureThreshold),
|
||||||
|
successThreshold: String(config.successThreshold),
|
||||||
|
timeoutSeconds: String(config.timeoutSeconds),
|
||||||
|
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
|
||||||
|
minRequests: String(config.minRequests),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,10 +171,7 @@ export function CircuitBreakerConfigPanel() {
|
|||||||
max="20"
|
max="20"
|
||||||
value={formData.failureThreshold}
|
value={formData.failureThreshold}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({ ...formData, failureThreshold: e.target.value })
|
||||||
...formData,
|
|
||||||
failureThreshold: parseInt(e.target.value) || 5,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -89,14 +185,11 @@ export function CircuitBreakerConfigPanel() {
|
|||||||
<Input
|
<Input
|
||||||
id="timeoutSeconds"
|
id="timeoutSeconds"
|
||||||
type="number"
|
type="number"
|
||||||
min="10"
|
min="0"
|
||||||
max="300"
|
max="300"
|
||||||
value={formData.timeoutSeconds}
|
value={formData.timeoutSeconds}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({ ...formData, timeoutSeconds: e.target.value })
|
||||||
...formData,
|
|
||||||
timeoutSeconds: parseInt(e.target.value) || 60,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -114,10 +207,7 @@ export function CircuitBreakerConfigPanel() {
|
|||||||
max="10"
|
max="10"
|
||||||
value={formData.successThreshold}
|
value={formData.successThreshold}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({ ...formData, successThreshold: e.target.value })
|
||||||
...formData,
|
|
||||||
successThreshold: parseInt(e.target.value) || 2,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -134,12 +224,9 @@ export function CircuitBreakerConfigPanel() {
|
|||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
step="5"
|
step="5"
|
||||||
value={Math.round(formData.errorRateThreshold * 100)}
|
value={formData.errorRateThreshold}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({ ...formData, errorRateThreshold: e.target.value })
|
||||||
...formData,
|
|
||||||
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -157,10 +244,7 @@ export function CircuitBreakerConfigPanel() {
|
|||||||
max="100"
|
max="100"
|
||||||
value={formData.minRequests}
|
value={formData.minRequests}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({ ...formData, minRequests: e.target.value })
|
||||||
...formData,
|
|
||||||
minRequests: parseInt(e.target.value) || 10,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -38,15 +38,15 @@ export function ProxyPanel() {
|
|||||||
const { data: globalConfig } = useGlobalProxyConfig();
|
const { data: globalConfig } = useGlobalProxyConfig();
|
||||||
const updateGlobalConfig = useUpdateGlobalProxyConfig();
|
const updateGlobalConfig = useUpdateGlobalProxyConfig();
|
||||||
|
|
||||||
// 监听地址/端口的本地状态
|
// 监听地址/端口的本地状态(端口用字符串以支持完全清空)
|
||||||
const [listenAddress, setListenAddress] = useState("127.0.0.1");
|
const [listenAddress, setListenAddress] = useState("127.0.0.1");
|
||||||
const [listenPort, setListenPort] = useState(5000);
|
const [listenPort, setListenPort] = useState("15721");
|
||||||
|
|
||||||
// 同步全局配置到本地状态
|
// 同步全局配置到本地状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (globalConfig) {
|
if (globalConfig) {
|
||||||
setListenAddress(globalConfig.listenAddress);
|
setListenAddress(globalConfig.listenAddress);
|
||||||
setListenPort(globalConfig.listenPort);
|
setListenPort(String(globalConfig.listenPort));
|
||||||
}
|
}
|
||||||
}, [globalConfig]);
|
}, [globalConfig]);
|
||||||
|
|
||||||
@@ -102,11 +102,52 @@ export function ProxyPanel() {
|
|||||||
|
|
||||||
const handleSaveBasicConfig = async () => {
|
const handleSaveBasicConfig = async () => {
|
||||||
if (!globalConfig) return;
|
if (!globalConfig) return;
|
||||||
|
|
||||||
|
// 校验地址格式(简单的 IP 地址或 localhost 校验)
|
||||||
|
const addressTrimmed = listenAddress.trim();
|
||||||
|
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||||
|
const isValidAddress =
|
||||||
|
addressTrimmed === "localhost" ||
|
||||||
|
addressTrimmed === "0.0.0.0" ||
|
||||||
|
(ipv4Regex.test(addressTrimmed) &&
|
||||||
|
addressTrimmed.split(".").every((n) => {
|
||||||
|
const num = parseInt(n);
|
||||||
|
return num >= 0 && num <= 255;
|
||||||
|
}));
|
||||||
|
if (!isValidAddress) {
|
||||||
|
toast.error(
|
||||||
|
t("proxy.settings.invalidAddress", {
|
||||||
|
defaultValue:
|
||||||
|
"地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 严格校验端口:必须是纯数字
|
||||||
|
const portTrimmed = listenPort.trim();
|
||||||
|
if (!/^\d+$/.test(portTrimmed)) {
|
||||||
|
toast.error(
|
||||||
|
t("proxy.settings.invalidPort", {
|
||||||
|
defaultValue: "端口无效,请输入 1024-65535 之间的数字",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = parseInt(portTrimmed);
|
||||||
|
if (isNaN(port) || port < 1024 || port > 65535) {
|
||||||
|
toast.error(
|
||||||
|
t("proxy.settings.invalidPort", {
|
||||||
|
defaultValue: "端口无效,请输入 1024-65535 之间的数字",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await updateGlobalConfig.mutateAsync({
|
await updateGlobalConfig.mutateAsync({
|
||||||
...globalConfig,
|
...globalConfig,
|
||||||
listenAddress,
|
listenAddress: addressTrimmed,
|
||||||
listenPort,
|
listenPort: port,
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
|
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
|
||||||
@@ -133,6 +174,13 @@ export function ProxyPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 格式化地址用于 URL(IPv6 需要方括号)
|
||||||
|
const formatAddressForUrl = (address: string, port: number): string => {
|
||||||
|
const isIPv6 = address.includes(":");
|
||||||
|
const host = isIPv6 ? `[${address}]` : address;
|
||||||
|
return `http://${host}:${port}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
@@ -147,14 +195,14 @@ export function ProxyPanel() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
|
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
|
||||||
http://{status.address}:{status.port}
|
{formatAddressForUrl(status.address, status.port)}
|
||||||
</code>
|
</code>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
`http://${status.address}:${status.port}`,
|
formatAddressForUrl(status.address, status.port),
|
||||||
);
|
);
|
||||||
toast.success(
|
toast.success(
|
||||||
t("proxy.panel.addressCopied", {
|
t("proxy.panel.addressCopied", {
|
||||||
@@ -389,7 +437,12 @@ export function ProxyPanel() {
|
|||||||
id="listen-address"
|
id="listen-address"
|
||||||
value={listenAddress}
|
value={listenAddress}
|
||||||
onChange={(e) => setListenAddress(e.target.value)}
|
onChange={(e) => setListenAddress(e.target.value)}
|
||||||
placeholder="127.0.0.1"
|
placeholder={t(
|
||||||
|
"proxy.settings.fields.listenAddress.placeholder",
|
||||||
|
{
|
||||||
|
defaultValue: "127.0.0.1",
|
||||||
|
},
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("proxy.settings.fields.listenAddress.description", {
|
{t("proxy.settings.fields.listenAddress.description", {
|
||||||
@@ -409,10 +462,13 @@ export function ProxyPanel() {
|
|||||||
id="listen-port"
|
id="listen-port"
|
||||||
type="number"
|
type="number"
|
||||||
value={listenPort}
|
value={listenPort}
|
||||||
onChange={(e) =>
|
onChange={(e) => setListenPort(e.target.value)}
|
||||||
setListenPort(parseInt(e.target.value) || 5000)
|
placeholder={t(
|
||||||
}
|
"proxy.settings.fields.listenPort.placeholder",
|
||||||
placeholder="5000"
|
{
|
||||||
|
defaultValue: "15721",
|
||||||
|
},
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("proxy.settings.fields.listenPort.description", {
|
{t("proxy.settings.fields.listenPort.description", {
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
|
|||||||
useProxyStatus();
|
useProxyStatus();
|
||||||
|
|
||||||
const handleToggle = async (checked: boolean) => {
|
const handleToggle = async (checked: boolean) => {
|
||||||
await setTakeoverForApp({ appType: activeApp, enabled: checked });
|
try {
|
||||||
|
await setTakeoverForApp({ appType: activeApp, enabled: checked });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ProxyToggle] Toggle takeover failed:", error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const takeoverEnabled = takeoverStatus?.[activeApp] || false;
|
const takeoverEnabled = takeoverStatus?.[activeApp] || false;
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* 全局出站代理设置组件
|
||||||
|
*
|
||||||
|
* 提供配置全局代理的输入界面,支持用户名密码认证。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2, TestTube2, Search, Eye, EyeOff, X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useGlobalProxyUrl,
|
||||||
|
useSetGlobalProxyUrl,
|
||||||
|
useTestProxy,
|
||||||
|
useScanProxies,
|
||||||
|
type DetectedProxy,
|
||||||
|
} from "@/hooks/useGlobalProxy";
|
||||||
|
|
||||||
|
/** 从完整 URL 提取认证信息 */
|
||||||
|
function extractAuth(url: string): {
|
||||||
|
baseUrl: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
} {
|
||||||
|
if (!url.trim()) return { baseUrl: "", username: "", password: "" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const username = decodeURIComponent(parsed.username || "");
|
||||||
|
const password = decodeURIComponent(parsed.password || "");
|
||||||
|
// 移除认证信息,获取基础 URL
|
||||||
|
parsed.username = "";
|
||||||
|
parsed.password = "";
|
||||||
|
return { baseUrl: parsed.toString(), username, password };
|
||||||
|
} catch {
|
||||||
|
return { baseUrl: url, username: "", password: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将认证信息合并到 URL */
|
||||||
|
function mergeAuth(
|
||||||
|
baseUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): string {
|
||||||
|
if (!baseUrl.trim()) return "";
|
||||||
|
if (!username.trim()) return baseUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(baseUrl);
|
||||||
|
// URL 对象的 username/password setter 会自动进行 percent-encoding
|
||||||
|
// 不要使用 encodeURIComponent,否则会导致双重编码
|
||||||
|
parsed.username = username.trim();
|
||||||
|
if (password) {
|
||||||
|
parsed.password = password;
|
||||||
|
}
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
// URL 解析失败,尝试手动插入(此时需要手动编码)
|
||||||
|
const match = baseUrl.match(/^(\w+:\/\/)(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
const auth = password
|
||||||
|
? `${encodeURIComponent(username.trim())}:${encodeURIComponent(password)}@`
|
||||||
|
: `${encodeURIComponent(username.trim())}@`;
|
||||||
|
return `${match[1]}${auth}${match[2]}`;
|
||||||
|
}
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobalProxySettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: savedUrl, isLoading } = useGlobalProxyUrl();
|
||||||
|
const setMutation = useSetGlobalProxyUrl();
|
||||||
|
const testMutation = useTestProxy();
|
||||||
|
const scanMutation = useScanProxies();
|
||||||
|
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [detected, setDetected] = useState<DetectedProxy[]>([]);
|
||||||
|
|
||||||
|
// 计算完整 URL(含认证信息)
|
||||||
|
const fullUrl = useMemo(
|
||||||
|
() => mergeAuth(url, username, password),
|
||||||
|
[url, username, password],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 同步远程配置
|
||||||
|
useEffect(() => {
|
||||||
|
if (savedUrl !== undefined) {
|
||||||
|
const { baseUrl, username: u, password: p } = extractAuth(savedUrl || "");
|
||||||
|
setUrl(baseUrl);
|
||||||
|
setUsername(u);
|
||||||
|
setPassword(p);
|
||||||
|
setDirty(false);
|
||||||
|
}
|
||||||
|
}, [savedUrl]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await setMutation.mutateAsync(fullUrl);
|
||||||
|
setDirty(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
if (fullUrl) {
|
||||||
|
await testMutation.mutateAsync(fullUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScan = async () => {
|
||||||
|
const result = await scanMutation.mutateAsync();
|
||||||
|
setDetected(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (proxyUrl: string) => {
|
||||||
|
const { baseUrl, username: u, password: p } = extractAuth(proxyUrl);
|
||||||
|
setUrl(baseUrl);
|
||||||
|
setUsername(u);
|
||||||
|
setPassword(p);
|
||||||
|
setDirty(true);
|
||||||
|
setDetected([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setUrl("");
|
||||||
|
setUsername("");
|
||||||
|
setPassword("");
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && dirty && !setMutation.isPending) {
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 只在首次加载且无数据时显示加载状态
|
||||||
|
if (isLoading && savedUrl === undefined) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 描述 */}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.globalProxy.hint")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 代理地址输入框和按钮 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="http://127.0.0.1:7890 / socks5://127.0.0.1:1080"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUrl(e.target.value);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="font-mono text-sm flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={scanMutation.isPending}
|
||||||
|
onClick={handleScan}
|
||||||
|
title={t("settings.globalProxy.scan")}
|
||||||
|
>
|
||||||
|
{scanMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={!fullUrl || testMutation.isPending}
|
||||||
|
onClick={handleTest}
|
||||||
|
title={t("settings.globalProxy.test")}
|
||||||
|
>
|
||||||
|
{testMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<TestTube2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={!url && !username && !password}
|
||||||
|
onClick={handleClear}
|
||||||
|
title={t("settings.globalProxy.clear")}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!dirty || setMutation.isPending}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{setMutation.isPending && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 认证信息:用户名 + 密码(可选) */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder={t("settings.globalProxy.username")}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUsername(e.target.value);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="font-mono text-sm flex-1"
|
||||||
|
/>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder={t("settings.globalProxy.password")}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPassword(e.target.value);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="font-mono text-sm pr-10"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 扫描结果 */}
|
||||||
|
{detected.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{detected.map((p) => (
|
||||||
|
<Button
|
||||||
|
key={p.url}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSelect(p.url)}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
>
|
||||||
|
{p.url}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { settingsApi, type RectifierConfig } from "@/lib/api/settings";
|
||||||
|
|
||||||
|
export function RectifierConfigPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [config, setConfig] = useState<RectifierConfig>({
|
||||||
|
enabled: true,
|
||||||
|
requestThinkingSignature: true,
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
settingsApi
|
||||||
|
.getRectifierConfig()
|
||||||
|
.then(setConfig)
|
||||||
|
.catch((e) => console.error("Failed to load rectifier config:", e))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = async (updates: Partial<RectifierConfig>) => {
|
||||||
|
const newConfig = { ...config, ...updates };
|
||||||
|
setConfig(newConfig);
|
||||||
|
try {
|
||||||
|
await settingsApi.setRectifierConfig(newConfig);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save rectifier config:", e);
|
||||||
|
toast.error(String(e));
|
||||||
|
setConfig(config);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>{t("settings.advanced.rectifier.enabled")}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.advanced.rectifier.enabledDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.enabled}
|
||||||
|
onCheckedChange={(checked) => handleChange({ enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("settings.advanced.rectifier.requestGroup")}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center justify-between pl-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>{t("settings.advanced.rectifier.thinkingSignature")}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.advanced.rectifier.thinkingSignatureDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.requestThinkingSignature}
|
||||||
|
disabled={!config.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleChange({ requestThinkingSignature: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
Server,
|
Server,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Zap,
|
||||||
|
Globe,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -34,12 +36,14 @@ import { WindowSettings } from "@/components/settings/WindowSettings";
|
|||||||
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
||||||
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
||||||
import { AboutSection } from "@/components/settings/AboutSection";
|
import { AboutSection } from "@/components/settings/AboutSection";
|
||||||
|
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
|
||||||
import { ProxyPanel } from "@/components/proxy";
|
import { ProxyPanel } from "@/components/proxy";
|
||||||
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
||||||
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
||||||
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
||||||
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
|
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
|
||||||
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
||||||
|
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
import { useSettings } from "@/hooks/useSettings";
|
||||||
import { useImportExport } from "@/hooks/useImportExport";
|
import { useImportExport } from "@/hooks/useImportExport";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -495,6 +499,28 @@ export function SettingsPage({
|
|||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem
|
||||||
|
value="globalProxy"
|
||||||
|
className="rounded-xl glass-card overflow-hidden"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Globe className="h-5 w-5 text-cyan-500" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{t("settings.advanced.globalProxy.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
|
{t("settings.advanced.globalProxy.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||||
|
<GlobalProxySettings />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
<AccordionItem
|
<AccordionItem
|
||||||
value="data"
|
value="data"
|
||||||
className="rounded-xl glass-card overflow-hidden"
|
className="rounded-xl glass-card overflow-hidden"
|
||||||
@@ -526,6 +552,28 @@ export function SettingsPage({
|
|||||||
/>
|
/>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem
|
||||||
|
value="rectifier"
|
||||||
|
className="rounded-xl glass-card overflow-hidden"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Zap className="h-5 w-5 text-purple-500" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{t("settings.advanced.rectifier.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
|
{t("settings.advanced.rectifier.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||||
|
<RectifierConfigPanel />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Save, Loader2 } from "lucide-react";
|
import { Save, Loader2 } from "lucide-react";
|
||||||
@@ -17,13 +18,15 @@ export function ModelTestConfigPanel() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [config, setConfig] = useState<StreamCheckConfig>({
|
// 使用字符串状态以支持完全清空数字输入框
|
||||||
timeoutSecs: 45,
|
const [config, setConfig] = useState({
|
||||||
maxRetries: 2,
|
timeoutSecs: "45",
|
||||||
degradedThresholdMs: 6000,
|
maxRetries: "2",
|
||||||
|
degradedThresholdMs: "6000",
|
||||||
claudeModel: "claude-haiku-4-5-20251001",
|
claudeModel: "claude-haiku-4-5-20251001",
|
||||||
codexModel: "gpt-5.1-codex@low",
|
codexModel: "gpt-5.1-codex@low",
|
||||||
geminiModel: "gemini-3-pro-preview",
|
geminiModel: "gemini-3-pro-preview",
|
||||||
|
testPrompt: "Who are you?",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,7 +38,15 @@ export function ModelTestConfigPanel() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const data = await getStreamCheckConfig();
|
const data = await getStreamCheckConfig();
|
||||||
setConfig(data);
|
setConfig({
|
||||||
|
timeoutSecs: String(data.timeoutSecs),
|
||||||
|
maxRetries: String(data.maxRetries),
|
||||||
|
degradedThresholdMs: String(data.degradedThresholdMs),
|
||||||
|
claudeModel: data.claudeModel,
|
||||||
|
codexModel: data.codexModel,
|
||||||
|
geminiModel: data.geminiModel,
|
||||||
|
testPrompt: data.testPrompt || "Who are you?",
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -44,9 +55,23 @@ export function ModelTestConfigPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
|
// 解析数字,空值使用默认值,0 是有效值
|
||||||
|
const parseNum = (val: string, defaultVal: number) => {
|
||||||
|
const n = parseInt(val);
|
||||||
|
return isNaN(n) ? defaultVal : n;
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
await saveStreamCheckConfig(config);
|
const parsed: StreamCheckConfig = {
|
||||||
|
timeoutSecs: parseNum(config.timeoutSecs, 45),
|
||||||
|
maxRetries: parseNum(config.maxRetries, 2),
|
||||||
|
degradedThresholdMs: parseNum(config.degradedThresholdMs, 6000),
|
||||||
|
claudeModel: config.claudeModel,
|
||||||
|
codexModel: config.codexModel,
|
||||||
|
geminiModel: config.geminiModel,
|
||||||
|
testPrompt: config.testPrompt || "Who are you?",
|
||||||
|
};
|
||||||
|
await saveStreamCheckConfig(parsed);
|
||||||
toast.success(t("streamCheck.configSaved"), {
|
toast.success(t("streamCheck.configSaved"), {
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
});
|
});
|
||||||
@@ -132,10 +157,7 @@ export function ModelTestConfigPanel() {
|
|||||||
max={120}
|
max={120}
|
||||||
value={config.timeoutSecs}
|
value={config.timeoutSecs}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setConfig({
|
setConfig({ ...config, timeoutSecs: e.target.value })
|
||||||
...config,
|
|
||||||
timeoutSecs: parseInt(e.target.value) || 45,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,10 +171,7 @@ export function ModelTestConfigPanel() {
|
|||||||
max={5}
|
max={5}
|
||||||
value={config.maxRetries}
|
value={config.maxRetries}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setConfig({
|
setConfig({ ...config, maxRetries: e.target.value })
|
||||||
...config,
|
|
||||||
maxRetries: parseInt(e.target.value) || 2,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,14 +188,26 @@ export function ModelTestConfigPanel() {
|
|||||||
step={1000}
|
step={1000}
|
||||||
value={config.degradedThresholdMs}
|
value={config.degradedThresholdMs}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setConfig({
|
setConfig({ ...config, degradedThresholdMs: e.target.value })
|
||||||
...config,
|
|
||||||
degradedThresholdMs: parseInt(e.target.value) || 6000,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 检查提示词配置 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="testPrompt">{t("streamCheck.testPrompt")}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="testPrompt"
|
||||||
|
value={config.testPrompt}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, testPrompt: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Who are you?"
|
||||||
|
rows={2}
|
||||||
|
className="min-h-[60px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export function PricingConfigPanel() {
|
|||||||
|
|
||||||
{editingModel && (
|
{editingModel && (
|
||||||
<PricingEditModal
|
<PricingEditModal
|
||||||
|
open={!!editingModel}
|
||||||
model={editingModel}
|
model={editingModel}
|
||||||
isNew={isAddingNew}
|
isNew={isAddingNew}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import { Save, Plus } from "lucide-react";
|
||||||
Dialog,
|
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -15,12 +10,14 @@ import { useUpdateModelPricing } from "@/lib/query/usage";
|
|||||||
import type { ModelPricing } from "@/types/usage";
|
import type { ModelPricing } from "@/types/usage";
|
||||||
|
|
||||||
interface PricingEditModalProps {
|
interface PricingEditModalProps {
|
||||||
|
open: boolean;
|
||||||
model: ModelPricing;
|
model: ModelPricing;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PricingEditModal({
|
export function PricingEditModal({
|
||||||
|
open,
|
||||||
model,
|
model,
|
||||||
isNew = false,
|
isNew = false,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -86,139 +83,142 @@ export function PricingEditModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={onClose}>
|
<FullScreenPanel
|
||||||
<DialogContent>
|
isOpen={open}
|
||||||
<DialogHeader>
|
title={
|
||||||
<DialogTitle>
|
isNew
|
||||||
{isNew
|
? t("usage.addPricing", "新增定价")
|
||||||
? t("usage.addPricing", "新增定价")
|
: `${t("usage.editPricing", "编辑定价")} - ${model.modelId}`
|
||||||
: `${t("usage.editPricing", "编辑定价")} - ${model.modelId}`}
|
}
|
||||||
</DialogTitle>
|
onClose={onClose}
|
||||||
</DialogHeader>
|
footer={
|
||||||
|
<Button
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
type="submit"
|
||||||
{isNew && (
|
form="pricing-form"
|
||||||
<div className="space-y-2">
|
disabled={updatePricing.isPending}
|
||||||
<Label htmlFor="modelId">{t("usage.modelId", "模型 ID")}</Label>
|
>
|
||||||
<Input
|
{isNew ? (
|
||||||
id="modelId"
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
value={formData.modelId}
|
) : (
|
||||||
onChange={(e) =>
|
<Save className="h-4 w-4 mr-2" />
|
||||||
setFormData({ ...formData, modelId: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder={t("usage.modelIdPlaceholder", {
|
|
||||||
defaultValue: "例如: claude-3-5-sonnet-20241022",
|
|
||||||
})}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{updatePricing.isPending
|
||||||
|
? t("common.saving", "保存中...")
|
||||||
|
: isNew
|
||||||
|
? t("common.add", "新增")
|
||||||
|
: t("common.save", "保存")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="pricing-form" onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{isNew && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="displayName">
|
<Label htmlFor="modelId">{t("usage.modelId", "模型 ID")}</Label>
|
||||||
{t("usage.displayName", "显示名称")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="displayName"
|
id="modelId"
|
||||||
value={formData.displayName}
|
value={formData.modelId}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, displayName: e.target.value })
|
setFormData({ ...formData, modelId: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder={t("usage.displayNamePlaceholder", {
|
placeholder={t("usage.modelIdPlaceholder", {
|
||||||
defaultValue: "例如: Claude 3.5 Sonnet",
|
defaultValue: "例如: claude-3-5-sonnet-20241022",
|
||||||
})}
|
})}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="inputCost">
|
<Label htmlFor="displayName">
|
||||||
{t("usage.inputCostPerMillion", "输入成本 (每百万 tokens, USD)")}
|
{t("usage.displayName", "显示名称")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="inputCost"
|
id="displayName"
|
||||||
type="number"
|
value={formData.displayName}
|
||||||
step="0.01"
|
onChange={(e) =>
|
||||||
min="0"
|
setFormData({ ...formData, displayName: e.target.value })
|
||||||
value={formData.inputCost}
|
}
|
||||||
onChange={(e) =>
|
placeholder={t("usage.displayNamePlaceholder", {
|
||||||
setFormData({ ...formData, inputCost: e.target.value })
|
defaultValue: "例如: Claude 3.5 Sonnet",
|
||||||
}
|
})}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="outputCost">
|
<Label htmlFor="inputCost">
|
||||||
{t("usage.outputCostPerMillion", "输出成本 (每百万 tokens, USD)")}
|
{t("usage.inputCostPerMillion", "输入成本 (每百万 tokens, USD)")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="outputCost"
|
id="inputCost"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
value={formData.outputCost}
|
value={formData.inputCost}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, outputCost: e.target.value })
|
setFormData({ ...formData, inputCost: e.target.value })
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="cacheReadCost">
|
<Label htmlFor="outputCost">
|
||||||
{t(
|
{t("usage.outputCostPerMillion", "输出成本 (每百万 tokens, USD)")}
|
||||||
"usage.cacheReadCostPerMillion",
|
</Label>
|
||||||
"缓存读取成本 (每百万 tokens, USD)",
|
<Input
|
||||||
)}
|
id="outputCost"
|
||||||
</Label>
|
type="number"
|
||||||
<Input
|
step="0.01"
|
||||||
id="cacheReadCost"
|
min="0"
|
||||||
type="number"
|
value={formData.outputCost}
|
||||||
step="0.01"
|
onChange={(e) =>
|
||||||
min="0"
|
setFormData({ ...formData, outputCost: e.target.value })
|
||||||
value={formData.cacheReadCost}
|
}
|
||||||
onChange={(e) =>
|
required
|
||||||
setFormData({ ...formData, cacheReadCost: e.target.value })
|
/>
|
||||||
}
|
</div>
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="cacheCreationCost">
|
<Label htmlFor="cacheReadCost">
|
||||||
{t(
|
{t(
|
||||||
"usage.cacheCreationCostPerMillion",
|
"usage.cacheReadCostPerMillion",
|
||||||
"缓存写入成本 (每百万 tokens, USD)",
|
"缓存读取成本 (每百万 tokens, USD)",
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="cacheCreationCost"
|
id="cacheReadCost"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
value={formData.cacheCreationCost}
|
value={formData.cacheReadCost}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, cacheCreationCost: e.target.value })
|
setFormData({ ...formData, cacheReadCost: e.target.value })
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<div className="space-y-2">
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Label htmlFor="cacheCreationCost">
|
||||||
{t("common.cancel", "取消")}
|
{t(
|
||||||
</Button>
|
"usage.cacheCreationCostPerMillion",
|
||||||
<Button type="submit" disabled={updatePricing.isPending}>
|
"缓存写入成本 (每百万 tokens, USD)",
|
||||||
{updatePricing.isPending
|
)}
|
||||||
? t("common.saving", "保存中...")
|
</Label>
|
||||||
: isNew
|
<Input
|
||||||
? t("common.add", "新增")
|
id="cacheCreationCost"
|
||||||
: t("common.save", "保存")}
|
type="number"
|
||||||
</Button>
|
step="0.01"
|
||||||
</DialogFooter>
|
min="0"
|
||||||
</form>
|
value={formData.cacheCreationCost}
|
||||||
</DialogContent>
|
onChange={(e) =>
|
||||||
</Dialog>
|
setFormData({ ...formData, cacheCreationCost: e.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</FullScreenPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,6 +372,46 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
partnerPromotionKey: "packycode", // 促销信息 i18n key
|
partnerPromotionKey: "packycode", // 促销信息 i18n key
|
||||||
icon: "packycode",
|
icon: "packycode",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Cubence",
|
||||||
|
websiteUrl: "https://cubence.com",
|
||||||
|
apiKeyUrl: "https://cubence.com/signup?code=CCSWITCH&source=ccs",
|
||||||
|
settingsConfig: {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: "https://api.cubence.com",
|
||||||
|
ANTHROPIC_AUTH_TOKEN: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
endpointCandidates: [
|
||||||
|
"https://api.cubence.com",
|
||||||
|
"https://api-cf.cubence.com",
|
||||||
|
"https://api-dmit.cubence.com",
|
||||||
|
"https://api-bwg.cubence.com",
|
||||||
|
],
|
||||||
|
category: "third_party",
|
||||||
|
isPartner: true, // 合作伙伴
|
||||||
|
partnerPromotionKey: "cubence", // 促销信息 i18n key
|
||||||
|
icon: "cubence",
|
||||||
|
iconColor: "#000000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AIGoCode",
|
||||||
|
websiteUrl: "https://aigocode.com",
|
||||||
|
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||||
|
settingsConfig: {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: "https://api.aigocode.com/api",
|
||||||
|
ANTHROPIC_AUTH_TOKEN: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 请求地址候选(用于地址管理/测速)
|
||||||
|
endpointCandidates: ["https://api.aigocode.com"],
|
||||||
|
category: "third_party",
|
||||||
|
isPartner: true, // 合作伙伴
|
||||||
|
partnerPromotionKey: "aigocode", // 促销信息 i18n key
|
||||||
|
icon: "aigocode",
|
||||||
|
iconColor: "#5B7FFF",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "OpenRouter",
|
name: "OpenRouter",
|
||||||
websiteUrl: "https://openrouter.ai",
|
websiteUrl: "https://openrouter.ai",
|
||||||
|
|||||||
@@ -153,4 +153,57 @@ requires_openai_auth = true`,
|
|||||||
partnerPromotionKey: "packycode", // 促销信息 i18n key
|
partnerPromotionKey: "packycode", // 促销信息 i18n key
|
||||||
icon: "packycode",
|
icon: "packycode",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Cubence",
|
||||||
|
websiteUrl: "https://cubence.com",
|
||||||
|
apiKeyUrl: "https://cubence.com/signup?code=CCSWITCH&source=ccs",
|
||||||
|
auth: generateThirdPartyAuth(""),
|
||||||
|
config: generateThirdPartyConfig(
|
||||||
|
"cubence",
|
||||||
|
"https://api.cubence.com/v1",
|
||||||
|
"gpt-5.2",
|
||||||
|
),
|
||||||
|
endpointCandidates: [
|
||||||
|
"https://api.cubence.com/v1",
|
||||||
|
"https://api-cf.cubence.com/v1",
|
||||||
|
"https://api-dmit.cubence.com/v1",
|
||||||
|
"https://api-bwg.cubence.com/v1",
|
||||||
|
],
|
||||||
|
category: "third_party",
|
||||||
|
isPartner: true, // 合作伙伴
|
||||||
|
partnerPromotionKey: "cubence", // 促销信息 i18n key
|
||||||
|
icon: "cubence",
|
||||||
|
iconColor: "#000000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AIGoCode",
|
||||||
|
websiteUrl: "https://aigocode.com",
|
||||||
|
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||||
|
category: "third_party",
|
||||||
|
auth: generateThirdPartyAuth(""),
|
||||||
|
config: generateThirdPartyConfig(
|
||||||
|
"aigocode",
|
||||||
|
"https://api.aigocode.com/openai",
|
||||||
|
"gpt-5.2",
|
||||||
|
),
|
||||||
|
endpointCandidates: ["https://api.aigocode.com"],
|
||||||
|
isPartner: true, // 合作伙伴
|
||||||
|
partnerPromotionKey: "aigocode", // 促销信息 i18n key
|
||||||
|
icon: "aigocode",
|
||||||
|
iconColor: "#5B7FFF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OpenRouter",
|
||||||
|
websiteUrl: "https://openrouter.ai",
|
||||||
|
apiKeyUrl: "https://openrouter.ai/keys",
|
||||||
|
auth: generateThirdPartyAuth(""),
|
||||||
|
config: generateThirdPartyConfig(
|
||||||
|
"openrouter",
|
||||||
|
"https://openrouter.ai/api/v1",
|
||||||
|
"gpt-5.2",
|
||||||
|
),
|
||||||
|
category: "aggregator",
|
||||||
|
icon: "openrouter",
|
||||||
|
iconColor: "#6566F1",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
|||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
GOOGLE_GEMINI_BASE_URL: "https://www.packyapi.com",
|
GOOGLE_GEMINI_BASE_URL: "https://www.packyapi.com",
|
||||||
GEMINI_MODEL: "gemini-3-pro-preview",
|
GEMINI_MODEL: "gemini-3-pro",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
baseURL: "https://www.packyapi.com",
|
baseURL: "https://www.packyapi.com",
|
||||||
model: "gemini-3-pro-preview",
|
model: "gemini-3-pro",
|
||||||
description: "PackyCode",
|
description: "PackyCode",
|
||||||
category: "third_party",
|
category: "third_party",
|
||||||
isPartner: true,
|
isPartner: true,
|
||||||
@@ -71,16 +71,78 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
|||||||
],
|
],
|
||||||
icon: "packycode",
|
icon: "packycode",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Cubence",
|
||||||
|
websiteUrl: "https://cubence.com",
|
||||||
|
apiKeyUrl: "https://cubence.com/signup?code=CCSWITCH&source=ccs",
|
||||||
|
settingsConfig: {
|
||||||
|
env: {
|
||||||
|
GOOGLE_GEMINI_BASE_URL: "https://api.cubence.com",
|
||||||
|
GEMINI_MODEL: "gemini-3-pro",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
baseURL: "https://api.cubence.com",
|
||||||
|
model: "gemini-3-pro",
|
||||||
|
description: "Cubence",
|
||||||
|
category: "third_party",
|
||||||
|
isPartner: true,
|
||||||
|
partnerPromotionKey: "cubence",
|
||||||
|
endpointCandidates: [
|
||||||
|
"https://api.cubence.com/v1",
|
||||||
|
"https://api-cf.cubence.com/v1",
|
||||||
|
"https://api-dmit.cubence.com/v1",
|
||||||
|
"https://api-bwg.cubence.com/v1",
|
||||||
|
],
|
||||||
|
icon: "cubence",
|
||||||
|
iconColor: "#000000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AIGoCode",
|
||||||
|
websiteUrl: "https://aigocode.com",
|
||||||
|
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||||
|
settingsConfig: {
|
||||||
|
env: {
|
||||||
|
GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.com/gemini",
|
||||||
|
GEMINI_MODEL: "gemini-3-pro",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
baseURL: "https://api.aigocode.com/gemini",
|
||||||
|
model: "gemini-3-pro",
|
||||||
|
description: "AIGoCode",
|
||||||
|
category: "third_party",
|
||||||
|
isPartner: true,
|
||||||
|
partnerPromotionKey: "aigocode",
|
||||||
|
endpointCandidates: ["https://api.aigocode.com/gemini"],
|
||||||
|
icon: "aigocode",
|
||||||
|
iconColor: "#5B7FFF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OpenRouter",
|
||||||
|
websiteUrl: "https://openrouter.ai",
|
||||||
|
apiKeyUrl: "https://openrouter.ai/keys",
|
||||||
|
settingsConfig: {
|
||||||
|
env: {
|
||||||
|
GOOGLE_GEMINI_BASE_URL: "https://openrouter.ai/api",
|
||||||
|
GEMINI_MODEL: "gemini-3-pro-preview",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
baseURL: "https://openrouter.ai/api",
|
||||||
|
model: "gemini-3-pro",
|
||||||
|
description: "OpenRouter",
|
||||||
|
category: "aggregator",
|
||||||
|
icon: "openrouter",
|
||||||
|
iconColor: "#6566F1",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "自定义",
|
name: "自定义",
|
||||||
websiteUrl: "",
|
websiteUrl: "",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
GOOGLE_GEMINI_BASE_URL: "",
|
GOOGLE_GEMINI_BASE_URL: "",
|
||||||
GEMINI_MODEL: "gemini-3-pro-preview",
|
GEMINI_MODEL: "gemini-3-pro",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
model: "gemini-3-pro-preview",
|
model: "gemini-3-pro",
|
||||||
description: "自定义 Gemini API 端点",
|
description: "自定义 Gemini API 端点",
|
||||||
category: "custom",
|
category: "custom",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 全局出站代理 React Hooks
|
||||||
|
*
|
||||||
|
* 提供获取、设置和测试全局代理的 React Query hooks。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
getGlobalProxyUrl,
|
||||||
|
setGlobalProxyUrl,
|
||||||
|
testProxyUrl,
|
||||||
|
getUpstreamProxyStatus,
|
||||||
|
scanLocalProxies,
|
||||||
|
type ProxyTestResult,
|
||||||
|
type UpstreamProxyStatus,
|
||||||
|
type DetectedProxy,
|
||||||
|
} from "@/lib/api/globalProxy";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全局代理 URL
|
||||||
|
*/
|
||||||
|
export function useGlobalProxyUrl() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["globalProxyUrl"],
|
||||||
|
queryFn: getGlobalProxyUrl,
|
||||||
|
staleTime: 30 * 1000, // 30秒内不重新获取,避免展开时闪烁
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置全局代理 URL
|
||||||
|
*/
|
||||||
|
export function useSetGlobalProxyUrl() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: setGlobalProxyUrl,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("settings.globalProxy.saved"));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["globalProxyUrl"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["upstreamProxyStatus"] });
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof error === "string"
|
||||||
|
? error
|
||||||
|
: "Unknown error";
|
||||||
|
toast.error(t("settings.globalProxy.saveFailed", { error: message }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试代理连接
|
||||||
|
*/
|
||||||
|
export function useTestProxy() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: testProxyUrl,
|
||||||
|
onSuccess: (result: ProxyTestResult) => {
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(
|
||||||
|
t("settings.globalProxy.testSuccess", { latency: result.latencyMs }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
t("settings.globalProxy.testFailed", { error: result.error }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前出站代理状态
|
||||||
|
*/
|
||||||
|
export function useUpstreamProxyStatus() {
|
||||||
|
return useQuery<UpstreamProxyStatus>({
|
||||||
|
queryKey: ["upstreamProxyStatus"],
|
||||||
|
queryFn: getUpstreamProxyStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描本地代理
|
||||||
|
*/
|
||||||
|
export function useScanProxies() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: scanLocalProxies,
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(
|
||||||
|
t("settings.globalProxy.scanFailed", { error: error.message }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { DetectedProxy };
|
||||||
@@ -115,6 +115,11 @@ export function useProviderActions(activeApp: AppId) {
|
|||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["providers", activeApp],
|
queryKey: ["providers", activeApp],
|
||||||
});
|
});
|
||||||
|
// 🔧 保存用量脚本后,也应该失效该 provider 的用量查询缓存
|
||||||
|
// 这样主页列表会使用新配置重新查询,而不是使用测试时的缓存
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["usage", provider.id, activeApp],
|
||||||
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
t("provider.usageSaved", {
|
t("provider.usageSaved", {
|
||||||
defaultValue: "用量查询配置已保存",
|
defaultValue: "用量查询配置已保存",
|
||||||
|
|||||||
@@ -183,9 +183,23 @@
|
|||||||
"title": "Cost Pricing",
|
"title": "Cost Pricing",
|
||||||
"description": "Manage token pricing rules for each model"
|
"description": "Manage token pricing rules for each model"
|
||||||
},
|
},
|
||||||
|
"globalProxy": {
|
||||||
|
"title": "Global Outbound Proxy",
|
||||||
|
"description": "Configure proxy for CC Switch to access external APIs"
|
||||||
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"title": "Data Management",
|
"title": "Data Management",
|
||||||
"description": "Import/export configurations and backup/restore"
|
"description": "Import/export configurations and backup/restore"
|
||||||
|
},
|
||||||
|
"rectifier": {
|
||||||
|
"title": "Rectifier",
|
||||||
|
"description": "Automatically fix API request compatibility issues",
|
||||||
|
"enabled": "Enable Rectifier",
|
||||||
|
"enabledDescription": "Master switch, all rectification features will be disabled when turned off",
|
||||||
|
"requestGroup": "Request Rectification",
|
||||||
|
"responseGroup": "Response Rectification",
|
||||||
|
"thinkingSignature": "Thinking Signature Rectification",
|
||||||
|
"thinkingSignatureDescription": "Automatically fix Claude API errors caused by thinking signature validation failures"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
@@ -271,7 +285,21 @@
|
|||||||
"restartLater": "Restart Later",
|
"restartLater": "Restart Later",
|
||||||
"restartFailed": "Application restart failed, please manually close and reopen.",
|
"restartFailed": "Application restart failed, please manually close and reopen.",
|
||||||
"devModeRestartHint": "Dev Mode: Automatic restart not supported, please manually restart the application.",
|
"devModeRestartHint": "Dev Mode: Automatic restart not supported, please manually restart the application.",
|
||||||
"saving": "Saving..."
|
"saving": "Saving...",
|
||||||
|
"globalProxy": {
|
||||||
|
"label": "Global Proxy",
|
||||||
|
"hint": "Proxy all requests (API, Skills download, etc.). Leave empty for direct connection.",
|
||||||
|
"username": "Username (optional)",
|
||||||
|
"password": "Password (optional)",
|
||||||
|
"test": "Test Connection",
|
||||||
|
"scan": "Scan Local Proxies",
|
||||||
|
"clear": "Clear",
|
||||||
|
"scanFailed": "Scan failed: {{error}}",
|
||||||
|
"saved": "Proxy settings saved",
|
||||||
|
"saveFailed": "Save failed: {{error}}",
|
||||||
|
"testSuccess": "Connected! Latency {{latency}}ms",
|
||||||
|
"testFailed": "Connection failed: {{error}}"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"claude": "Claude Code",
|
"claude": "Claude Code",
|
||||||
@@ -323,7 +351,9 @@
|
|||||||
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
|
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
|
||||||
"minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
|
"minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
|
||||||
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
|
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
|
||||||
"dmxapi": "Claude Code exclusive model 66% OFF now!"
|
"dmxapi": "Claude Code exclusive model 66% OFF now!",
|
||||||
|
"cubence": "Cubence is an official partner of CC Switch. Register using this link and enter \"CCSWITCH\" promo code during recharge to get 10% off every top-up",
|
||||||
|
"aigocode": "AIGoCode is an official partner of CC Switch. Register using this link and get 10% bonus credit on your first top-up!"
|
||||||
},
|
},
|
||||||
"parameterConfig": "Parameter Config - {{name}} *",
|
"parameterConfig": "Parameter Config - {{name}} *",
|
||||||
"mainModel": "Main Model (optional)",
|
"mainModel": "Main Model (optional)",
|
||||||
@@ -554,6 +584,7 @@
|
|||||||
"testFailed": "Test failed",
|
"testFailed": "Test failed",
|
||||||
"formatSuccess": "Format successful",
|
"formatSuccess": "Format successful",
|
||||||
"formatFailed": "Format failed",
|
"formatFailed": "Format failed",
|
||||||
|
"supportedVariables": "Supported Variables",
|
||||||
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
|
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
|
||||||
"scriptConfig": "Request configuration",
|
"scriptConfig": "Request configuration",
|
||||||
"extractorCode": "Extractor code",
|
"extractorCode": "Extractor code",
|
||||||
@@ -956,6 +987,7 @@
|
|||||||
"configDetails": "Config Details",
|
"configDetails": "Config Details",
|
||||||
"configUrl": "Config File URL",
|
"configUrl": "Config File URL",
|
||||||
"configMergeError": "Failed to merge configuration file",
|
"configMergeError": "Failed to merge configuration file",
|
||||||
|
"primaryEndpoint": "Primary",
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"title": "Batch Import MCP Servers",
|
"title": "Batch Import MCP Servers",
|
||||||
"targetApps": "Target Apps",
|
"targetApps": "Target Apps",
|
||||||
@@ -1057,7 +1089,7 @@
|
|||||||
},
|
},
|
||||||
"listenPort": {
|
"listenPort": {
|
||||||
"label": "Listen Port",
|
"label": "Listen Port",
|
||||||
"placeholder": "5000",
|
"placeholder": "15721",
|
||||||
"description": "Port number the proxy server listens on (1024 ~ 65535)"
|
"description": "Port number the proxy server listens on (1024 ~ 65535)"
|
||||||
},
|
},
|
||||||
"maxRetries": {
|
"maxRetries": {
|
||||||
@@ -1105,7 +1137,12 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"saved": "Proxy configuration saved",
|
"saved": "Proxy configuration saved",
|
||||||
"saveFailed": "Save failed: {{error}}"
|
"saveFailed": "Save failed: {{error}}"
|
||||||
}
|
},
|
||||||
|
"invalidPort": "Invalid port, please enter a number between 1024-65535",
|
||||||
|
"invalidAddress": "Invalid address, please enter a valid IP address (e.g. 127.0.0.1) or localhost",
|
||||||
|
"configSaved": "Proxy configuration saved",
|
||||||
|
"configSaveFailed": "Failed to save configuration",
|
||||||
|
"restartRequired": "Restart proxy service for address or port changes to take effect"
|
||||||
},
|
},
|
||||||
"switchFailed": "Switch failed: {{error}}",
|
"switchFailed": "Switch failed: {{error}}",
|
||||||
"failover": {
|
"failover": {
|
||||||
@@ -1132,6 +1169,7 @@
|
|||||||
"info": "When the failover queue has multiple providers, the system will try them in priority order when requests fail. When a provider reaches the consecutive failure threshold, the circuit breaker will open and skip it temporarily.",
|
"info": "When the failover queue has multiple providers, the system will try them in priority order when requests fail. When a provider reaches the consecutive failure threshold, the circuit breaker will open and skip it temporarily.",
|
||||||
"configSaved": "Auto failover config saved",
|
"configSaved": "Auto failover config saved",
|
||||||
"configSaveFailed": "Failed to save",
|
"configSaveFailed": "Failed to save",
|
||||||
|
"validationFailed": "The following fields are out of valid range: {{fields}}",
|
||||||
"retrySettings": "Retry & Timeout Settings",
|
"retrySettings": "Retry & Timeout Settings",
|
||||||
"failureThreshold": "Failure Threshold",
|
"failureThreshold": "Failure Threshold",
|
||||||
"failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)",
|
"failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)",
|
||||||
@@ -1165,7 +1203,8 @@
|
|||||||
"checkParams": "Check Parameters",
|
"checkParams": "Check Parameters",
|
||||||
"timeout": "Timeout (seconds)",
|
"timeout": "Timeout (seconds)",
|
||||||
"maxRetries": "Max Retries",
|
"maxRetries": "Max Retries",
|
||||||
"degradedThreshold": "Degraded Threshold (ms)"
|
"degradedThreshold": "Degraded Threshold (ms)",
|
||||||
|
"testPrompt": "Test Prompt"
|
||||||
},
|
},
|
||||||
"proxyConfig": {
|
"proxyConfig": {
|
||||||
"proxyEnabled": "Proxy Enabled",
|
"proxyEnabled": "Proxy Enabled",
|
||||||
@@ -1183,6 +1222,16 @@
|
|||||||
"streamingIdle": "Streaming Idle Timeout",
|
"streamingIdle": "Streaming Idle Timeout",
|
||||||
"nonStreaming": "Non-Streaming Timeout"
|
"nonStreaming": "Non-Streaming Timeout"
|
||||||
},
|
},
|
||||||
|
"circuitBreaker": {
|
||||||
|
"failureThreshold": "Failure Threshold",
|
||||||
|
"successThreshold": "Success Threshold",
|
||||||
|
"timeoutSeconds": "Timeout (seconds)",
|
||||||
|
"errorRateThreshold": "Error Rate Threshold",
|
||||||
|
"minRequests": "Min Requests",
|
||||||
|
"validationFailed": "The following fields are out of valid range: {{fields}}",
|
||||||
|
"configSaved": "Circuit breaker config saved",
|
||||||
|
"saveFailed": "Failed to save"
|
||||||
|
},
|
||||||
"universalProvider": {
|
"universalProvider": {
|
||||||
"title": "Universal Provider",
|
"title": "Universal Provider",
|
||||||
"description": "Universal providers manage Claude, Codex, and Gemini configurations simultaneously. Changes are automatically synced to all enabled apps.",
|
"description": "Universal providers manage Claude, Codex, and Gemini configurations simultaneously. Changes are automatically synced to all enabled apps.",
|
||||||
|
|||||||
@@ -183,9 +183,23 @@
|
|||||||
"title": "コスト計算",
|
"title": "コスト計算",
|
||||||
"description": "各モデルのトークン料金ルールを管理"
|
"description": "各モデルのトークン料金ルールを管理"
|
||||||
},
|
},
|
||||||
|
"globalProxy": {
|
||||||
|
"title": "グローバル送信プロキシ",
|
||||||
|
"description": "CC Switch が外部 API にアクセスする際のプロキシを設定"
|
||||||
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"title": "データ管理",
|
"title": "データ管理",
|
||||||
"description": "設定のインポート/エクスポートとバックアップ/復元"
|
"description": "設定のインポート/エクスポートとバックアップ/復元"
|
||||||
|
},
|
||||||
|
"rectifier": {
|
||||||
|
"title": "整流器",
|
||||||
|
"description": "API リクエストの互換性問題を自動修正",
|
||||||
|
"enabled": "整流器を有効化",
|
||||||
|
"enabledDescription": "マスタースイッチ、オフにするとすべての整流機能が無効になります",
|
||||||
|
"requestGroup": "リクエスト整流",
|
||||||
|
"responseGroup": "レスポンス整流",
|
||||||
|
"thinkingSignature": "Thinking 署名整流",
|
||||||
|
"thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"language": "言語",
|
"language": "言語",
|
||||||
@@ -271,7 +285,21 @@
|
|||||||
"restartLater": "後で再起動",
|
"restartLater": "後で再起動",
|
||||||
"restartFailed": "アプリの再起動に失敗しました。手動で閉じて再度開いてください。",
|
"restartFailed": "アプリの再起動に失敗しました。手動で閉じて再度開いてください。",
|
||||||
"devModeRestartHint": "開発モードでは自動再起動をサポートしていません。手動で再起動してください。",
|
"devModeRestartHint": "開発モードでは自動再起動をサポートしていません。手動で再起動してください。",
|
||||||
"saving": "保存中..."
|
"saving": "保存中...",
|
||||||
|
"globalProxy": {
|
||||||
|
"label": "グローバルプロキシ",
|
||||||
|
"hint": "すべてのリクエスト(API、Skills ダウンロードなど)をプロキシ経由で送信します。空欄で直接接続。",
|
||||||
|
"username": "ユーザー名(任意)",
|
||||||
|
"password": "パスワード(任意)",
|
||||||
|
"test": "接続テスト",
|
||||||
|
"scan": "ローカルプロキシをスキャン",
|
||||||
|
"clear": "クリア",
|
||||||
|
"scanFailed": "スキャンに失敗しました: {{error}}",
|
||||||
|
"saved": "プロキシ設定を保存しました",
|
||||||
|
"saveFailed": "保存に失敗しました: {{error}}",
|
||||||
|
"testSuccess": "接続成功!遅延 {{latency}}ms",
|
||||||
|
"testFailed": "接続に失敗しました: {{error}}"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"claude": "Claude Code",
|
"claude": "Claude Code",
|
||||||
@@ -323,7 +351,9 @@
|
|||||||
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
|
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
|
||||||
"minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から",
|
"minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から",
|
||||||
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $2(80% OFF)",
|
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $2(80% OFF)",
|
||||||
"dmxapi": "Claude Code 専用モデル 66% OFF 実施中!"
|
"dmxapi": "Claude Code 専用モデル 66% OFF 実施中!",
|
||||||
|
"cubence": "Cubence は CC Switch の公式パートナーです。登録後チャージ時に \"CCSWITCH\" を入力すると、毎回 10% オフ",
|
||||||
|
"aigocode": "AIGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます!"
|
||||||
},
|
},
|
||||||
"parameterConfig": "パラメーター設定 - {{name}} *",
|
"parameterConfig": "パラメーター設定 - {{name}} *",
|
||||||
"mainModel": "メインモデル(任意)",
|
"mainModel": "メインモデル(任意)",
|
||||||
@@ -554,6 +584,7 @@
|
|||||||
"testFailed": "テストに失敗しました",
|
"testFailed": "テストに失敗しました",
|
||||||
"formatSuccess": "整形に成功しました",
|
"formatSuccess": "整形に成功しました",
|
||||||
"formatFailed": "整形に失敗しました",
|
"formatFailed": "整形に失敗しました",
|
||||||
|
"supportedVariables": "使用可能な変数",
|
||||||
"variablesHint": "使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます",
|
"variablesHint": "使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます",
|
||||||
"scriptConfig": "リクエスト設定",
|
"scriptConfig": "リクエスト設定",
|
||||||
"extractorCode": "抽出コード",
|
"extractorCode": "抽出コード",
|
||||||
@@ -956,6 +987,7 @@
|
|||||||
"configDetails": "設定の詳細",
|
"configDetails": "設定の詳細",
|
||||||
"configUrl": "設定ファイル URL",
|
"configUrl": "設定ファイル URL",
|
||||||
"configMergeError": "設定ファイルのマージに失敗しました",
|
"configMergeError": "設定ファイルのマージに失敗しました",
|
||||||
|
"primaryEndpoint": "メイン",
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"title": "MCP サーバーを一括インポート",
|
"title": "MCP サーバーを一括インポート",
|
||||||
"targetApps": "ターゲットアプリ",
|
"targetApps": "ターゲットアプリ",
|
||||||
@@ -1057,7 +1089,7 @@
|
|||||||
},
|
},
|
||||||
"listenPort": {
|
"listenPort": {
|
||||||
"label": "リッスンポート",
|
"label": "リッスンポート",
|
||||||
"placeholder": "5000",
|
"placeholder": "15721",
|
||||||
"description": "プロキシサーバーがリッスンするポート番号(1024 ~ 65535)"
|
"description": "プロキシサーバーがリッスンするポート番号(1024 ~ 65535)"
|
||||||
},
|
},
|
||||||
"maxRetries": {
|
"maxRetries": {
|
||||||
@@ -1165,7 +1197,8 @@
|
|||||||
"checkParams": "チェックパラメーター",
|
"checkParams": "チェックパラメーター",
|
||||||
"timeout": "タイムアウト(秒)",
|
"timeout": "タイムアウト(秒)",
|
||||||
"maxRetries": "最大リトライ回数",
|
"maxRetries": "最大リトライ回数",
|
||||||
"degradedThreshold": "劣化しきい値(ミリ秒)"
|
"degradedThreshold": "劣化しきい値(ミリ秒)",
|
||||||
|
"testPrompt": "テストプロンプト"
|
||||||
},
|
},
|
||||||
"proxyConfig": {
|
"proxyConfig": {
|
||||||
"proxyEnabled": "プロキシ有効",
|
"proxyEnabled": "プロキシ有効",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user