mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 621be9a466 | |||
| 937978d68a | |||
| 298d19af89 | |||
| 5fcddbd096 | |||
| 00a94e118f | |||
| c09e0378b9 | |||
| 30004037e5 | |||
| d6ed95078c | |||
| e27b8ee31f | |||
| 3d7b056df1 | |||
| 69f3c78bbf | |||
| 9b1c659f07 | |||
| e1720a0dd1 | |||
| 85998600a1 | |||
| 2b1fd0582e | |||
| dbdaf35770 | |||
| c6f4a54c98 | |||
| 7d495aa772 | |||
| db8180aa31 | |||
| 1586451862 | |||
| bf570b6d2a | |||
| 26c3f05daf | |||
| d303706d51 | |||
| 97495d1550 | |||
| a1537807eb | |||
| f047960a33 | |||
| ace9b38cee | |||
| b67cdbb18c | |||
| c4f1e90893 | |||
| ddbff070d5 | |||
| ca7cb398c2 | |||
| 2fb3b5405a | |||
| ec649e7718 | |||
| 3da5525c79 | |||
| 44ca688253 | |||
| 5fe5ed98be | |||
| b2a9e91d70 | |||
| 4a1a997935 | |||
| c4535c894a | |||
| 64e0cabaa7 | |||
| 8ecb41d25e | |||
| 3e8f84481d | |||
| ba59483b33 | |||
| b6ff721d67 | |||
| 1706c9a26f | |||
| 5bce6d6020 | |||
| 6bdbb4df23 | |||
| 256903ee70 | |||
| f42f73ebb0 | |||
| ec6e113cf2 | |||
| ae837ade02 | |||
| a8fd1f0dd2 | |||
| fa33330b3b | |||
| 18207771ad | |||
| 0cd7d0756c | |||
| bca0997afa | |||
| 0ef8a4153f | |||
| 1b73b26c0e | |||
| 3d514c8250 | |||
| 18e973b920 | |||
| ec20ff4d8c | |||
| e6654bd7f9 | |||
| d4f33224c6 | |||
| 510a013449 | |||
| 1e5bab1cb6 | |||
| 6dcf268317 | |||
| 5efc0cdd5e | |||
| e081c7560c | |||
| 007813e09e | |||
| 9196d07925 | |||
| 1172209f49 | |||
| c49cfa5ac5 | |||
| bfe9bb6a0c | |||
| 5a5ca2a989 | |||
| 5d424b1383 | |||
| c42a0dccaf | |||
| ebe2a665ae | |||
| 1926af4988 | |||
| 1e3a978ecb | |||
| 395783e22a | |||
| 038b74b844 | |||
| f1e5afdae2 | |||
| c9ea13a7ce | |||
| 404ab5a1ae | |||
| 9a8f12a490 | |||
| 6a7c2df2d2 | |||
| 735b3b7d39 | |||
| cbc23764c0 | |||
| 2a541cfda4 | |||
| 5cc864c6aa | |||
| 3cdce2eced | |||
| 8876d67807 | |||
| 493b154a9d | |||
| 56b40bdad2 | |||
| 41267135f5 | |||
| 1fb2d5ed44 | |||
| 622a24ded4 | |||
| 6713368657 | |||
| b1103c8a59 | |||
| bf9228093b | |||
| 420b5d4389 | |||
| b1e4c37f9c | |||
| 74a2e9c08b | |||
| 169db5b6d8 |
@@ -17,3 +17,4 @@ GEMINI.md
|
|||||||
/.idea
|
/.idea
|
||||||
/.vscode
|
/.vscode
|
||||||
vitest-report.json
|
vitest-report.json
|
||||||
|
nul
|
||||||
|
|||||||
+84
-18
@@ -5,31 +5,97 @@ All notable changes to CC Switch will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [3.8.3] - 2025-12-24
|
## [3.9.0-1] - 2025-12-18
|
||||||
|
|
||||||
|
### Beta Release
|
||||||
|
|
||||||
|
This beta release introduces the **Local API Proxy** feature, along with Skills multi-app support, UI improvements, and numerous bug fixes.
|
||||||
|
|
||||||
|
### Major Features
|
||||||
|
|
||||||
|
#### Local Proxy Server
|
||||||
|
- **Local HTTP Proxy** - High-performance proxy server built on Axum framework
|
||||||
|
- **Multi-app Support** - Unified proxy for Claude Code, Codex, and Gemini CLI API requests
|
||||||
|
- **Per-app Takeover** - Independent control over which apps route through the proxy
|
||||||
|
- **Live Config Takeover** - Automatically backs up and redirects CLI configurations to local proxy
|
||||||
|
|
||||||
|
#### Auto Failover
|
||||||
|
- **Circuit Breaker** - Automatically detects provider failures and triggers protection
|
||||||
|
- **Smart Failover** - Automatically switches to backup provider when current one is unavailable
|
||||||
|
- **Health Tracking** - Real-time monitoring of provider availability
|
||||||
|
- **Independent Failover Queues** - Each app maintains its own failover queue
|
||||||
|
|
||||||
|
#### Monitoring
|
||||||
|
- **Request Logging** - Detailed logging of all proxy requests
|
||||||
|
- **Usage Statistics** - Token consumption, latency, success rate metrics
|
||||||
|
- **Real-time Status** - Frontend displays proxy status and statistics
|
||||||
|
|
||||||
|
#### Skills Multi-App Support
|
||||||
|
- **Multi-app Support** - Skills now support both Claude and Codex (#365)
|
||||||
|
- **Multi-app Migration** - Existing Skills auto-migrate to multi-app structure (#378)
|
||||||
|
- **Installation Path Fix** - Use directory basename for skill installation path (#358)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Provider Icon Colors** - Customize provider icon colors (#385)
|
||||||
- **macOS tray template icon** - Use system template icon for better dark/light mode adaptation
|
- **Deeplink Usage Config** - Import usage query config via deeplink (#400)
|
||||||
- **AiGoCode partner** - Added AiGoCode icon and partner promotion
|
- **Error Request Logging** - Detailed logging for proxy requests (#401)
|
||||||
- **Skip first-run confirmation** - New setting to skip Claude Code first-run confirmation dialog
|
- **Closable Toast** - Added close button to switch notification toast (#350)
|
||||||
- **DMXAPI partner** - Added DMXAPI as official partner
|
- **Icon Color Component** - ProviderIcon component supports color prop (#384)
|
||||||
- **Provider icons** - Added icons for OpenRouter, LongCat, ModelScope, AiHubMix
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **UI header layout** - Fixed content being covered by fixed header (added padding-top)
|
#### Proxy Related
|
||||||
- **Dark mode visibility** - Improved text visibility in dark mode
|
- Takeover Codex base_url via model_provider
|
||||||
- **Azure link** - Corrected Azure website link (#407)
|
- Harden crash recovery with fallback detection
|
||||||
- **Skill installation** - Use directory basename for skill installation path (#358)
|
- Sync UI when active provider differs from current setting
|
||||||
- **SQL import refresh** - Refresh all providers immediately after SQL import
|
- Resolve circuit breaker race condition and error classification
|
||||||
- **MCP sync safety** - Skip sync when target CLI app is not installed
|
- Stabilize live takeover and provider editing
|
||||||
|
- Reset health badges when proxy stops
|
||||||
|
- Retry failover for all HTTP errors including 4xx
|
||||||
|
- Fix HalfOpen counter underflow and config field inconsistencies
|
||||||
|
- Resolve circuit breaker state persistence and HalfOpen deadlock
|
||||||
|
- Auto-recover live config after abnormal exit
|
||||||
|
- Update live backup when hot-switching provider in proxy mode
|
||||||
|
- Wait for server shutdown before exiting app
|
||||||
|
- Disable auto-start on app launch by resetting enabled flag on stop
|
||||||
|
- Sync live config tokens to database before takeover
|
||||||
|
- Resolve 404 error and auto-setup proxy targets
|
||||||
|
|
||||||
### Changed
|
#### MCP Related
|
||||||
|
- Skip sync when target CLI app is not installed
|
||||||
|
- Improve upsert and import robustness
|
||||||
|
- Use browser-compatible platform detection for MCP presets
|
||||||
|
|
||||||
- **Model versions** - Updated model versions for provider presets
|
#### UI Related
|
||||||
- **Provider switch notification** - Removed restart prompt from notification
|
- Restore fade transition for Skills button
|
||||||
- **SQL import restriction** - Restrict SQL import to CC Switch exported backups only
|
- Add close button to all success toasts
|
||||||
- **GLM partner images** - Updated partner banner images
|
- Prevent card jitter when health badge appears
|
||||||
|
- Update SettingsPage tab styles (#342)
|
||||||
|
|
||||||
|
#### Other
|
||||||
|
- Fix Azure website link (#407)
|
||||||
|
- Add fallback to provider config for usage credentials (#360)
|
||||||
|
- Fix Windows black screen on startup (use system titlebar)
|
||||||
|
- Add fallback for crypto.randomUUID() on older WebViews
|
||||||
|
- Use correct npm package for Codex CLI version check
|
||||||
|
- Security fixes for JavaScript executor and usage script (#151)
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active
|
||||||
|
- **Card Animation** - Improved provider card hover animation
|
||||||
|
- **Remove Restart Prompt** - No longer prompts restart when switching providers
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- Implement per-app takeover mode
|
||||||
|
- Proxy module contains 20+ Rust files with complete layered architecture
|
||||||
|
- Add 5 new database tables for proxy functionality
|
||||||
|
- Modularize handlers.rs to reduce code duplication
|
||||||
|
- Remove is_proxy_target in favor of failover_queue
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
- 55 commits since v3.8.2
|
||||||
|
- 164 files changed
|
||||||
|
- +22,164 / -570 lines
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1,165 @@
|
|||||||
|
# CC Switch 代理功能使用指南
|
||||||
|
|
||||||
|
## 功能介绍
|
||||||
|
|
||||||
|
CC Switch 的代理功能是一个本地 HTTP 代理服务器,可以统一管理 Claude Code、Codex 和 Gemini CLI 的 API 请求。主要特性包括:
|
||||||
|
|
||||||
|
- **统一代理入口** - 所有 CLI 应用的请求通过本地代理转发
|
||||||
|
- **自动故障转移** - 当前供应商故障时自动切换到备用供应商
|
||||||
|
- **按应用控制** - 可独立控制每个应用是否启用代理
|
||||||
|
- **配置保护** - 自动备份原始配置,停止代理时安全恢复
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 启动代理
|
||||||
|
|
||||||
|
在 CC Switch 主界面,点击右上角的 **Proxy** 按钮,可以看到代理控制面板。
|
||||||
|
|
||||||
|
点击 **启动代理** 按钮启动本地代理服务器。代理默认监听 `127.0.0.1:15721`。
|
||||||
|
|
||||||
|
### 2. 启用应用接管
|
||||||
|
|
||||||
|
代理启动后,你可以选择让哪些应用的请求通过代理:
|
||||||
|
|
||||||
|
- **Claude** - 接管 Claude Code 的 API 请求
|
||||||
|
- **Codex** - 接管 Codex CLI 的 API 请求
|
||||||
|
- **Gemini** - 接管 Gemini CLI 的 API 请求
|
||||||
|
|
||||||
|
点击对应应用的开关即可启用/禁用接管。
|
||||||
|
|
||||||
|
> **注意**:启用接管后,CC Switch 会自动修改对应应用的配置文件,将 API 端点指向本地代理。原始配置会被安全备份。
|
||||||
|
|
||||||
|
### 3. 正常使用 CLI
|
||||||
|
|
||||||
|
启用接管后,你可以正常使用各个 CLI 工具。所有请求都会经过 CC Switch 代理转发到配置的供应商。
|
||||||
|
|
||||||
|
### 4. 停止代理
|
||||||
|
|
||||||
|
当你不再需要代理时,点击 **停止代理** 按钮。CC Switch 会:
|
||||||
|
|
||||||
|
1. 安全关闭代理服务器
|
||||||
|
2. 自动恢复所有应用的原始配置
|
||||||
|
3. 清除代理状态
|
||||||
|
|
||||||
|
## 自动故障转移
|
||||||
|
|
||||||
|
### 工作原理
|
||||||
|
|
||||||
|
代理功能内置了智能故障转移机制:
|
||||||
|
|
||||||
|
1. **健康监控** - 实时监控每个供应商的响应状态
|
||||||
|
2. **熔断器** - 连续失败 5 次后触发熔断,暂停使用该供应商
|
||||||
|
3. **自动切换** - 熔断后自动切换到列表中的下一个供应商
|
||||||
|
4. **自动恢复** - 30 秒后尝试恢复熔断的供应商
|
||||||
|
|
||||||
|
### 配置故障转移
|
||||||
|
|
||||||
|
要使用故障转移功能,你需要:
|
||||||
|
|
||||||
|
1. 在对应应用下添加多个供应商(至少 2 个)
|
||||||
|
2. 启动代理并启用接管
|
||||||
|
3. 当主供应商故障时,代理会自动切换到备用供应商
|
||||||
|
|
||||||
|
### 健康状态指示
|
||||||
|
|
||||||
|
在供应商卡片上可以看到健康状态指示:
|
||||||
|
|
||||||
|
- **绿色** - 供应商正常
|
||||||
|
- **红色** - 供应商故障/熔断中
|
||||||
|
- **灰色** - 未使用代理或未检测
|
||||||
|
|
||||||
|
## 按应用接管
|
||||||
|
|
||||||
|
v3.9.0 新增了按应用分粒度控制功能:
|
||||||
|
|
||||||
|
- 你可以只接管 Claude,而让 Codex 使用原始配置
|
||||||
|
- 每个应用的接管状态独立管理
|
||||||
|
- 启用/禁用不会影响其他应用
|
||||||
|
|
||||||
|
### 接管状态检测
|
||||||
|
|
||||||
|
CC Switch 通过检测配置备份来判断接管状态:
|
||||||
|
|
||||||
|
- 存在备份 = 已接管
|
||||||
|
- 无备份 = 未接管
|
||||||
|
|
||||||
|
这确保了即使 CC Switch 异常退出,重新启动后也能正确识别状态。
|
||||||
|
|
||||||
|
## 代理配置
|
||||||
|
|
||||||
|
在代理面板中,你可以配置以下参数:
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 监听地址 | 127.0.0.1 | 代理服务器绑定地址 |
|
||||||
|
| 监听端口 | 15721 | 代理服务器端口 |
|
||||||
|
| 最大重试 | 3 | 请求失败时的最大重试次数 |
|
||||||
|
| 请求超时 | 120 秒 | 单个请求的超时时间 |
|
||||||
|
| 启用日志 | 是 | 是否记录请求日志 |
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 代理启动失败,提示端口被占用?
|
||||||
|
|
||||||
|
A: 默认端口 15721 可能被其他程序占用。你可以:
|
||||||
|
- 关闭占用该端口的程序
|
||||||
|
- 在代理配置中修改端口号
|
||||||
|
|
||||||
|
### Q: 启用接管后 CLI 无法使用?
|
||||||
|
|
||||||
|
A: 请检查:
|
||||||
|
1. 代理服务器是否正常运行(查看代理面板状态)
|
||||||
|
2. 供应商配置是否正确(API Key 等)
|
||||||
|
3. 网络连接是否正常
|
||||||
|
|
||||||
|
### Q: 如何恢复原始配置?
|
||||||
|
|
||||||
|
A: 点击 **停止代理** 按钮,CC Switch 会自动恢复所有应用的原始配置。
|
||||||
|
|
||||||
|
如果 CC Switch 异常退出,重新启动后会检测到之前的备份,你可以:
|
||||||
|
- 点击停止代理来恢复配置
|
||||||
|
- 或继续使用代理功能
|
||||||
|
|
||||||
|
### Q: 故障转移没有生效?
|
||||||
|
|
||||||
|
A: 请确保:
|
||||||
|
1. 配置了至少 2 个供应商
|
||||||
|
2. 代理已启动且接管已启用
|
||||||
|
3. 故障转移只在代理模式下工作
|
||||||
|
|
||||||
|
### Q: 代理会影响性能吗?
|
||||||
|
|
||||||
|
A: 本地代理的延迟开销非常小(通常 < 1ms)。但如果启用了请求日志,在高频请求场景下可能会有少量性能影响。
|
||||||
|
|
||||||
|
## 技术细节
|
||||||
|
|
||||||
|
### 配置文件位置
|
||||||
|
|
||||||
|
启用接管后,CC Switch 会修改以下配置文件:
|
||||||
|
|
||||||
|
| 应用 | 配置文件 | 修改内容 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| Claude | `~/.claude/settings.json` | `apiBaseUrl` 指向代理 |
|
||||||
|
| Codex | `~/.codex/config.toml` | `[api] baseUrl` 指向代理 |
|
||||||
|
| Gemini | `~/.gemini/.env` | `GEMINI_BASE_URL` 指向代理 |
|
||||||
|
|
||||||
|
原始配置备份在 CC Switch 数据库中,停止代理时自动恢复。
|
||||||
|
|
||||||
|
### 代理模式
|
||||||
|
|
||||||
|
代理服务器运行在接管模式下,会:
|
||||||
|
|
||||||
|
1. 接收来自 CLI 的 HTTPS 请求
|
||||||
|
2. 根据当前供应商配置转发到真实 API 端点
|
||||||
|
3. 返回响应给 CLI
|
||||||
|
4. 记录请求日志和健康状态
|
||||||
|
|
||||||
|
### 数据库表
|
||||||
|
|
||||||
|
代理功能使用以下数据库表:
|
||||||
|
|
||||||
|
- `proxy_config` - 代理配置
|
||||||
|
- `provider_health` - 供应商健康状态
|
||||||
|
- `proxy_request_logs` - 请求日志
|
||||||
|
- `circuit_breaker_config` - 熔断器配置
|
||||||
|
- `proxy_live_backup` - Live 配置备份
|
||||||
+9
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.8.3",
|
"version": "3.9.0-2",
|
||||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm tauri dev",
|
"dev": "pnpm tauri dev",
|
||||||
@@ -26,16 +26,16 @@
|
|||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
"cross-fetch": "^4.1.0",
|
"cross-fetch": "^4.1.0",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"msw": "^2.11.6",
|
"msw": "^2.11.6",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
"vitest": "^2.0.5",
|
"vitest": "^2.0.5"
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"tailwindcss": "^3.4.17"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@lobehub/icons-static-svg": "^1.73.0",
|
"@lobehub/icons-static-svg": "^1.73.0",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -67,7 +68,9 @@
|
|||||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
|
"framer-motion": "^12.23.25",
|
||||||
"i18next": "^25.5.2",
|
"i18next": "^25.5.2",
|
||||||
"jsonc-parser": "^3.2.1",
|
"jsonc-parser": "^3.2.1",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
@@ -75,6 +78,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.65.0",
|
||||||
"react-i18next": "^16.0.0",
|
"react-i18next": "^16.0.0",
|
||||||
|
"recharts": "^3.5.1",
|
||||||
"smol-toml": "^1.4.2",
|
"smol-toml": "^1.4.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
|||||||
Generated
+423
@@ -44,6 +44,9 @@ importers:
|
|||||||
'@lobehub/icons-static-svg':
|
'@lobehub/icons-static-svg':
|
||||||
specifier: ^1.73.0
|
specifier: ^1.73.0
|
||||||
version: 1.73.0
|
version: 1.73.0
|
||||||
|
'@radix-ui/react-accordion':
|
||||||
|
specifier: ^1.2.12
|
||||||
|
version: 1.2.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@radix-ui/react-checkbox':
|
'@radix-ui/react-checkbox':
|
||||||
specifier: ^1.3.3
|
specifier: ^1.3.3
|
||||||
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -95,9 +98,15 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
cmdk:
|
||||||
|
specifier: ^1.1.1
|
||||||
|
version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
codemirror:
|
codemirror:
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.2
|
version: 6.0.2
|
||||||
|
framer-motion:
|
||||||
|
specifier: ^12.23.25
|
||||||
|
version: 12.23.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^25.5.2
|
specifier: ^25.5.2
|
||||||
version: 25.5.2(typescript@5.9.2)
|
version: 25.5.2(typescript@5.9.2)
|
||||||
@@ -119,6 +128,9 @@ importers:
|
|||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^16.0.0
|
specifier: ^16.0.0
|
||||||
version: 16.0.0(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2)
|
version: 16.0.0(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2)
|
||||||
|
recharts:
|
||||||
|
specifier: ^3.5.1
|
||||||
|
version: 3.5.1(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(redux@5.0.1)
|
||||||
smol-toml:
|
smol-toml:
|
||||||
specifier: ^1.4.2
|
specifier: ^1.4.2
|
||||||
version: 1.4.2
|
version: 1.4.2
|
||||||
@@ -649,6 +661,19 @@ packages:
|
|||||||
'@radix-ui/primitive@1.1.3':
|
'@radix-ui/primitive@1.1.3':
|
||||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||||
|
|
||||||
|
'@radix-ui/react-accordion@1.2.12':
|
||||||
|
resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-arrow@1.1.7':
|
'@radix-ui/react-arrow@1.1.7':
|
||||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -675,6 +700,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-collapsible@1.1.12':
|
||||||
|
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-collection@1.1.7':
|
'@radix-ui/react-collection@1.1.7':
|
||||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1047,6 +1085,17 @@ packages:
|
|||||||
'@radix-ui/rect@1.1.1':
|
'@radix-ui/rect@1.1.1':
|
||||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||||
|
|
||||||
|
'@reduxjs/toolkit@2.11.0':
|
||||||
|
resolution: {integrity: sha512-hBjYg0aaRL1O2Z0IqWhnTLytnjDIxekmRxm1snsHjHaKVmIF1HiImWqsq+PuEbn6zdMlkIj9WofK1vR8jjx+Xw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||||
|
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-redux:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||||
|
|
||||||
@@ -1161,6 +1210,9 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.0.0':
|
||||||
|
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||||
|
|
||||||
'@standard-schema/utils@0.3.0':
|
'@standard-schema/utils@0.3.0':
|
||||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||||
|
|
||||||
@@ -1307,6 +1359,33 @@ packages:
|
|||||||
'@types/babel__traverse@7.28.0':
|
'@types/babel__traverse@7.28.0':
|
||||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||||
|
|
||||||
|
'@types/d3-array@3.2.2':
|
||||||
|
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3':
|
||||||
|
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||||
|
|
||||||
|
'@types/d3-ease@3.0.2':
|
||||||
|
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||||
|
|
||||||
|
'@types/d3-path@3.1.1':
|
||||||
|
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||||
|
|
||||||
|
'@types/d3-scale@4.0.9':
|
||||||
|
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||||
|
|
||||||
|
'@types/d3-shape@3.1.7':
|
||||||
|
resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
|
||||||
|
|
||||||
|
'@types/d3-time@3.0.4':
|
||||||
|
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||||
|
|
||||||
|
'@types/d3-timer@3.0.2':
|
||||||
|
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -1327,6 +1406,9 @@ packages:
|
|||||||
'@types/statuses@2.0.6':
|
'@types/statuses@2.0.6':
|
||||||
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6':
|
||||||
|
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.7.0':
|
'@vitejs/plugin-react@4.7.0':
|
||||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
@@ -1480,6 +1562,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
cmdk@1.1.1:
|
||||||
|
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18 || ^19 || ^19.0.0-rc
|
||||||
|
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
codemirror@6.0.2:
|
codemirror@6.0.2:
|
||||||
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
|
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
|
||||||
|
|
||||||
@@ -1526,6 +1614,50 @@ packages:
|
|||||||
csstype@3.1.3:
|
csstype@3.1.3:
|
||||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-color@3.1.0:
|
||||||
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-ease@3.0.1:
|
||||||
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-format@3.1.0:
|
||||||
|
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-path@3.1.0:
|
||||||
|
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-scale@4.0.2:
|
||||||
|
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-shape@3.2.0:
|
||||||
|
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-time-format@4.1.0:
|
||||||
|
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-time@3.1.0:
|
||||||
|
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-timer@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
data-urls@5.0.0:
|
data-urls@5.0.0:
|
||||||
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1539,6 +1671,9 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decimal.js-light@2.5.1:
|
||||||
|
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||||
|
|
||||||
decimal.js@10.6.0:
|
decimal.js@10.6.0:
|
||||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
@@ -1609,6 +1744,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-toolkit@1.42.0:
|
||||||
|
resolution: {integrity: sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==}
|
||||||
|
|
||||||
esbuild@0.21.5:
|
esbuild@0.21.5:
|
||||||
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1621,6 +1759,9 @@ packages:
|
|||||||
estree-walker@3.0.3:
|
estree-walker@3.0.3:
|
||||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
||||||
|
eventemitter3@5.0.1:
|
||||||
|
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||||
|
|
||||||
expect-type@1.2.2:
|
expect-type@1.2.2:
|
||||||
resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==}
|
resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -1652,6 +1793,20 @@ packages:
|
|||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
|
framer-motion@12.23.25:
|
||||||
|
resolution: {integrity: sha512-gUHGl2e4VG66jOcH0JHhuJQr6ZNwrET9g31ZG0xdXzT0CznP7fHX4P8Bcvuc4MiUB90ysNnWX2ukHRIggkl6hQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@emotion/is-prop-valid': '*'
|
||||||
|
react: ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@emotion/is-prop-valid':
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -1738,10 +1893,20 @@ packages:
|
|||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
immer@10.2.0:
|
||||||
|
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||||
|
|
||||||
|
immer@11.0.1:
|
||||||
|
resolution: {integrity: sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==}
|
||||||
|
|
||||||
indent-string@4.0.0:
|
indent-string@4.0.0:
|
||||||
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
internmap@2.0.3:
|
||||||
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
is-binary-path@2.1.0:
|
is-binary-path@2.1.0:
|
||||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1925,6 +2090,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
motion-dom@12.23.23:
|
||||||
|
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
|
||||||
|
|
||||||
|
motion-utils@12.23.6:
|
||||||
|
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@@ -2115,6 +2286,18 @@ packages:
|
|||||||
react-is@17.0.2:
|
react-is@17.0.2:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
|
react-redux@9.2.0:
|
||||||
|
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': ^18.2.25 || ^19
|
||||||
|
react: ^18.0 || ^19
|
||||||
|
redux: ^5.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
redux:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react-refresh@0.17.0:
|
react-refresh@0.17.0:
|
||||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2160,14 +2343,33 @@ packages:
|
|||||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||||
engines: {node: '>=8.10.0'}
|
engines: {node: '>=8.10.0'}
|
||||||
|
|
||||||
|
recharts@3.5.1:
|
||||||
|
resolution: {integrity: sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
redent@3.0.0:
|
redent@3.0.0:
|
||||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
redux-thunk@3.1.0:
|
||||||
|
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||||
|
peerDependencies:
|
||||||
|
redux: ^5.0.0
|
||||||
|
|
||||||
|
redux@5.0.1:
|
||||||
|
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||||
|
|
||||||
require-directory@2.1.1:
|
require-directory@2.1.1:
|
||||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
reselect@5.1.1:
|
||||||
|
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||||
|
|
||||||
resolve@1.22.11:
|
resolve@1.22.11:
|
||||||
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2284,6 +2486,9 @@ packages:
|
|||||||
thenify@3.3.1:
|
thenify@3.3.1:
|
||||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
||||||
|
|
||||||
|
tiny-invariant@1.3.3:
|
||||||
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
tinybench@2.9.0:
|
tinybench@2.9.0:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
@@ -2392,9 +2597,17 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
use-sync-external-store@1.6.0:
|
||||||
|
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
victory-vendor@37.3.6:
|
||||||
|
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||||
|
|
||||||
vite-node@2.1.9:
|
vite-node@2.1.9:
|
||||||
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
@@ -3031,6 +3244,23 @@ snapshots:
|
|||||||
|
|
||||||
'@radix-ui/primitive@1.1.3': {}
|
'@radix-ui/primitive@1.1.3': {}
|
||||||
|
|
||||||
|
'@radix-ui/react-accordion@1.2.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.23
|
||||||
|
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||||
|
|
||||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -3056,6 +3286,22 @@ snapshots:
|
|||||||
'@types/react': 18.3.23
|
'@types/react': 18.3.23
|
||||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||||
|
|
||||||
|
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.23
|
||||||
|
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||||
|
|
||||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||||
@@ -3416,6 +3662,18 @@ snapshots:
|
|||||||
|
|
||||||
'@radix-ui/rect@1.1.1': {}
|
'@radix-ui/rect@1.1.1': {}
|
||||||
|
|
||||||
|
'@reduxjs/toolkit@2.11.0(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@standard-schema/spec': 1.0.0
|
||||||
|
'@standard-schema/utils': 0.3.0
|
||||||
|
immer: 11.0.1
|
||||||
|
redux: 5.0.1
|
||||||
|
redux-thunk: 3.1.0(redux@5.0.1)
|
||||||
|
reselect: 5.1.1
|
||||||
|
optionalDependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
react-redux: 9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1)
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.46.2':
|
'@rollup/rollup-android-arm-eabi@4.46.2':
|
||||||
@@ -3478,6 +3736,8 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.46.2':
|
'@rollup/rollup-win32-x64-msvc@4.46.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.0.0': {}
|
||||||
|
|
||||||
'@standard-schema/utils@0.3.0': {}
|
'@standard-schema/utils@0.3.0': {}
|
||||||
|
|
||||||
'@tanstack/query-core@5.90.3': {}
|
'@tanstack/query-core@5.90.3': {}
|
||||||
@@ -3609,6 +3869,30 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.28.2
|
'@babel/types': 7.28.2
|
||||||
|
|
||||||
|
'@types/d3-array@3.2.2': {}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3': {}
|
||||||
|
|
||||||
|
'@types/d3-ease@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-color': 3.1.3
|
||||||
|
|
||||||
|
'@types/d3-path@3.1.1': {}
|
||||||
|
|
||||||
|
'@types/d3-scale@4.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-time': 3.0.4
|
||||||
|
|
||||||
|
'@types/d3-shape@3.1.7':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-path': 3.1.1
|
||||||
|
|
||||||
|
'@types/d3-time@3.0.4': {}
|
||||||
|
|
||||||
|
'@types/d3-timer@3.0.2': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/node@20.19.9':
|
'@types/node@20.19.9':
|
||||||
@@ -3628,6 +3912,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/statuses@2.0.6': {}
|
'@types/statuses@2.0.6': {}
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6': {}
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.7.0(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))':
|
'@vitejs/plugin-react@4.7.0(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.0
|
'@babel/core': 7.28.0
|
||||||
@@ -3796,6 +4082,18 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
||||||
|
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- '@types/react-dom'
|
||||||
|
|
||||||
codemirror@6.0.2:
|
codemirror@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.18.7
|
'@codemirror/autocomplete': 6.18.7
|
||||||
@@ -3841,6 +4139,44 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.1.3: {}
|
csstype@3.1.3: {}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
dependencies:
|
||||||
|
internmap: 2.0.3
|
||||||
|
|
||||||
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-ease@3.0.1: {}
|
||||||
|
|
||||||
|
d3-format@3.1.0: {}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
|
||||||
|
d3-path@3.1.0: {}
|
||||||
|
|
||||||
|
d3-scale@4.0.2:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
d3-format: 3.1.0
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-time: 3.1.0
|
||||||
|
d3-time-format: 4.1.0
|
||||||
|
|
||||||
|
d3-shape@3.2.0:
|
||||||
|
dependencies:
|
||||||
|
d3-path: 3.1.0
|
||||||
|
|
||||||
|
d3-time-format@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-time: 3.1.0
|
||||||
|
|
||||||
|
d3-time@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
|
||||||
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
data-urls@5.0.0:
|
data-urls@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-mimetype: 4.0.0
|
whatwg-mimetype: 4.0.0
|
||||||
@@ -3850,6 +4186,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decimal.js-light@2.5.1: {}
|
||||||
|
|
||||||
decimal.js@10.6.0: {}
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
deep-eql@5.0.2: {}
|
deep-eql@5.0.2: {}
|
||||||
@@ -3902,6 +4240,8 @@ snapshots:
|
|||||||
has-tostringtag: 1.0.2
|
has-tostringtag: 1.0.2
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
|
|
||||||
|
es-toolkit@1.42.0: {}
|
||||||
|
|
||||||
esbuild@0.21.5:
|
esbuild@0.21.5:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.21.5
|
'@esbuild/aix-ppc64': 0.21.5
|
||||||
@@ -3934,6 +4274,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
|
|
||||||
|
eventemitter3@5.0.1: {}
|
||||||
|
|
||||||
expect-type@1.2.2: {}
|
expect-type@1.2.2: {}
|
||||||
|
|
||||||
fast-glob@3.3.3:
|
fast-glob@3.3.3:
|
||||||
@@ -3966,6 +4308,15 @@ snapshots:
|
|||||||
|
|
||||||
fraction.js@5.3.4: {}
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
|
framer-motion@12.23.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
motion-dom: 12.23.23
|
||||||
|
motion-utils: 12.23.6
|
||||||
|
tslib: 2.8.1
|
||||||
|
optionalDependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4051,8 +4402,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
|
immer@10.2.0: {}
|
||||||
|
|
||||||
|
immer@11.0.1: {}
|
||||||
|
|
||||||
indent-string@4.0.0: {}
|
indent-string@4.0.0: {}
|
||||||
|
|
||||||
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
is-binary-path@2.1.0:
|
is-binary-path@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
binary-extensions: 2.3.0
|
binary-extensions: 2.3.0
|
||||||
@@ -4202,6 +4559,12 @@ snapshots:
|
|||||||
|
|
||||||
min-indent@1.0.1: {}
|
min-indent@1.0.1: {}
|
||||||
|
|
||||||
|
motion-dom@12.23.23:
|
||||||
|
dependencies:
|
||||||
|
motion-utils: 12.23.6
|
||||||
|
|
||||||
|
motion-utils@12.23.6: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2):
|
msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2):
|
||||||
@@ -4352,6 +4715,15 @@ snapshots:
|
|||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
|
|
||||||
|
react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
'@types/use-sync-external-store': 0.0.6
|
||||||
|
react: 18.3.1
|
||||||
|
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.23
|
||||||
|
redux: 5.0.1
|
||||||
|
|
||||||
react-refresh@0.17.0: {}
|
react-refresh@0.17.0: {}
|
||||||
|
|
||||||
react-remove-scroll-bar@2.3.8(@types/react@18.3.23)(react@18.3.1):
|
react-remove-scroll-bar@2.3.8(@types/react@18.3.23)(react@18.3.1):
|
||||||
@@ -4393,13 +4765,41 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
|
|
||||||
|
recharts@3.5.1(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
'@reduxjs/toolkit': 2.11.0(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1)
|
||||||
|
clsx: 2.1.1
|
||||||
|
decimal.js-light: 2.5.1
|
||||||
|
es-toolkit: 1.42.0
|
||||||
|
eventemitter3: 5.0.1
|
||||||
|
immer: 10.2.0
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
react-is: 17.0.2
|
||||||
|
react-redux: 9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1)
|
||||||
|
reselect: 5.1.1
|
||||||
|
tiny-invariant: 1.3.3
|
||||||
|
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||||
|
victory-vendor: 37.3.6
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- redux
|
||||||
|
|
||||||
redent@3.0.0:
|
redent@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
indent-string: 4.0.0
|
indent-string: 4.0.0
|
||||||
strip-indent: 3.0.0
|
strip-indent: 3.0.0
|
||||||
|
|
||||||
|
redux-thunk@3.1.0(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
redux: 5.0.1
|
||||||
|
|
||||||
|
redux@5.0.1: {}
|
||||||
|
|
||||||
require-directory@2.1.1: {}
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
|
reselect@5.1.1: {}
|
||||||
|
|
||||||
resolve@1.22.11:
|
resolve@1.22.11:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
@@ -4545,6 +4945,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
any-promise: 1.3.0
|
any-promise: 1.3.0
|
||||||
|
|
||||||
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
tinyexec@0.3.2: {}
|
tinyexec@0.3.2: {}
|
||||||
@@ -4629,8 +5031,29 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.23
|
'@types/react': 18.3.23
|
||||||
|
|
||||||
|
use-sync-external-store@1.6.0(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
|
victory-vendor@37.3.6:
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-array': 3.2.2
|
||||||
|
'@types/d3-ease': 3.0.2
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-scale': 4.0.9
|
||||||
|
'@types/d3-shape': 3.1.7
|
||||||
|
'@types/d3-time': 3.0.4
|
||||||
|
'@types/d3-timer': 3.0.2
|
||||||
|
d3-array: 3.2.4
|
||||||
|
d3-ease: 3.0.1
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-scale: 4.0.2
|
||||||
|
d3-shape: 3.2.0
|
||||||
|
d3-time: 3.1.0
|
||||||
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
vite-node@2.1.9(@types/node@20.19.9)(lightningcss@1.30.1):
|
vite-node@2.1.9(@types/node@20.19.9)(lightningcss@1.30.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
|
|||||||
Generated
+351
-11
@@ -257,6 +257,28 @@ dependencies = [
|
|||||||
"windows-sys 0.61.1",
|
"windows-sys 0.61.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-stream"
|
||||||
|
version = "0.3.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
||||||
|
dependencies = [
|
||||||
|
"async-stream-impl",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-stream-impl"
|
||||||
|
version = "0.3.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.106",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-task"
|
name = "async-task"
|
||||||
version = "4.7.1"
|
version = "4.7.1"
|
||||||
@@ -320,6 +342,61 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum"
|
||||||
|
version = "0.7.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"itoa",
|
||||||
|
"matchit",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustversion",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower 0.5.2",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-core"
|
||||||
|
version = "0.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustversion",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "backtrace"
|
name = "backtrace"
|
||||||
version = "0.3.76"
|
version = "0.3.76"
|
||||||
@@ -624,14 +701,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.8.3"
|
version = "3.9.0-2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-stream",
|
||||||
"auto-launch",
|
"auto-launch",
|
||||||
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"futures",
|
"futures",
|
||||||
|
"hyper",
|
||||||
"indexmap 2.11.4",
|
"indexmap 2.11.4",
|
||||||
"log",
|
"log",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
@@ -641,6 +722,7 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"rquickjs",
|
"rquickjs",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
"rust_decimal",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
@@ -656,11 +738,14 @@ dependencies = [
|
|||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 2.0.17",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml 0.8.2",
|
"toml 0.8.2",
|
||||||
"toml_edit 0.22.27",
|
"toml_edit 0.22.27",
|
||||||
|
"tower 0.4.13",
|
||||||
|
"tower-http 0.5.2",
|
||||||
"url",
|
"url",
|
||||||
|
"uuid",
|
||||||
"winreg 0.52.0",
|
"winreg 0.52.0",
|
||||||
"zip 2.4.2",
|
"zip 2.4.2",
|
||||||
]
|
]
|
||||||
@@ -789,6 +874,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -812,9 +907,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.9.4",
|
"bitflags 2.9.4",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -825,7 +920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.9.4",
|
"bitflags 2.9.4",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1210,6 +1305,15 @@ version = "1.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -1375,6 +1479,15 @@ version = "1.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared 0.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -1382,7 +1495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1396,6 +1509,12 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1839,6 +1958,25 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap 2.11.4",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1963,6 +2101,12 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.7.0"
|
version = "1.7.0"
|
||||||
@@ -1973,9 +2117,11 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
@@ -2001,6 +2147,22 @@ dependencies = [
|
|||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-tls"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.17"
|
version = "0.1.17"
|
||||||
@@ -2020,9 +2182,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2560,6 +2724,12 @@ version = "0.1.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.6"
|
version = "2.7.6"
|
||||||
@@ -2639,6 +2809,23 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native-tls"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"openssl",
|
||||||
|
"openssl-probe",
|
||||||
|
"openssl-sys",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -3056,6 +3243,50 @@ dependencies = [
|
|||||||
"pathdiff",
|
"pathdiff",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.75"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.9.4",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.106",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.111"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3821,16 +4052,21 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime",
|
||||||
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -3841,10 +4077,11 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower 0.5.2",
|
||||||
"tower-http",
|
"tower-http 0.6.6",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -4088,6 +4325,15 @@ dependencies = [
|
|||||||
"sdd",
|
"sdd",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -4163,6 +4409,29 @@ version = "4.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.9.4",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
@@ -4257,6 +4526,17 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_path_to_error"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_repr"
|
name = "serde_repr"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -4491,7 +4771,7 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
@@ -4632,6 +4912,27 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.9.4",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"system-configuration-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration-sys"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -4653,7 +4954,7 @@ checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.9.4",
|
"bitflags 2.9.4",
|
||||||
"block2 0.6.1",
|
"block2 0.6.1",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
@@ -5293,6 +5594,16 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-native-tls"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||||
|
dependencies = [
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -5430,6 +5741,17 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109"
|
checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower"
|
||||||
|
version = "0.4.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||||
|
dependencies = [
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -5443,6 +5765,23 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-http"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.9.4",
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5458,7 +5797,7 @@ dependencies = [
|
|||||||
"http-body",
|
"http-body",
|
||||||
"iri-string",
|
"iri-string",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tower",
|
"tower 0.5.2",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
@@ -5481,6 +5820,7 @@ version = "0.1.41"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
|
|||||||
+12
-4
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.8.3"
|
version = "3.9.0-2"
|
||||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||||
authors = ["Jason Young"]
|
authors = ["Jason Young"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -37,12 +37,18 @@ 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", default-features = false, features = ["rustls-tls", "json"] }
|
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
async-stream = "0.3"
|
||||||
|
bytes = "1.5"
|
||||||
|
axum = "0.7"
|
||||||
|
tower = "0.4"
|
||||||
|
tower-http = { version = "0.5", features = ["cors"] }
|
||||||
|
hyper = { version = "1.0", features = ["full"] }
|
||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
||||||
thiserror = "1.0"
|
thiserror = "2.0"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
zip = "2.2"
|
zip = "2.2"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
@@ -53,6 +59,8 @@ once_cell = "1.21.3"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
||||||
indexmap = { version = "2", features = ["serde"] }
|
indexmap = { version = "2", features = ["serde"] }
|
||||||
|
rust_decimal = "1.33"
|
||||||
|
uuid = { version = "1.11", features = ["v4"] }
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
//! 故障转移队列命令
|
||||||
|
//!
|
||||||
|
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
|
||||||
|
|
||||||
|
use crate::database::FailoverQueueItem;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
/// 获取故障转移队列
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_failover_queue(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<Vec<FailoverQueueItem>, String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.get_failover_queue(&app_type)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_available_providers_for_failover(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<Vec<Provider>, String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.get_available_providers_for_failover(&app_type)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加供应商到故障转移队列
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn add_to_failover_queue(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
provider_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.add_to_failover_queue(&app_type, &provider_id)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从故障转移队列移除供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn remove_from_failover_queue(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
provider_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.remove_from_failover_queue(&app_type, &provider_id)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取指定应用的自动故障转移开关状态(从 proxy_config 表读取)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_auto_failover_enabled(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.get_proxy_config_for_app(&app_type)
|
||||||
|
.await
|
||||||
|
.map(|config| config.auto_failover_enabled)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置指定应用的自动故障转移开关状态(写入 proxy_config 表)
|
||||||
|
///
|
||||||
|
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_auto_failover_enabled(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!(
|
||||||
|
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 读取当前配置
|
||||||
|
let mut config = state
|
||||||
|
.db
|
||||||
|
.get_proxy_config_for_app(&app_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 更新 auto_failover_enabled 字段
|
||||||
|
config.auto_failover_enabled = enabled;
|
||||||
|
|
||||||
|
// 写回数据库
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.update_proxy_config_for_app(config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ use crate::init_status::InitErrorPayload;
|
|||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
/// 打开外部链接
|
/// 打开外部链接
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
|
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
|
||||||
@@ -58,3 +64,221 @@ pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
|
|||||||
pub async fn get_migration_result() -> Result<bool, String> {
|
pub async fn get_migration_result() -> Result<bool, String> {
|
||||||
Ok(crate::init_status::take_migration_success())
|
Ok(crate::init_status::take_migration_success())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct ToolVersion {
|
||||||
|
name: String,
|
||||||
|
version: Option<String>,
|
||||||
|
latest_version: Option<String>, // 新增字段:最新版本
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
||||||
|
let tools = vec!["claude", "codex", "gemini"];
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
// 用于获取远程版本的 client
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("cc-switch/1.0")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
for tool in tools {
|
||||||
|
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
||||||
|
let (local_version, local_error) = {
|
||||||
|
// 先尝试直接执行
|
||||||
|
let direct_result = try_get_version(tool);
|
||||||
|
|
||||||
|
if direct_result.0.is_some() {
|
||||||
|
direct_result
|
||||||
|
} else {
|
||||||
|
// 扫描常见的 npm 全局安装路径
|
||||||
|
scan_cli_version(tool)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 获取远程最新版本
|
||||||
|
let latest_version = match tool {
|
||||||
|
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
||||||
|
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
||||||
|
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push(ToolVersion {
|
||||||
|
name: tool.to_string(),
|
||||||
|
version: local_version,
|
||||||
|
latest_version,
|
||||||
|
error: local_error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to fetch latest version from npm registry
|
||||||
|
async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Option<String> {
|
||||||
|
let url = format!("https://registry.npmjs.org/{package}");
|
||||||
|
match client.get(&url).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||||
|
json.get("dist-tags")
|
||||||
|
.and_then(|tags| tags.get("latest"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从版本输出中提取纯版本号
|
||||||
|
fn extract_version(raw: &str) -> String {
|
||||||
|
// 匹配 semver 格式: x.y.z 或 x.y.z-xxx
|
||||||
|
let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").unwrap();
|
||||||
|
re.find(raw)
|
||||||
|
.map(|m| m.as_str().to_string())
|
||||||
|
.unwrap_or_else(|| raw.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 尝试直接执行命令获取版本
|
||||||
|
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let output = {
|
||||||
|
Command::new("cmd")
|
||||||
|
.args(["/C", &format!("{tool} --version")])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let output = {
|
||||||
|
Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(format!("{tool} --version"))
|
||||||
|
.output()
|
||||||
|
};
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(out) => {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
|
if out.status.success() {
|
||||||
|
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||||
|
if raw.is_empty() {
|
||||||
|
(None, Some("未安装或无法执行".to_string()))
|
||||||
|
} else {
|
||||||
|
(Some(extract_version(raw)), None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let err = if stderr.is_empty() { stdout } else { stderr };
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
Some(if err.is_empty() {
|
||||||
|
"未安装或无法执行".to_string()
|
||||||
|
} else {
|
||||||
|
err
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => (None, Some(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扫描常见路径查找 CLI
|
||||||
|
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let home = dirs::home_dir().unwrap_or_default();
|
||||||
|
|
||||||
|
// 常见的 npm 全局安装路径
|
||||||
|
let mut search_paths: Vec<std::path::PathBuf> = vec![
|
||||||
|
home.join(".npm-global/bin"),
|
||||||
|
home.join(".local/bin"),
|
||||||
|
home.join("n/bin"), // n version manager
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
search_paths.push(std::path::PathBuf::from("/opt/homebrew/bin"));
|
||||||
|
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
||||||
|
search_paths.push(std::path::PathBuf::from("/usr/bin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
if let Some(appdata) = dirs::data_dir() {
|
||||||
|
search_paths.push(appdata.join("npm"));
|
||||||
|
}
|
||||||
|
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描 nvm 目录下的所有 node 版本
|
||||||
|
let nvm_base = home.join(".nvm/versions/node");
|
||||||
|
if nvm_base.exists() {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&nvm_base) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let bin_path = entry.path().join("bin");
|
||||||
|
if bin_path.exists() {
|
||||||
|
search_paths.push(bin_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在每个路径中查找工具
|
||||||
|
for path in &search_paths {
|
||||||
|
let tool_path = if cfg!(target_os = "windows") {
|
||||||
|
path.join(format!("{tool}.cmd"))
|
||||||
|
} else {
|
||||||
|
path.join(tool)
|
||||||
|
};
|
||||||
|
|
||||||
|
if tool_path.exists() {
|
||||||
|
// 构建 PATH 环境变量,确保 node 可被找到
|
||||||
|
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||||
|
let new_path = format!("{}:{}", path.display(), current_path);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let output = {
|
||||||
|
Command::new(&tool_path)
|
||||||
|
.arg("--version")
|
||||||
|
.env("PATH", &new_path)
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let output = {
|
||||||
|
Command::new(&tool_path)
|
||||||
|
.arg("--version")
|
||||||
|
.env("PATH", &new_path)
|
||||||
|
.output()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(out) = output {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
|
if out.status.success() {
|
||||||
|
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||||
|
if !raw.is_empty() {
|
||||||
|
return (Some(extract_version(raw)), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(None, Some("未安装或无法执行".to_string()))
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,23 +3,31 @@
|
|||||||
mod config;
|
mod config;
|
||||||
mod deeplink;
|
mod deeplink;
|
||||||
mod env;
|
mod env;
|
||||||
|
mod failover;
|
||||||
mod import_export;
|
mod import_export;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod misc;
|
mod misc;
|
||||||
mod plugin;
|
mod plugin;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod provider;
|
mod provider;
|
||||||
|
mod proxy;
|
||||||
mod settings;
|
mod settings;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
|
mod stream_check;
|
||||||
|
mod usage;
|
||||||
|
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
pub use deeplink::*;
|
pub use deeplink::*;
|
||||||
pub use env::*;
|
pub use env::*;
|
||||||
|
pub use failover::*;
|
||||||
pub use import_export::*;
|
pub use import_export::*;
|
||||||
pub use mcp::*;
|
pub use mcp::*;
|
||||||
pub use misc::*;
|
pub use misc::*;
|
||||||
pub use plugin::*;
|
pub use plugin::*;
|
||||||
pub use prompt::*;
|
pub use prompt::*;
|
||||||
pub use provider::*;
|
pub use provider::*;
|
||||||
|
pub use proxy::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use skill::*;
|
pub use skill::*;
|
||||||
|
pub use stream_check::*;
|
||||||
|
pub use usage::*;
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
//! 代理服务相关的 Tauri 命令
|
||||||
|
//!
|
||||||
|
//! 提供前端调用的 API 接口
|
||||||
|
|
||||||
|
use crate::proxy::types::*;
|
||||||
|
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
/// 启动代理服务器(仅启动服务,不接管 Live 配置)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_proxy_server(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<ProxyServerInfo, String> {
|
||||||
|
state.proxy_service.start().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止代理服务器(恢复 Live 配置)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||||
|
state.proxy_service.stop_with_restore().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取各应用接管状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_proxy_takeover_status(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<ProxyTakeoverStatus, String> {
|
||||||
|
state.proxy_service.get_takeover_status().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为指定应用开启/关闭接管
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_proxy_takeover_for_app(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.proxy_service
|
||||||
|
.set_takeover_for_app(&app_type, enabled)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取代理服务器状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result<ProxyStatus, String> {
|
||||||
|
state.proxy_service.get_status().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取代理配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_proxy_config(state: tauri::State<'_, AppState>) -> Result<ProxyConfig, String> {
|
||||||
|
state.proxy_service.get_config().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新代理配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_proxy_config(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
config: ProxyConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.proxy_service.update_config(&config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Global & Per-App Config ====================
|
||||||
|
|
||||||
|
/// 获取全局代理配置
|
||||||
|
///
|
||||||
|
/// 返回统一的全局配置字段(代理开关、监听地址、端口、日志开关)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_global_proxy_config(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<GlobalProxyConfig, String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.get_global_proxy_config()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新全局代理配置
|
||||||
|
///
|
||||||
|
/// 更新统一的全局配置字段,会同时更新三行(claude/codex/gemini)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_global_proxy_config(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
config: GlobalProxyConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.update_global_proxy_config(config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取指定应用的代理配置
|
||||||
|
///
|
||||||
|
/// 返回应用级配置(enabled、auto_failover、超时、熔断器等)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_proxy_config_for_app(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<AppProxyConfig, String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.get_proxy_config_for_app(&app_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新指定应用的代理配置
|
||||||
|
///
|
||||||
|
/// 更新应用级配置(enabled、auto_failover、超时、熔断器等)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_proxy_config_for_app(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
config: AppProxyConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.update_proxy_config_for_app(config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查代理服务器是否正在运行
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||||
|
Ok(state.proxy_service.is_running().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否处于 Live 接管模式
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn is_live_takeover_active(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||||
|
state.proxy_service.is_takeover_active().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 代理模式下切换供应商(热切换)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn switch_proxy_provider(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
provider_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.proxy_service
|
||||||
|
.switch_proxy_target(&app_type, &provider_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 故障转移相关命令 ====================
|
||||||
|
|
||||||
|
/// 获取供应商健康状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_provider_health(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
provider_id: String,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<ProviderHealth, String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.get_provider_health(&provider_id, &app_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置熔断器
|
||||||
|
///
|
||||||
|
/// 重置后会检查是否应该切回队列中优先级更高的供应商:
|
||||||
|
/// 1. 检查自动故障转移是否开启
|
||||||
|
/// 2. 如果恢复的供应商在队列中优先级更高(queue_order 更小),则自动切换
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn reset_circuit_breaker(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
provider_id: String,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// 1. 重置数据库健康状态
|
||||||
|
let db = &state.db;
|
||||||
|
db.update_provider_health(&provider_id, &app_type, true, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 2. 如果代理正在运行,重置内存中的熔断器状态
|
||||||
|
state
|
||||||
|
.proxy_service
|
||||||
|
.reset_provider_circuit_breaker(&provider_id, &app_type)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取)
|
||||||
|
let auto_failover_enabled = match db.get_proxy_config_for_app(&app_type).await {
|
||||||
|
Ok(config) => config.auto_failover_enabled,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if auto_failover_enabled && state.proxy_service.is_running().await {
|
||||||
|
// 获取当前供应商 ID
|
||||||
|
let current_id = db
|
||||||
|
.get_current_provider(&app_type)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if let Some(current_id) = current_id {
|
||||||
|
// 获取故障转移队列
|
||||||
|
let queue = db
|
||||||
|
.get_failover_queue(&app_type)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 找到恢复的供应商和当前供应商在队列中的位置(使用 sort_index)
|
||||||
|
let restored_order = queue
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.provider_id == provider_id)
|
||||||
|
.and_then(|item| item.sort_index);
|
||||||
|
|
||||||
|
let current_order = queue
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.provider_id == current_id)
|
||||||
|
.and_then(|item| item.sort_index);
|
||||||
|
|
||||||
|
// 如果恢复的供应商优先级更高(sort_index 更小),则切换
|
||||||
|
if let (Some(restored), Some(current)) = (restored_order, current_order) {
|
||||||
|
if restored < current {
|
||||||
|
log::info!(
|
||||||
|
"[Recovery] 供应商 {provider_id} 已恢复且优先级更高 (P{restored} vs P{current}),自动切换"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取供应商名称用于日志和事件
|
||||||
|
let provider_name = db
|
||||||
|
.get_all_providers(&app_type)
|
||||||
|
.ok()
|
||||||
|
.and_then(|providers| providers.get(&provider_id).map(|p| p.name.clone()))
|
||||||
|
.unwrap_or_else(|| provider_id.clone());
|
||||||
|
|
||||||
|
// 创建故障转移切换管理器并执行切换
|
||||||
|
let switch_manager =
|
||||||
|
crate::proxy::failover_switch::FailoverSwitchManager::new(db.clone());
|
||||||
|
if let Err(e) = switch_manager
|
||||||
|
.try_switch(Some(&app_handle), &app_type, &provider_id, &provider_name)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::error!("[Recovery] 自动切换失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取熔断器配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_circuit_breaker_config(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<CircuitBreakerConfig, String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.get_circuit_breaker_config()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新熔断器配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_circuit_breaker_config(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
config: CircuitBreakerConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db = &state.db;
|
||||||
|
|
||||||
|
// 1. 更新数据库配置
|
||||||
|
db.update_circuit_breaker_config(&config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 2. 如果代理正在运行,热更新内存中的熔断器配置
|
||||||
|
state
|
||||||
|
.proxy_service
|
||||||
|
.update_circuit_breaker_configs(config)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取熔断器统计信息(仅当代理服务器运行时)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_circuit_breaker_stats(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
provider_id: String,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<Option<CircuitBreakerStats>, String> {
|
||||||
|
// 这个功能需要访问运行中的代理服务器的内存状态
|
||||||
|
// 目前先返回 None,后续可以通过 ProxyService 暴露接口来实现
|
||||||
|
let _ = (state, provider_id, app_type);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::app_config::AppType;
|
||||||
use crate::error::format_skill_error;
|
use crate::error::format_skill_error;
|
||||||
use crate::services::skill::SkillState;
|
use crate::services::skill::SkillState;
|
||||||
use crate::services::{Skill, SkillRepo, SkillService};
|
use crate::services::{Skill, SkillRepo, SkillService};
|
||||||
@@ -8,15 +9,46 @@ use tauri::State;
|
|||||||
|
|
||||||
pub struct SkillServiceState(pub Arc<SkillService>);
|
pub struct SkillServiceState(pub Arc<SkillService>);
|
||||||
|
|
||||||
|
/// 解析 app 参数为 AppType
|
||||||
|
fn parse_app_type(app: &str) -> Result<AppType, String> {
|
||||||
|
match app.to_lowercase().as_str() {
|
||||||
|
"claude" => Ok(AppType::Claude),
|
||||||
|
"codex" => Ok(AppType::Codex),
|
||||||
|
"gemini" => Ok(AppType::Gemini),
|
||||||
|
_ => Err(format!("不支持的 app 类型: {app}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据 app_type 生成带前缀的 skill key
|
||||||
|
fn get_skill_key(app_type: &AppType, directory: &str) -> String {
|
||||||
|
let prefix = match app_type {
|
||||||
|
AppType::Claude => "claude",
|
||||||
|
AppType::Codex => "codex",
|
||||||
|
AppType::Gemini => "gemini",
|
||||||
|
};
|
||||||
|
format!("{prefix}:{directory}")
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_skills(
|
pub async fn get_skills(
|
||||||
service: State<'_, SkillServiceState>,
|
service: State<'_, SkillServiceState>,
|
||||||
app_state: State<'_, AppState>,
|
app_state: State<'_, AppState>,
|
||||||
) -> Result<Vec<Skill>, String> {
|
) -> Result<Vec<Skill>, String> {
|
||||||
|
get_skills_for_app("claude".to_string(), service, app_state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_skills_for_app(
|
||||||
|
app: String,
|
||||||
|
_service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<Skill>, String> {
|
||||||
|
let app_type = parse_app_type(&app)?;
|
||||||
|
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let skills = service
|
let skills = service
|
||||||
.0
|
|
||||||
.list_skills(repos)
|
.list_skills(repos)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -26,16 +58,19 @@ pub async fn get_skills(
|
|||||||
let existing_states = app_state.db.get_skills().unwrap_or_default();
|
let existing_states = app_state.db.get_skills().unwrap_or_default();
|
||||||
|
|
||||||
for skill in &skills {
|
for skill in &skills {
|
||||||
if skill.installed && !existing_states.contains_key(&skill.directory) {
|
if skill.installed {
|
||||||
// 本地有该 skill,但数据库中没有记录,自动添加
|
let key = get_skill_key(&app_type, &skill.directory);
|
||||||
if let Err(e) = app_state.db.update_skill_state(
|
if !existing_states.contains_key(&key) {
|
||||||
&skill.directory,
|
// 本地有该 skill,但数据库中没有记录,自动添加
|
||||||
&SkillState {
|
if let Err(e) = app_state.db.update_skill_state(
|
||||||
installed: true,
|
&key,
|
||||||
installed_at: Utc::now(),
|
&SkillState {
|
||||||
},
|
installed: true,
|
||||||
) {
|
installed_at: Utc::now(),
|
||||||
log::warn!("同步本地 skill {} 状态到数据库失败: {}", skill.directory, e);
|
},
|
||||||
|
) {
|
||||||
|
log::warn!("同步本地 skill {key} 状态到数据库失败: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,11 +84,23 @@ pub async fn install_skill(
|
|||||||
service: State<'_, SkillServiceState>,
|
service: State<'_, SkillServiceState>,
|
||||||
app_state: State<'_, AppState>,
|
app_state: State<'_, AppState>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
|
install_skill_for_app("claude".to_string(), directory, service, app_state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_skill_for_app(
|
||||||
|
app: String,
|
||||||
|
directory: String,
|
||||||
|
_service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = parse_app_type(&app)?;
|
||||||
|
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// 先在不持有写锁的情况下收集仓库与技能信息
|
// 先在不持有写锁的情况下收集仓库与技能信息
|
||||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let skills = service
|
let skills = service
|
||||||
.0
|
|
||||||
.list_skills(repos)
|
.list_skills(repos)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -93,16 +140,16 @@ pub async fn install_skill(
|
|||||||
};
|
};
|
||||||
|
|
||||||
service
|
service
|
||||||
.0
|
|
||||||
.install_skill(directory.clone(), repo)
|
.install_skill(directory.clone(), repo)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let key = get_skill_key(&app_type, &directory);
|
||||||
app_state
|
app_state
|
||||||
.db
|
.db
|
||||||
.update_skill_state(
|
.update_skill_state(
|
||||||
&directory,
|
&key,
|
||||||
&SkillState {
|
&SkillState {
|
||||||
installed: true,
|
installed: true,
|
||||||
installed_at: Utc::now(),
|
installed_at: Utc::now(),
|
||||||
@@ -119,16 +166,29 @@ pub fn uninstall_skill(
|
|||||||
service: State<'_, SkillServiceState>,
|
service: State<'_, SkillServiceState>,
|
||||||
app_state: State<'_, AppState>,
|
app_state: State<'_, AppState>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
|
uninstall_skill_for_app("claude".to_string(), directory, service, app_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn uninstall_skill_for_app(
|
||||||
|
app: String,
|
||||||
|
directory: String,
|
||||||
|
_service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = parse_app_type(&app)?;
|
||||||
|
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
service
|
service
|
||||||
.0
|
|
||||||
.uninstall_skill(directory.clone())
|
.uninstall_skill(directory.clone())
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Remove from database by setting installed = false
|
// Remove from database by setting installed = false
|
||||||
|
let key = get_skill_key(&app_type, &directory);
|
||||||
app_state
|
app_state
|
||||||
.db
|
.db
|
||||||
.update_skill_state(
|
.update_skill_state(
|
||||||
&directory,
|
&key,
|
||||||
&SkillState {
|
&SkillState {
|
||||||
installed: false,
|
installed: false,
|
||||||
installed_at: Utc::now(),
|
installed_at: Utc::now(),
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! 流式健康检查命令
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::stream_check::{
|
||||||
|
HealthStatus, StreamCheckConfig, StreamCheckResult, StreamCheckService,
|
||||||
|
};
|
||||||
|
use crate::store::AppState;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
/// 流式健康检查(单个供应商)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stream_check_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_type: AppType,
|
||||||
|
provider_id: String,
|
||||||
|
) -> Result<StreamCheckResult, AppError> {
|
||||||
|
let config = state.db.get_stream_check_config()?;
|
||||||
|
|
||||||
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
|
let provider = providers
|
||||||
|
.get(&provider_id)
|
||||||
|
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
||||||
|
|
||||||
|
let result = StreamCheckService::check_with_retry(&app_type, provider, &config).await?;
|
||||||
|
|
||||||
|
// 记录日志
|
||||||
|
let _ =
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.save_stream_check_log(&provider_id, &provider.name, app_type.as_str(), &result);
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量流式健康检查
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stream_check_all_providers(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_type: AppType,
|
||||||
|
proxy_targets_only: bool,
|
||||||
|
) -> Result<Vec<(String, StreamCheckResult)>, AppError> {
|
||||||
|
let config = state.db.get_stream_check_config()?;
|
||||||
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let allowed_ids: Option<HashSet<String>> = if proxy_targets_only {
|
||||||
|
let mut ids = HashSet::new();
|
||||||
|
if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) {
|
||||||
|
ids.insert(current_id);
|
||||||
|
}
|
||||||
|
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
|
||||||
|
for item in queue {
|
||||||
|
ids.insert(item.provider_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ids)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
for (id, provider) in providers {
|
||||||
|
if let Some(ids) = &allowed_ids {
|
||||||
|
if !ids.contains(&id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = StreamCheckService::check_with_retry(&app_type, &provider, &config)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| StreamCheckResult {
|
||||||
|
status: HealthStatus::Failed,
|
||||||
|
success: false,
|
||||||
|
message: e.to_string(),
|
||||||
|
response_time_ms: None,
|
||||||
|
http_status: None,
|
||||||
|
model_used: String::new(),
|
||||||
|
tested_at: chrono::Utc::now().timestamp(),
|
||||||
|
retry_count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = state
|
||||||
|
.db
|
||||||
|
.save_stream_check_log(&id, &provider.name, app_type.as_str(), &result);
|
||||||
|
|
||||||
|
results.push((id, result));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取流式检查配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_stream_check_config(state: State<'_, AppState>) -> Result<StreamCheckConfig, AppError> {
|
||||||
|
state.db.get_stream_check_config()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存流式检查配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_stream_check_config(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
config: StreamCheckConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
state.db.save_stream_check_config(&config)
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
//! 使用统计相关命令
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::usage_stats::*;
|
||||||
|
use crate::store::AppState;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
/// 获取使用量汇总
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_usage_summary(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
start_date: Option<i64>,
|
||||||
|
end_date: Option<i64>,
|
||||||
|
) -> Result<UsageSummary, AppError> {
|
||||||
|
state.db.get_usage_summary(start_date, end_date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取每日趋势
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_usage_trends(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
days: u32,
|
||||||
|
) -> Result<Vec<DailyStats>, AppError> {
|
||||||
|
state.db.get_daily_trends(days)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Provider 统计
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_provider_stats(state: State<'_, AppState>) -> Result<Vec<ProviderStats>, AppError> {
|
||||||
|
state.db.get_provider_stats()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取模型统计
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_model_stats(state: State<'_, AppState>) -> Result<Vec<ModelStats>, AppError> {
|
||||||
|
state.db.get_model_stats()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取请求日志列表
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_request_logs(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
filters: LogFilters,
|
||||||
|
page: u32,
|
||||||
|
page_size: u32,
|
||||||
|
) -> Result<PaginatedLogs, AppError> {
|
||||||
|
state.db.get_request_logs(&filters, page, page_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取单个请求详情
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_request_detail(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
request_id: String,
|
||||||
|
) -> Result<Option<RequestLogDetail>, AppError> {
|
||||||
|
state.db.get_request_detail(&request_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取模型定价列表
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_model_pricing(state: State<'_, AppState>) -> Result<Vec<ModelPricingInfo>, AppError> {
|
||||||
|
log::info!("获取模型定价列表");
|
||||||
|
state.db.ensure_model_pricing_seeded()?;
|
||||||
|
|
||||||
|
let db = state.db.clone();
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
|
||||||
|
// 检查表是否存在
|
||||||
|
let table_exists: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='model_pricing'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0).map(|count| count > 0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !table_exists {
|
||||||
|
log::error!("model_pricing 表不存在,可能需要重启应用以触发数据库迁移");
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||||
|
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||||
|
FROM model_pricing
|
||||||
|
ORDER BY display_name",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok(ModelPricingInfo {
|
||||||
|
model_id: row.get(0)?,
|
||||||
|
display_name: row.get(1)?,
|
||||||
|
input_cost_per_million: row.get(2)?,
|
||||||
|
output_cost_per_million: row.get(3)?,
|
||||||
|
cache_read_cost_per_million: row.get(4)?,
|
||||||
|
cache_creation_cost_per_million: row.get(5)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut pricing = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
pricing.push(row?);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("成功获取 {} 条模型定价数据", pricing.len());
|
||||||
|
Ok(pricing)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新模型定价
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_model_pricing(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
model_id: String,
|
||||||
|
display_name: String,
|
||||||
|
input_cost: String,
|
||||||
|
output_cost: String,
|
||||||
|
cache_read_cost: String,
|
||||||
|
cache_creation_cost: String,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let db = state.db.clone();
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO model_pricing (
|
||||||
|
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||||
|
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
rusqlite::params![
|
||||||
|
model_id,
|
||||||
|
display_name,
|
||||||
|
input_cost,
|
||||||
|
output_cost,
|
||||||
|
cache_read_cost,
|
||||||
|
cache_creation_cost
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查 Provider 使用限额
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn check_provider_limits(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
provider_id: String,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<crate::services::usage_stats::ProviderLimitStatus, AppError> {
|
||||||
|
state.db.check_provider_limits(&provider_id, &app_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除模型定价
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Result<(), AppError> {
|
||||||
|
let db = state.db.clone();
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM model_pricing WHERE model_id = ?1",
|
||||||
|
rusqlite::params![model_id],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("删除模型定价失败: {e}")))?;
|
||||||
|
|
||||||
|
log::info!("已删除模型定价: {model_id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模型定价信息
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelPricingInfo {
|
||||||
|
pub model_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub input_cost_per_million: String,
|
||||||
|
pub output_cost_per_million: String,
|
||||||
|
pub cache_read_cost_per_million: String,
|
||||||
|
pub cache_creation_cost_per_million: String,
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
//! 故障转移队列 DAO
|
||||||
|
//!
|
||||||
|
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
|
||||||
|
|
||||||
|
use crate::database::{lock_conn, Database};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 故障转移队列条目(简化版,用于前端展示)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FailoverQueueItem {
|
||||||
|
pub provider_id: String,
|
||||||
|
pub provider_name: String,
|
||||||
|
pub sort_index: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// 获取故障转移队列(按 sort_index 排序)
|
||||||
|
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, name, sort_index
|
||||||
|
FROM providers
|
||||||
|
WHERE app_type = ?1 AND in_failover_queue = 1
|
||||||
|
ORDER BY COALESCE(sort_index, 999999), id ASC",
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let items = stmt
|
||||||
|
.query_map([app_type], |row| {
|
||||||
|
Ok(FailoverQueueItem {
|
||||||
|
provider_id: row.get(0)?,
|
||||||
|
provider_name: row.get(1)?,
|
||||||
|
sort_index: row.get(2)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
|
||||||
|
pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||||
|
let all_providers = self.get_all_providers(app_type)?;
|
||||||
|
|
||||||
|
let result: Vec<Provider> = all_providers
|
||||||
|
.into_values()
|
||||||
|
.filter(|p| p.in_failover_queue)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加供应商到故障转移队列
|
||||||
|
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE providers SET in_failover_queue = 1 WHERE id = ?1 AND app_type = ?2",
|
||||||
|
rusqlite::params![provider_id, app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从故障转移队列中移除供应商
|
||||||
|
pub fn remove_from_failover_queue(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
provider_id: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
// 1. 从队列中移除
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE providers SET in_failover_queue = 0 WHERE id = ?1 AND app_type = ?2",
|
||||||
|
rusqlite::params![provider_id, app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 2. 清除该供应商的健康状态(退出队列后不再需要健康监控)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
|
||||||
|
rusqlite::params![provider_id, app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::info!("已从故障转移队列移除供应商 {provider_id} ({app_type}), 并清除其健康状态");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空故障转移队列
|
||||||
|
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE providers SET in_failover_queue = 0 WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查供应商是否在故障转移队列中
|
||||||
|
pub fn is_in_failover_queue(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
provider_id: &str,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
let in_queue: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
|
rusqlite::params![provider_id, app_type],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
Ok(in_queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
||||||
|
pub fn get_available_providers_for_failover(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<Vec<Provider>, AppError> {
|
||||||
|
let all_providers = self.get_all_providers(app_type)?;
|
||||||
|
|
||||||
|
let available: Vec<Provider> = all_providers
|
||||||
|
.into_values()
|
||||||
|
.filter(|p| !p.in_failover_queue)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(available)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
//! 数据访问对象 (DAO) 模块
|
//! Data Access Object layer
|
||||||
//!
|
//!
|
||||||
//! 提供各类数据的 CRUD 操作。
|
//! Database access operations for each domain
|
||||||
|
|
||||||
mod mcp;
|
pub mod failover;
|
||||||
mod prompts;
|
pub mod mcp;
|
||||||
mod providers;
|
pub mod prompts;
|
||||||
mod settings;
|
pub mod providers;
|
||||||
mod skills;
|
pub mod proxy;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod skills;
|
||||||
|
pub mod stream_check;
|
||||||
|
|
||||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||||
|
// 导出 FailoverQueueItem 供外部使用
|
||||||
|
pub use failover::FailoverQueueItem;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ impl Database {
|
|||||||
) -> Result<IndexMap<String, Provider>, AppError> {
|
) -> Result<IndexMap<String, Provider>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||||
FROM providers WHERE app_type = ?1
|
FROM providers WHERE app_type = ?1
|
||||||
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
|
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
|
||||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -35,6 +35,7 @@ impl Database {
|
|||||||
let icon: Option<String> = row.get(8)?;
|
let icon: Option<String> = row.get(8)?;
|
||||||
let icon_color: Option<String> = row.get(9)?;
|
let icon_color: Option<String> = row.get(9)?;
|
||||||
let meta_str: String = row.get(10)?;
|
let meta_str: String = row.get(10)?;
|
||||||
|
let in_failover_queue: bool = row.get(11)?;
|
||||||
|
|
||||||
let settings_config =
|
let settings_config =
|
||||||
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||||
@@ -54,6 +55,7 @@ impl Database {
|
|||||||
meta: Some(meta),
|
meta: Some(meta),
|
||||||
icon,
|
icon,
|
||||||
icon_color,
|
icon_color,
|
||||||
|
in_failover_queue,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
@@ -121,6 +123,57 @@ impl Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 根据 ID 获取单个供应商
|
||||||
|
pub fn get_provider_by_id(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<Option<Provider>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let result = conn.query_row(
|
||||||
|
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||||
|
FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
|
params![id, app_type],
|
||||||
|
|row| {
|
||||||
|
let name: String = row.get(0)?;
|
||||||
|
let settings_config_str: String = row.get(1)?;
|
||||||
|
let website_url: Option<String> = row.get(2)?;
|
||||||
|
let category: Option<String> = row.get(3)?;
|
||||||
|
let created_at: Option<i64> = row.get(4)?;
|
||||||
|
let sort_index: Option<usize> = row.get(5)?;
|
||||||
|
let notes: Option<String> = row.get(6)?;
|
||||||
|
let icon: Option<String> = row.get(7)?;
|
||||||
|
let icon_color: Option<String> = row.get(8)?;
|
||||||
|
let meta_str: String = row.get(9)?;
|
||||||
|
let in_failover_queue: bool = row.get(10)?;
|
||||||
|
|
||||||
|
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||||
|
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(Provider {
|
||||||
|
id: id.to_string(),
|
||||||
|
name,
|
||||||
|
settings_config,
|
||||||
|
website_url,
|
||||||
|
category,
|
||||||
|
created_at,
|
||||||
|
sort_index,
|
||||||
|
notes,
|
||||||
|
meta: Some(meta),
|
||||||
|
icon,
|
||||||
|
icon_color,
|
||||||
|
in_failover_queue,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(provider) => Ok(Some(provider)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 保存供应商(新增或更新)
|
/// 保存供应商(新增或更新)
|
||||||
///
|
///
|
||||||
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
|
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
|
||||||
@@ -135,17 +188,18 @@ impl Database {
|
|||||||
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
||||||
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
||||||
|
|
||||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current)
|
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue)
|
||||||
let existing: Option<bool> = tx
|
let existing: Option<(bool, bool)> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
|
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
params![provider.id, app_type],
|
params![provider.id, app_type],
|
||||||
|row| row.get(0),
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
let is_update = existing.is_some();
|
let is_update = existing.is_some();
|
||||||
let is_current = existing.unwrap_or(false);
|
let (is_current, in_failover_queue) =
|
||||||
|
existing.unwrap_or((false, provider.in_failover_queue));
|
||||||
|
|
||||||
if is_update {
|
if is_update {
|
||||||
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
||||||
@@ -161,8 +215,9 @@ impl Database {
|
|||||||
icon = ?8,
|
icon = ?8,
|
||||||
icon_color = ?9,
|
icon_color = ?9,
|
||||||
meta = ?10,
|
meta = ?10,
|
||||||
is_current = ?11
|
is_current = ?11,
|
||||||
WHERE id = ?12 AND app_type = ?13",
|
in_failover_queue = ?12
|
||||||
|
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).unwrap(),
|
||||||
@@ -175,6 +230,7 @@ impl Database {
|
|||||||
provider.icon_color,
|
provider.icon_color,
|
||||||
serde_json::to_string(&meta_clone).unwrap(),
|
serde_json::to_string(&meta_clone).unwrap(),
|
||||||
is_current,
|
is_current,
|
||||||
|
in_failover_queue,
|
||||||
provider.id,
|
provider.id,
|
||||||
app_type,
|
app_type,
|
||||||
],
|
],
|
||||||
@@ -185,8 +241,8 @@ impl Database {
|
|||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO providers (
|
"INSERT INTO providers (
|
||||||
id, app_type, name, settings_config, website_url, category,
|
id, app_type, name, settings_config, website_url, category,
|
||||||
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
created_at, sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||||
params![
|
params![
|
||||||
provider.id,
|
provider.id,
|
||||||
app_type,
|
app_type,
|
||||||
@@ -201,6 +257,7 @@ impl Database {
|
|||||||
provider.icon_color,
|
provider.icon_color,
|
||||||
serde_json::to_string(&meta_clone).unwrap(),
|
serde_json::to_string(&meta_clone).unwrap(),
|
||||||
is_current,
|
is_current,
|
||||||
|
in_failover_queue,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -256,6 +313,26 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新供应商的 settings_config(仅更新配置,不改变其他字段)
|
||||||
|
pub fn update_provider_settings_config(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
provider_id: &str,
|
||||||
|
settings_config: &serde_json::Value,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||||
|
params![
|
||||||
|
serde_json::to_string(settings_config).unwrap(),
|
||||||
|
provider_id,
|
||||||
|
app_type
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 添加自定义端点
|
/// 添加自定义端点
|
||||||
pub fn add_custom_endpoint(
|
pub fn add_custom_endpoint(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -0,0 +1,617 @@
|
|||||||
|
//! 代理功能数据访问层
|
||||||
|
//!
|
||||||
|
//! 处理代理配置、Provider健康状态和使用统计的数据库操作
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::proxy::types::*;
|
||||||
|
|
||||||
|
use super::super::{lock_conn, Database};
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
// ==================== Global Proxy Config ====================
|
||||||
|
|
||||||
|
/// 获取全局代理配置(统一字段)
|
||||||
|
///
|
||||||
|
/// 从 claude 行读取(三行镜像一致)
|
||||||
|
pub async fn get_global_proxy_config(&self) -> Result<GlobalProxyConfig, AppError> {
|
||||||
|
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||||
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT proxy_enabled, listen_address, listen_port, enable_logging
|
||||||
|
FROM proxy_config WHERE app_type = 'claude'",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok(GlobalProxyConfig {
|
||||||
|
proxy_enabled: row.get::<_, i32>(0)? != 0,
|
||||||
|
listen_address: row.get(1)?,
|
||||||
|
listen_port: row.get::<_, i32>(2)? as u16,
|
||||||
|
enable_logging: row.get::<_, i32>(3)? != 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// conn 已在 block 结束时释放
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
|
// 如果不存在,创建默认配置
|
||||||
|
self.init_proxy_config_rows().await?;
|
||||||
|
Ok(GlobalProxyConfig {
|
||||||
|
proxy_enabled: false,
|
||||||
|
listen_address: "127.0.0.1".to_string(),
|
||||||
|
listen_port: 5000,
|
||||||
|
enable_logging: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新全局代理配置(镜像写三行)
|
||||||
|
pub async fn update_global_proxy_config(
|
||||||
|
&self,
|
||||||
|
config: GlobalProxyConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE proxy_config SET
|
||||||
|
proxy_enabled = ?1,
|
||||||
|
listen_address = ?2,
|
||||||
|
listen_port = ?3,
|
||||||
|
enable_logging = ?4,
|
||||||
|
updated_at = datetime('now')",
|
||||||
|
rusqlite::params![
|
||||||
|
if config.proxy_enabled { 1 } else { 0 },
|
||||||
|
config.listen_address,
|
||||||
|
config.listen_port as i32,
|
||||||
|
if config.enable_logging { 1 } else { 0 },
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取应用级代理配置
|
||||||
|
pub async fn get_proxy_config_for_app(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<AppProxyConfig, AppError> {
|
||||||
|
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||||
|
let app_type_owned = app_type.to_string();
|
||||||
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT app_type, enabled, auto_failover_enabled,
|
||||||
|
max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||||
|
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
|
circuit_error_rate_threshold, circuit_min_requests
|
||||||
|
FROM proxy_config WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
|row| {
|
||||||
|
Ok(AppProxyConfig {
|
||||||
|
app_type: row.get(0)?,
|
||||||
|
enabled: row.get::<_, i32>(1)? != 0,
|
||||||
|
auto_failover_enabled: row.get::<_, i32>(2)? != 0,
|
||||||
|
max_retries: row.get::<_, i32>(3)? as u32,
|
||||||
|
streaming_first_byte_timeout: row.get::<_, i32>(4)? as u32,
|
||||||
|
streaming_idle_timeout: row.get::<_, i32>(5)? as u32,
|
||||||
|
non_streaming_timeout: row.get::<_, i32>(6)? as u32,
|
||||||
|
circuit_failure_threshold: row.get::<_, i32>(7)? as u32,
|
||||||
|
circuit_success_threshold: row.get::<_, i32>(8)? as u32,
|
||||||
|
circuit_timeout_seconds: row.get::<_, i32>(9)? as u32,
|
||||||
|
circuit_error_rate_threshold: row.get(10)?,
|
||||||
|
circuit_min_requests: row.get::<_, i32>(11)? as u32,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// conn 已在 block 结束时释放
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
|
// 如果不存在,创建默认配置
|
||||||
|
self.init_proxy_config_rows().await?;
|
||||||
|
Ok(AppProxyConfig {
|
||||||
|
app_type: app_type_owned,
|
||||||
|
enabled: false,
|
||||||
|
auto_failover_enabled: false,
|
||||||
|
max_retries: 3,
|
||||||
|
streaming_first_byte_timeout: 30,
|
||||||
|
streaming_idle_timeout: 60,
|
||||||
|
non_streaming_timeout: 300,
|
||||||
|
circuit_failure_threshold: 5,
|
||||||
|
circuit_success_threshold: 2,
|
||||||
|
circuit_timeout_seconds: 60,
|
||||||
|
circuit_error_rate_threshold: 0.5,
|
||||||
|
circuit_min_requests: 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新应用级代理配置
|
||||||
|
pub async fn update_proxy_config_for_app(
|
||||||
|
&self,
|
||||||
|
config: AppProxyConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE proxy_config SET
|
||||||
|
enabled = ?2,
|
||||||
|
auto_failover_enabled = ?3,
|
||||||
|
max_retries = ?4,
|
||||||
|
streaming_first_byte_timeout = ?5,
|
||||||
|
streaming_idle_timeout = ?6,
|
||||||
|
non_streaming_timeout = ?7,
|
||||||
|
circuit_failure_threshold = ?8,
|
||||||
|
circuit_success_threshold = ?9,
|
||||||
|
circuit_timeout_seconds = ?10,
|
||||||
|
circuit_error_rate_threshold = ?11,
|
||||||
|
circuit_min_requests = ?12,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE app_type = ?1",
|
||||||
|
rusqlite::params![
|
||||||
|
config.app_type,
|
||||||
|
if config.enabled { 1 } else { 0 },
|
||||||
|
if config.auto_failover_enabled { 1 } else { 0 },
|
||||||
|
config.max_retries as i32,
|
||||||
|
config.streaming_first_byte_timeout as i32,
|
||||||
|
config.streaming_idle_timeout as i32,
|
||||||
|
config.non_streaming_timeout as i32,
|
||||||
|
config.circuit_failure_threshold as i32,
|
||||||
|
config.circuit_success_threshold as i32,
|
||||||
|
config.circuit_timeout_seconds as i32,
|
||||||
|
config.circuit_error_rate_threshold,
|
||||||
|
config.circuit_min_requests as i32,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化 proxy_config 表的三行数据
|
||||||
|
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
for app_type in &["claude", "codex", "gemini"] {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
|
||||||
|
[app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Legacy Proxy Config (兼容旧代码) ====================
|
||||||
|
|
||||||
|
/// 获取代理配置(兼容旧接口,返回 claude 行的配置)
|
||||||
|
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
|
||||||
|
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||||
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT listen_address, listen_port, max_retries,
|
||||||
|
enable_logging,
|
||||||
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
|
||||||
|
FROM proxy_config WHERE app_type = 'claude'",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok(ProxyConfig {
|
||||||
|
listen_address: row.get(0)?,
|
||||||
|
listen_port: row.get::<_, i32>(1)? as u16,
|
||||||
|
max_retries: row.get::<_, i32>(2)? as u8,
|
||||||
|
request_timeout: 300, // 废弃字段,返回默认值
|
||||||
|
enable_logging: row.get::<_, i32>(3)? != 0,
|
||||||
|
live_takeover_active: false, // 废弃字段
|
||||||
|
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(30) as u64,
|
||||||
|
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(60) as u64,
|
||||||
|
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(300) as u64,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// conn 已在 block 结束时释放
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
|
// 如果不存在,初始化默认配置
|
||||||
|
self.init_proxy_config_rows().await?;
|
||||||
|
Ok(ProxyConfig::default())
|
||||||
|
}
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新代理配置(兼容旧接口,更新所有三行的公共字段)
|
||||||
|
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
// 更新所有三行的公共字段
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE proxy_config SET
|
||||||
|
listen_address = ?1,
|
||||||
|
listen_port = ?2,
|
||||||
|
max_retries = ?3,
|
||||||
|
enable_logging = ?4,
|
||||||
|
streaming_first_byte_timeout = ?5,
|
||||||
|
streaming_idle_timeout = ?6,
|
||||||
|
non_streaming_timeout = ?7,
|
||||||
|
updated_at = datetime('now')",
|
||||||
|
rusqlite::params![
|
||||||
|
config.listen_address,
|
||||||
|
config.listen_port as i32,
|
||||||
|
config.max_retries as i32,
|
||||||
|
if config.enable_logging { 1 } else { 0 },
|
||||||
|
config.streaming_first_byte_timeout as i32,
|
||||||
|
config.streaming_idle_timeout as i32,
|
||||||
|
config.non_streaming_timeout as i32,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 Live 接管状态(兼容旧版本,更新 enabled 字段)
|
||||||
|
pub async fn set_live_takeover_active(&self, _active: bool) -> Result<(), AppError> {
|
||||||
|
// 不再使用此字段,由 enabled 字段替代
|
||||||
|
// 保留空实现以兼容旧代码
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否处于 Live 接管模式
|
||||||
|
///
|
||||||
|
/// 检查是否有任一 app 的 enabled = true
|
||||||
|
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM proxy_config WHERE enabled = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Provider Health ====================
|
||||||
|
|
||||||
|
/// 获取Provider健康状态
|
||||||
|
pub async fn get_provider_health(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<ProviderHealth, AppError> {
|
||||||
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
|
||||||
|
last_success_at, last_failure_at, last_error, updated_at
|
||||||
|
FROM provider_health
|
||||||
|
WHERE provider_id = ?1 AND app_type = ?2",
|
||||||
|
rusqlite::params![provider_id, app_type],
|
||||||
|
|row| {
|
||||||
|
Ok(ProviderHealth {
|
||||||
|
provider_id: row.get(0)?,
|
||||||
|
app_type: row.get(1)?,
|
||||||
|
is_healthy: row.get::<_, i64>(2)? != 0,
|
||||||
|
consecutive_failures: row.get::<_, i64>(3)? as u32,
|
||||||
|
last_success_at: row.get(4)?,
|
||||||
|
last_failure_at: row.get(5)?,
|
||||||
|
last_error: row.get(6)?,
|
||||||
|
updated_at: row.get(7)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(health) => Ok(health),
|
||||||
|
// 缺少记录时视为健康(关闭后清空状态,再次打开时默认正常)
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ProviderHealth {
|
||||||
|
provider_id: provider_id.to_string(),
|
||||||
|
app_type: app_type.to_string(),
|
||||||
|
is_healthy: true,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
last_success_at: None,
|
||||||
|
last_failure_at: None,
|
||||||
|
last_error: None,
|
||||||
|
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
}),
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新Provider健康状态
|
||||||
|
///
|
||||||
|
/// 使用默认阈值(5)判断是否健康,建议使用 `update_provider_health_with_threshold` 传入配置的阈值
|
||||||
|
pub async fn update_provider_health(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
success: bool,
|
||||||
|
error_msg: Option<String>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// 默认阈值与 CircuitBreakerConfig::default() 保持一致
|
||||||
|
self.update_provider_health_with_threshold(provider_id, app_type, success, error_msg, 5)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新Provider健康状态(带阈值参数)
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `failure_threshold` - 连续失败多少次后标记为不健康
|
||||||
|
pub async fn update_provider_health_with_threshold(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
success: bool,
|
||||||
|
error_msg: Option<String>,
|
||||||
|
failure_threshold: u32,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
// 先查询当前状态
|
||||||
|
let current = conn.query_row(
|
||||||
|
"SELECT consecutive_failures FROM provider_health
|
||||||
|
WHERE provider_id = ?1 AND app_type = ?2",
|
||||||
|
rusqlite::params![provider_id, app_type],
|
||||||
|
|row| Ok(row.get::<_, i64>(0)? as u32),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (is_healthy, consecutive_failures) = if success {
|
||||||
|
// 成功:重置失败计数
|
||||||
|
(1, 0)
|
||||||
|
} else {
|
||||||
|
// 失败:增加失败计数
|
||||||
|
let failures = current.unwrap_or(0) + 1;
|
||||||
|
// 使用传入的阈值而非硬编码
|
||||||
|
let healthy = if failures >= failure_threshold { 0 } else { 1 };
|
||||||
|
(healthy, failures)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (last_success_at, last_failure_at) = if success {
|
||||||
|
(Some(now.clone()), None)
|
||||||
|
} else {
|
||||||
|
(None, Some(now.clone()))
|
||||||
|
};
|
||||||
|
|
||||||
|
// UPSERT
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO provider_health
|
||||||
|
(provider_id, app_type, is_healthy, consecutive_failures,
|
||||||
|
last_success_at, last_failure_at, last_error, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4,
|
||||||
|
COALESCE(?5, (SELECT last_success_at FROM provider_health
|
||||||
|
WHERE provider_id = ?1 AND app_type = ?2)),
|
||||||
|
COALESCE(?6, (SELECT last_failure_at FROM provider_health
|
||||||
|
WHERE provider_id = ?1 AND app_type = ?2)),
|
||||||
|
?7, ?8)",
|
||||||
|
rusqlite::params![
|
||||||
|
provider_id,
|
||||||
|
app_type,
|
||||||
|
is_healthy,
|
||||||
|
consecutive_failures as i64,
|
||||||
|
last_success_at,
|
||||||
|
last_failure_at,
|
||||||
|
error_msg,
|
||||||
|
&now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置Provider健康状态
|
||||||
|
pub async fn reset_provider_health(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
|
||||||
|
rusqlite::params![provider_id, app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::debug!("Reset health status for provider {provider_id} (app: {app_type})");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空指定应用的健康状态(关闭单个代理时使用)
|
||||||
|
pub async fn clear_provider_health_for_app(&self, app_type: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM provider_health WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::debug!("Cleared provider health records for app {app_type}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空所有Provider健康状态(代理停止时调用)
|
||||||
|
pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM provider_health", [])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::debug!("Cleared all provider health records");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Circuit Breaker Config (Legacy Compatibility) ====================
|
||||||
|
|
||||||
|
/// 获取熔断器配置(兼容旧接口,从 claude 行读取)
|
||||||
|
///
|
||||||
|
/// 熔断器配置已合并到 proxy_config 表,每 app 独立
|
||||||
|
/// 此方法保留用于兼容旧代码,建议使用 get_proxy_config_for_app
|
||||||
|
pub async fn get_circuit_breaker_config(
|
||||||
|
&self,
|
||||||
|
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
|
||||||
|
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||||
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
|
circuit_error_rate_threshold, circuit_min_requests
|
||||||
|
FROM proxy_config WHERE app_type = 'claude'",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
||||||
|
failure_threshold: row.get::<_, i32>(0)? as u32,
|
||||||
|
success_threshold: row.get::<_, i32>(1)? as u32,
|
||||||
|
timeout_seconds: row.get::<_, i64>(2)? as u64,
|
||||||
|
error_rate_threshold: row.get(3)?,
|
||||||
|
min_requests: row.get::<_, i32>(4)? as u32,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// conn 已在 block 结束时释放
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
|
// 如果不存在,初始化默认配置
|
||||||
|
self.init_proxy_config_rows().await?;
|
||||||
|
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig::default())
|
||||||
|
}
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新熔断器配置(兼容旧接口,更新所有三行)
|
||||||
|
///
|
||||||
|
/// 熔断器配置已合并到 proxy_config 表
|
||||||
|
/// 此方法保留用于兼容旧代码,建议使用 update_proxy_config_for_app
|
||||||
|
pub async fn update_circuit_breaker_config(
|
||||||
|
&self,
|
||||||
|
config: &crate::proxy::circuit_breaker::CircuitBreakerConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
// 更新所有三行的熔断器配置
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE proxy_config SET
|
||||||
|
circuit_failure_threshold = ?1,
|
||||||
|
circuit_success_threshold = ?2,
|
||||||
|
circuit_timeout_seconds = ?3,
|
||||||
|
circuit_error_rate_threshold = ?4,
|
||||||
|
circuit_min_requests = ?5,
|
||||||
|
updated_at = datetime('now')",
|
||||||
|
rusqlite::params![
|
||||||
|
config.failure_threshold as i32,
|
||||||
|
config.success_threshold as i32,
|
||||||
|
config.timeout_seconds as i64,
|
||||||
|
config.error_rate_threshold,
|
||||||
|
config.min_requests as i32,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Live Backup ====================
|
||||||
|
|
||||||
|
/// 保存 Live 配置备份
|
||||||
|
pub async fn save_live_backup(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
config_json: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO proxy_live_backup (app_type, original_config, backed_up_at)
|
||||||
|
VALUES (?1, ?2, ?3)",
|
||||||
|
rusqlite::params![app_type, config_json, now],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::info!("已备份 {app_type} Live 配置");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否存在任意 Live 配置备份
|
||||||
|
pub async fn has_any_live_backup(&self) -> Result<bool, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM proxy_live_backup", [], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Live 配置备份
|
||||||
|
pub async fn get_live_backup(&self, app_type: &str) -> Result<Option<LiveBackup>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
let result = conn.query_row(
|
||||||
|
"SELECT app_type, original_config, backed_up_at FROM proxy_live_backup WHERE app_type = ?1",
|
||||||
|
rusqlite::params![app_type],
|
||||||
|
|row| {
|
||||||
|
Ok(LiveBackup {
|
||||||
|
app_type: row.get(0)?,
|
||||||
|
original_config: row.get(1)?,
|
||||||
|
backed_up_at: row.get(2)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(backup) => Ok(Some(backup)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 Live 配置备份
|
||||||
|
pub async fn delete_live_backup(&self, app_type: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM proxy_live_backup WHERE app_type = ?1",
|
||||||
|
rusqlite::params![app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::info!("已删除 {app_type} Live 配置备份");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除所有 Live 配置备份
|
||||||
|
pub async fn delete_all_live_backups(&self) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM proxy_live_backup", [])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::info!("已删除所有 Live 配置备份");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,4 +62,70 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
|
||||||
|
|
||||||
|
/// 获取指定应用的代理接管状态
|
||||||
|
///
|
||||||
|
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
|
||||||
|
/// 此方法仅用于数据库迁移时读取旧数据
|
||||||
|
#[deprecated(since = "3.9.0", note = "使用 get_proxy_config_for_app().enabled 替代")]
|
||||||
|
pub fn get_proxy_takeover_enabled(&self, app_type: &str) -> Result<bool, AppError> {
|
||||||
|
let key = format!("proxy_takeover_{app_type}");
|
||||||
|
match self.get_setting(&key)? {
|
||||||
|
Some(value) => Ok(value == "true"),
|
||||||
|
None => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置指定应用的代理接管状态
|
||||||
|
///
|
||||||
|
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
|
||||||
|
#[deprecated(
|
||||||
|
since = "3.9.0",
|
||||||
|
note = "使用 update_proxy_config_for_app() 修改 enabled 字段"
|
||||||
|
)]
|
||||||
|
pub fn set_proxy_takeover_enabled(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let key = format!("proxy_takeover_{app_type}");
|
||||||
|
let value = if enabled { "true" } else { "false" };
|
||||||
|
self.set_setting(&key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否有任一应用开启了代理接管
|
||||||
|
///
|
||||||
|
/// **已废弃**: 请使用 `is_live_takeover_active()` 替代
|
||||||
|
#[deprecated(since = "3.9.0", note = "使用 is_live_takeover_active() 替代")]
|
||||||
|
pub fn has_any_proxy_takeover(&self) -> Result<bool, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM settings WHERE key LIKE 'proxy_takeover_%' AND value = 'true'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除所有代理接管状态(将所有 proxy_takeover_* 设置为 false)
|
||||||
|
///
|
||||||
|
/// **已废弃**: settings 表不再用于存储代理状态
|
||||||
|
#[deprecated(
|
||||||
|
since = "3.9.0",
|
||||||
|
note = "使用 update_proxy_config_for_app() 清除各应用的 enabled 字段"
|
||||||
|
)]
|
||||||
|
pub fn clear_all_proxy_takeover(&self) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE settings SET value = 'false' WHERE key LIKE 'proxy_takeover_%'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
log::info!("已清除所有代理接管状态");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,18 +13,22 @@ impl Database {
|
|||||||
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
|
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC")
|
.prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC")
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
let skill_iter = stmt
|
let skill_iter = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
let key: String = row.get(0)?;
|
let directory: String = row.get(0)?;
|
||||||
let installed: bool = row.get(1)?;
|
let app_type: String = row.get(1)?;
|
||||||
let installed_at_ts: i64 = row.get(2)?;
|
let installed: bool = row.get(2)?;
|
||||||
|
let installed_at_ts: i64 = row.get(3)?;
|
||||||
|
|
||||||
let installed_at =
|
let installed_at =
|
||||||
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
|
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
|
||||||
|
|
||||||
|
// 构建复合 key:"app_type:directory"
|
||||||
|
let key = format!("{app_type}:{directory}");
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
key,
|
key,
|
||||||
SkillState {
|
SkillState {
|
||||||
@@ -44,11 +48,21 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 更新 Skill 状态
|
/// 更新 Skill 状态
|
||||||
|
/// key 格式为 "app_type:directory"
|
||||||
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
|
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
|
||||||
|
// 解析 key
|
||||||
|
let (app_type, directory) = if let Some(idx) = key.find(':') {
|
||||||
|
let (app, dir) = key.split_at(idx);
|
||||||
|
(app, &dir[1..]) // 跳过冒号
|
||||||
|
} else {
|
||||||
|
// 向后兼容:如果没有前缀,默认为 claude
|
||||||
|
("claude", key)
|
||||||
|
};
|
||||||
|
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
"INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![key, state.installed, state.installed_at.timestamp()],
|
params![directory, app_type, state.installed, state.installed_at.timestamp()],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//! 流式健康检查日志 DAO
|
||||||
|
|
||||||
|
use crate::database::{lock_conn, Database};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::stream_check::{StreamCheckConfig, StreamCheckResult};
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// 保存流式检查日志
|
||||||
|
pub fn save_stream_check_log(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
provider_name: &str,
|
||||||
|
app_type: &str,
|
||||||
|
result: &StreamCheckResult,
|
||||||
|
) -> Result<i64, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO stream_check_logs
|
||||||
|
(provider_id, provider_name, app_type, status, success, message,
|
||||||
|
response_time_ms, http_status, model_used, retry_count, tested_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||||
|
rusqlite::params![
|
||||||
|
provider_id,
|
||||||
|
provider_name,
|
||||||
|
app_type,
|
||||||
|
format!("{:?}", result.status).to_lowercase(),
|
||||||
|
result.success,
|
||||||
|
result.message,
|
||||||
|
result.response_time_ms.map(|t| t as i64),
|
||||||
|
result.http_status.map(|s| s as i64),
|
||||||
|
result.model_used,
|
||||||
|
result.retry_count as i64,
|
||||||
|
result.tested_at,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取流式检查配置
|
||||||
|
pub fn get_stream_check_config(&self) -> Result<StreamCheckConfig, AppError> {
|
||||||
|
match self.get_setting("stream_check_config")? {
|
||||||
|
Some(json) => serde_json::from_str(&json)
|
||||||
|
.map_err(|e| AppError::Message(format!("解析配置失败: {e}"))),
|
||||||
|
None => Ok(StreamCheckConfig::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存流式检查配置
|
||||||
|
pub fn save_stream_check_config(&self, config: &StreamCheckConfig) -> Result<(), AppError> {
|
||||||
|
let json = serde_json::to_string(config)
|
||||||
|
.map_err(|e| AppError::Message(format!("序列化配置失败: {e}")))?;
|
||||||
|
self.set_setting("stream_check_config", &json)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ mod schema;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
|
// DAO 类型导出供外部使用
|
||||||
|
pub use dao::FailoverQueueItem;
|
||||||
|
|
||||||
use crate::config::get_app_config_dir;
|
use crate::config::get_app_config_dir;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
@@ -44,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
|
|||||||
|
|
||||||
/// 当前 Schema 版本号
|
/// 当前 Schema 版本号
|
||||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||||
pub(crate) const SCHEMA_VERSION: i32 = 1;
|
pub(crate) const SCHEMA_VERSION: i32 = 2;
|
||||||
|
|
||||||
/// 安全地序列化 JSON,避免 unwrap panic
|
/// 安全地序列化 JSON,避免 unwrap panic
|
||||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||||
@@ -95,6 +98,7 @@ impl Database {
|
|||||||
};
|
};
|
||||||
db.create_tables()?;
|
db.create_tables()?;
|
||||||
db.apply_schema_migrations()?;
|
db.apply_schema_migrations()?;
|
||||||
|
db.ensure_model_pricing_seeded()?;
|
||||||
|
|
||||||
Ok(db)
|
Ok(db)
|
||||||
}
|
}
|
||||||
@@ -111,6 +115,7 @@ impl Database {
|
|||||||
conn: Mutex::new(conn),
|
conn: Mutex::new(conn),
|
||||||
};
|
};
|
||||||
db.create_tables()?;
|
db.create_tables()?;
|
||||||
|
db.ensure_model_pricing_seeded()?;
|
||||||
|
|
||||||
Ok(db)
|
Ok(db)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ impl Database {
|
|||||||
icon_color TEXT,
|
icon_color TEXT,
|
||||||
meta TEXT NOT NULL DEFAULT '{}',
|
meta TEXT NOT NULL DEFAULT '{}',
|
||||||
is_current BOOLEAN NOT NULL DEFAULT 0,
|
is_current BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (id, app_type)
|
PRIMARY KEY (id, app_type)
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
@@ -54,45 +55,28 @@ impl Database {
|
|||||||
// 3. MCP Servers 表
|
// 3. MCP Servers 表
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS mcp_servers (
|
"CREATE TABLE IF NOT EXISTS mcp_servers (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
||||||
server_config TEXT NOT NULL,
|
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||||
description TEXT,
|
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
|
||||||
homepage TEXT,
|
)",
|
||||||
docs TEXT,
|
|
||||||
tags TEXT NOT NULL DEFAULT '[]',
|
|
||||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 4. Prompts 表
|
// 4. Prompts 表
|
||||||
conn.execute(
|
conn.execute("CREATE TABLE IF NOT EXISTS prompts (
|
||||||
"CREATE TABLE IF NOT EXISTS prompts (
|
id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, content TEXT NOT NULL,
|
||||||
id TEXT NOT NULL,
|
description TEXT, enabled BOOLEAN NOT NULL DEFAULT 1, created_at INTEGER, updated_at INTEGER,
|
||||||
app_type TEXT NOT NULL,
|
PRIMARY KEY (id, app_type)
|
||||||
name TEXT NOT NULL,
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
content TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
||||||
created_at INTEGER,
|
|
||||||
updated_at INTEGER,
|
|
||||||
PRIMARY KEY (id, app_type)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 5. Skills 表
|
// 5. Skills 表
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS skills (
|
"CREATE TABLE IF NOT EXISTS skills (
|
||||||
key TEXT PRIMARY KEY,
|
directory TEXT NOT NULL, app_type TEXT NOT NULL, installed BOOLEAN NOT NULL DEFAULT 0,
|
||||||
installed BOOLEAN NOT NULL DEFAULT 0,
|
installed_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (directory, app_type)
|
||||||
installed_at INTEGER NOT NULL DEFAULT 0
|
)",
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -100,26 +84,182 @@ impl Database {
|
|||||||
// 6. Skill Repos 表
|
// 6. Skill Repos 表
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS skill_repos (
|
"CREATE TABLE IF NOT EXISTS skill_repos (
|
||||||
owner TEXT NOT NULL,
|
owner TEXT NOT NULL, name TEXT NOT NULL, branch TEXT NOT NULL DEFAULT 'main',
|
||||||
name TEXT NOT NULL,
|
enabled BOOLEAN NOT NULL DEFAULT 1, PRIMARY KEY (owner, name)
|
||||||
branch TEXT NOT NULL DEFAULT 'main',
|
)",
|
||||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
||||||
PRIMARY KEY (owner, name)
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 7. Settings 表 (通用配置)
|
// 7. Settings 表
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS settings (
|
"CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)",
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 8. Proxy Config 表(三行结构,app_type 主键)
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
|
||||||
|
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||||
|
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||||
|
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
|
||||||
|
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
|
||||||
|
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||||
|
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
|
||||||
|
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 初始化三行数据(每应用不同默认值)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||||
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||||
|
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
|
circuit_error_rate_threshold, circuit_min_requests)
|
||||||
|
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||||
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||||
|
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
|
circuit_error_rate_threshold, circuit_min_requests)
|
||||||
|
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||||
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||||
|
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
|
circuit_error_rate_threshold, circuit_min_requests)
|
||||||
|
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 9. Provider Health 表
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS provider_health (
|
||||||
|
provider_id TEXT NOT NULL, app_type TEXT NOT NULL, is_healthy INTEGER NOT NULL DEFAULT 1,
|
||||||
|
consecutive_failures INTEGER NOT NULL DEFAULT 0, last_success_at TEXT, last_failure_at TEXT,
|
||||||
|
last_error TEXT, updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (provider_id, app_type),
|
||||||
|
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
||||||
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 10. Proxy Request Logs 表
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||||
|
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||||||
|
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
|
cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
|
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
|
||||||
|
duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
|
||||||
|
provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL
|
||||||
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON proxy_request_logs(provider_id, app_type)", [])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON proxy_request_logs(created_at)", [])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_model ON proxy_request_logs(model)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_session ON proxy_request_logs(session_id)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_status ON proxy_request_logs(status_code)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 11. Model Pricing 表
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS model_pricing (
|
||||||
|
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
|
||||||
|
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
|
||||||
|
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||||||
|
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 12. Stream Check Logs 表
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS stream_check_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, provider_name TEXT NOT NULL,
|
||||||
|
app_type TEXT NOT NULL, status TEXT NOT NULL, success INTEGER NOT NULL, message TEXT NOT NULL,
|
||||||
|
response_time_ms INTEGER, http_status INTEGER, model_used TEXT,
|
||||||
|
retry_count INTEGER DEFAULT 0, tested_at INTEGER NOT NULL
|
||||||
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider
|
||||||
|
ON stream_check_logs(app_type, provider_id, tested_at DESC)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 注意:circuit_breaker_config 已合并到 proxy_config 表中
|
||||||
|
|
||||||
|
// 16. Proxy Live Backup 表 (Live 配置备份)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS proxy_live_backup (
|
||||||
|
app_type TEXT PRIMARY KEY, original_config TEXT NOT NULL, backed_up_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 尝试添加 live_takeover_active 列到 proxy_config 表
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 尝试添加超时配置列到 proxy_config 表
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 60",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 300",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"providers",
|
||||||
|
"in_failover_queue",
|
||||||
|
"BOOLEAN NOT NULL DEFAULT 0",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 删除旧的 failover_queue 表(如果存在)
|
||||||
|
let _ = conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", []);
|
||||||
|
let _ = conn.execute("DROP TABLE IF EXISTS failover_queue", []);
|
||||||
|
|
||||||
|
// 为故障转移队列创建索引(基于 providers 表)
|
||||||
|
let _ = conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_providers_failover
|
||||||
|
ON providers(app_type, in_failover_queue, sort_index)",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +290,14 @@ impl Database {
|
|||||||
0 => {
|
0 => {
|
||||||
log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)");
|
log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)");
|
||||||
Self::migrate_v0_to_v1(conn)?;
|
Self::migrate_v0_to_v1(conn)?;
|
||||||
Self::set_user_version(conn, SCHEMA_VERSION)?;
|
Self::set_user_version(conn, 1)?;
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
log::info!(
|
||||||
|
"迁移数据库从 v1 到 v2(添加使用统计表和完整字段,重构 skills 表)"
|
||||||
|
);
|
||||||
|
Self::migrate_v1_to_v2(conn)?;
|
||||||
|
Self::set_user_version(conn, 2)?;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::Database(format!(
|
return Err(AppError::Database(format!(
|
||||||
@@ -237,6 +384,469 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v1 -> v2 迁移:添加使用统计表和完整字段,重构 skills 表
|
||||||
|
fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {
|
||||||
|
// providers 表字段
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"providers",
|
||||||
|
"cost_multiplier",
|
||||||
|
"TEXT NOT NULL DEFAULT '1.0'",
|
||||||
|
)?;
|
||||||
|
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
|
||||||
|
Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
|
||||||
|
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"providers",
|
||||||
|
"in_failover_queue",
|
||||||
|
"BOOLEAN NOT NULL DEFAULT 0",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 添加代理超时配置字段
|
||||||
|
if Self::table_exists(conn, "proxy_config")? {
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"proxy_config",
|
||||||
|
"streaming_first_byte_timeout",
|
||||||
|
"INTEGER NOT NULL DEFAULT 30",
|
||||||
|
)?;
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"proxy_config",
|
||||||
|
"streaming_idle_timeout",
|
||||||
|
"INTEGER NOT NULL DEFAULT 60",
|
||||||
|
)?;
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"proxy_config",
|
||||||
|
"non_streaming_timeout",
|
||||||
|
"INTEGER NOT NULL DEFAULT 300",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除旧的 failover_queue 表(如果存在)
|
||||||
|
conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", [])
|
||||||
|
.map_err(|e| AppError::Database(format!("删除 failover_queue 索引失败: {e}")))?;
|
||||||
|
conn.execute("DROP TABLE IF EXISTS failover_queue", [])
|
||||||
|
.map_err(|e| AppError::Database(format!("删除 failover_queue 表失败: {e}")))?;
|
||||||
|
|
||||||
|
// 创建 failover 索引
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_providers_failover
|
||||||
|
ON providers(app_type, in_failover_queue, sort_index)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("创建 failover 索引失败: {e}")))?;
|
||||||
|
|
||||||
|
// proxy_request_logs 表
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||||
|
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||||||
|
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
|
cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
|
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
|
||||||
|
duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
|
||||||
|
provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL
|
||||||
|
)", [])?;
|
||||||
|
|
||||||
|
// 为已存在的表添加新字段
|
||||||
|
Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?;
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"proxy_request_logs",
|
||||||
|
"is_streaming",
|
||||||
|
"INTEGER NOT NULL DEFAULT 0",
|
||||||
|
)?;
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"proxy_request_logs",
|
||||||
|
"cost_multiplier",
|
||||||
|
"TEXT NOT NULL DEFAULT '1.0'",
|
||||||
|
)?;
|
||||||
|
Self::add_column_if_missing(conn, "proxy_request_logs", "first_token_ms", "INTEGER")?;
|
||||||
|
Self::add_column_if_missing(conn, "proxy_request_logs", "duration_ms", "INTEGER")?;
|
||||||
|
|
||||||
|
// model_pricing 表
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS model_pricing (
|
||||||
|
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
|
||||||
|
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
|
||||||
|
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||||||
|
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 清空并重新插入模型定价
|
||||||
|
conn.execute("DELETE FROM model_pricing", [])
|
||||||
|
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
|
||||||
|
Self::seed_model_pricing(conn)?;
|
||||||
|
|
||||||
|
// 重构 skills 表(添加 app_type 字段)
|
||||||
|
Self::migrate_skills_table(conn)?;
|
||||||
|
|
||||||
|
// 重构 proxy_config 为三行结构(每应用独立配置)
|
||||||
|
Self::migrate_proxy_config_to_per_app(conn)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 proxy_config 迁移为三行结构(每应用独立配置)
|
||||||
|
fn migrate_proxy_config_to_per_app(conn: &Connection) -> Result<(), AppError> {
|
||||||
|
// 检查是否已经是新表结构(幂等性)
|
||||||
|
if !Self::table_exists(conn, "proxy_config")? {
|
||||||
|
// 表不存在,跳过迁移(新安装)
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if Self::has_column(conn, "proxy_config", "app_type")? {
|
||||||
|
// 已经是三行结构,跳过迁移
|
||||||
|
log::info!("proxy_config 已经是三行结构,跳过迁移");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取旧配置
|
||||||
|
let old_config = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT listen_address, listen_port, max_retries, enable_logging,
|
||||||
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
|
||||||
|
FROM proxy_config WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, i32>(1)?,
|
||||||
|
row.get::<_, i32>(2)?,
|
||||||
|
row.get::<_, i32>(3)?,
|
||||||
|
row.get::<_, i32>(4).unwrap_or(30),
|
||||||
|
row.get::<_, i32>(5).unwrap_or(60),
|
||||||
|
row.get::<_, i32>(6).unwrap_or(300),
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| ("127.0.0.1".to_string(), 5000, 3, 1, 30, 60, 300));
|
||||||
|
|
||||||
|
let old_cb = conn.query_row(
|
||||||
|
"SELECT failure_threshold, success_threshold, timeout_seconds, error_rate_threshold, min_requests
|
||||||
|
FROM circuit_breaker_config WHERE id = 1", [],
|
||||||
|
|row| Ok((row.get::<_, i32>(0)?, row.get::<_, i32>(1)?, row.get::<_, i64>(2)?,
|
||||||
|
row.get::<_, f64>(3)?, row.get::<_, i32>(4)?))
|
||||||
|
).unwrap_or((5, 2, 60, 0.5, 10));
|
||||||
|
|
||||||
|
let get_bool = |key: &str| -> bool {
|
||||||
|
conn.query_row("SELECT value FROM settings WHERE key = ?", [key], |r| {
|
||||||
|
r.get::<_, String>(0)
|
||||||
|
})
|
||||||
|
.map(|v| v == "true" || v == "1")
|
||||||
|
.unwrap_or(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
let apps = [
|
||||||
|
(
|
||||||
|
"claude",
|
||||||
|
get_bool("proxy_takeover_claude"),
|
||||||
|
get_bool("auto_failover_enabled_claude"),
|
||||||
|
6,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
8,
|
||||||
|
3,
|
||||||
|
90,
|
||||||
|
0.6,
|
||||||
|
15,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"codex",
|
||||||
|
get_bool("proxy_takeover_codex"),
|
||||||
|
get_bool("auto_failover_enabled_codex"),
|
||||||
|
3,
|
||||||
|
old_config.4,
|
||||||
|
old_config.5,
|
||||||
|
old_cb.0,
|
||||||
|
old_cb.1,
|
||||||
|
old_cb.2,
|
||||||
|
old_cb.3,
|
||||||
|
old_cb.4,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gemini",
|
||||||
|
get_bool("proxy_takeover_gemini"),
|
||||||
|
get_bool("auto_failover_enabled_gemini"),
|
||||||
|
5,
|
||||||
|
old_config.4,
|
||||||
|
old_config.5,
|
||||||
|
old_cb.0,
|
||||||
|
old_cb.1,
|
||||||
|
old_cb.2,
|
||||||
|
old_cb.3,
|
||||||
|
old_cb.4,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 创建新表
|
||||||
|
conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
|
||||||
|
conn.execute("CREATE TABLE proxy_config_new (
|
||||||
|
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||||
|
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||||
|
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
|
||||||
|
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
|
||||||
|
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||||
|
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
|
||||||
|
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)", [])?;
|
||||||
|
|
||||||
|
// 插入三行配置
|
||||||
|
for (app, takeover, failover, retries, fb, idle, cb_f, cb_s, cb_t, cb_r, cb_m) in apps {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO proxy_config_new (app_type, proxy_enabled, listen_address, listen_port, enable_logging,
|
||||||
|
enabled, auto_failover_enabled, max_retries, streaming_first_byte_timeout, streaming_idle_timeout,
|
||||||
|
non_streaming_timeout, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
|
circuit_error_rate_threshold, circuit_min_requests)
|
||||||
|
VALUES (?1, 0, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||||
|
rusqlite::params![app, old_config.0, old_config.1, old_config.3,
|
||||||
|
if takeover { 1 } else { 0 }, if failover { 1 } else { 0 },
|
||||||
|
retries, fb, idle, old_config.6, cb_f, cb_s, cb_t, cb_r, cb_m]
|
||||||
|
).map_err(|e| AppError::Database(format!("插入 {app} 配置失败: {e}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换表并清理
|
||||||
|
conn.execute("DROP TABLE IF EXISTS proxy_config", [])?;
|
||||||
|
conn.execute("ALTER TABLE proxy_config_new RENAME TO proxy_config", [])?;
|
||||||
|
conn.execute("DROP TABLE IF EXISTS circuit_breaker_config", [])?;
|
||||||
|
conn.execute("DELETE FROM settings WHERE key LIKE 'proxy_takeover_%'", [])?;
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM settings WHERE key LIKE 'auto_failover_enabled_%'",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
log::info!("proxy_config 已迁移为三行结构");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键
|
||||||
|
fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> {
|
||||||
|
// 检查是否已经是新表结构
|
||||||
|
if Self::has_column(conn, "skills", "app_type")? {
|
||||||
|
log::info!("skills 表已经包含 app_type 字段,跳过迁移");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("开始迁移 skills 表...");
|
||||||
|
|
||||||
|
// 1. 重命名旧表
|
||||||
|
conn.execute("ALTER TABLE skills RENAME TO skills_old", [])
|
||||||
|
.map_err(|e| AppError::Database(format!("重命名旧 skills 表失败: {e}")))?;
|
||||||
|
|
||||||
|
// 2. 创建新表
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE skills (
|
||||||
|
directory TEXT NOT NULL,
|
||||||
|
app_type TEXT NOT NULL,
|
||||||
|
installed BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
installed_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (directory, app_type)
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
|
||||||
|
|
||||||
|
// 3. 迁移数据:解析 key 格式(如 "claude:my-skill" 或 "codex:foo")
|
||||||
|
// 旧数据如果没有前缀,默认为 claude
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT key, installed, installed_at FROM skills_old")
|
||||||
|
.map_err(|e| AppError::Database(format!("查询旧 skills 数据失败: {e}")))?;
|
||||||
|
|
||||||
|
let old_skills: Vec<(String, bool, i64)> = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, bool>(1)?,
|
||||||
|
row.get::<_, i64>(2)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.map_err(|e| AppError::Database(format!("读取旧 skills 数据失败: {e}")))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| AppError::Database(format!("解析旧 skills 数据失败: {e}")))?;
|
||||||
|
|
||||||
|
let count = old_skills.len();
|
||||||
|
|
||||||
|
for (key, installed, installed_at) in old_skills {
|
||||||
|
// 解析 key: "app:directory" 或 "directory"(默认 claude)
|
||||||
|
let (app_type, directory) = if let Some(idx) = key.find(':') {
|
||||||
|
let (app, dir) = key.split_at(idx);
|
||||||
|
(app.to_string(), dir[1..].to_string()) // 跳过冒号
|
||||||
|
} else {
|
||||||
|
("claude".to_string(), key.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
rusqlite::params![directory, app_type, installed, installed_at],
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::Database(format!("迁移 skill {key} 到新表失败: {e}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 删除旧表
|
||||||
|
conn.execute("DROP TABLE skills_old", [])
|
||||||
|
.map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
|
||||||
|
|
||||||
|
log::info!("skills 表迁移完成,共迁移 {count} 条记录");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 插入默认模型定价数据
|
||||||
|
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||||
|
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||||
|
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||||
|
let pricing_data = [
|
||||||
|
// Claude 4.5 系列
|
||||||
|
(
|
||||||
|
"claude-opus-4-5",
|
||||||
|
"Claude Opus 4.5",
|
||||||
|
"5",
|
||||||
|
"25",
|
||||||
|
"0.50",
|
||||||
|
"6.25",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"claude-sonnet-4-5",
|
||||||
|
"Claude Sonnet 4.5",
|
||||||
|
"3",
|
||||||
|
"15",
|
||||||
|
"0.30",
|
||||||
|
"3.75",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"claude-haiku-4-5",
|
||||||
|
"Claude Haiku 4.5",
|
||||||
|
"1",
|
||||||
|
"5",
|
||||||
|
"0.10",
|
||||||
|
"1.25",
|
||||||
|
),
|
||||||
|
// Claude 4.1 系列
|
||||||
|
(
|
||||||
|
"claude-opus-4-1",
|
||||||
|
"Claude Opus 4.1",
|
||||||
|
"15",
|
||||||
|
"75",
|
||||||
|
"1.50",
|
||||||
|
"18.75",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"claude-sonnet-4-1",
|
||||||
|
"Claude Sonnet 4.1",
|
||||||
|
"3",
|
||||||
|
"15",
|
||||||
|
"0.30",
|
||||||
|
"3.75",
|
||||||
|
),
|
||||||
|
// Claude 3.7 系列
|
||||||
|
(
|
||||||
|
"claude-sonnet-3-7",
|
||||||
|
"Claude Sonnet 3.7",
|
||||||
|
"3",
|
||||||
|
"15",
|
||||||
|
"0.30",
|
||||||
|
"3.75",
|
||||||
|
),
|
||||||
|
// Claude 3.5 系列
|
||||||
|
(
|
||||||
|
"claude-sonnet-3-5",
|
||||||
|
"Claude Sonnet 3.5",
|
||||||
|
"3",
|
||||||
|
"15",
|
||||||
|
"0.30",
|
||||||
|
"3.75",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"claude-haiku-3-5",
|
||||||
|
"Claude Haiku 3.5",
|
||||||
|
"0.80",
|
||||||
|
"4",
|
||||||
|
"0.08",
|
||||||
|
"1",
|
||||||
|
),
|
||||||
|
// GPT-5 系列(model_id 使用短横线格式)
|
||||||
|
("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"),
|
||||||
|
("gpt-5-1", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||||
|
("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
|
||||||
|
("gpt-5-1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
|
||||||
|
// Gemini 3 系列
|
||||||
|
(
|
||||||
|
"gemini-3-pro-preview",
|
||||||
|
"Gemini 3 Pro Preview",
|
||||||
|
"2",
|
||||||
|
"12",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
// Gemini 2.5 系列(model_id 使用短横线格式)
|
||||||
|
(
|
||||||
|
"gemini-2-5-pro",
|
||||||
|
"Gemini 2.5 Pro",
|
||||||
|
"1.25",
|
||||||
|
"10",
|
||||||
|
"0.125",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gemini-2-5-flash",
|
||||||
|
"Gemini 2.5 Flash",
|
||||||
|
"0.3",
|
||||||
|
"2.5",
|
||||||
|
"0.03",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO model_pricing (
|
||||||
|
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||||
|
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
rusqlite::params![
|
||||||
|
model_id,
|
||||||
|
display_name,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
cache_read,
|
||||||
|
cache_creation
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("已插入 {} 条默认模型定价数据", pricing_data.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 确保模型定价表具备默认数据
|
||||||
|
pub fn ensure_model_pricing_seeded(&self) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
Self::ensure_model_pricing_seeded_on_conn(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
|
||||||
|
.map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?;
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
Self::seed_model_pricing(conn)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// --- 辅助方法 ---
|
// --- 辅助方法 ---
|
||||||
|
|
||||||
pub(crate) fn get_user_version(conn: &Connection) -> Result<i32, AppError> {
|
pub(crate) fn get_user_version(conn: &Connection) -> Result<i32, AppError> {
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ fn dry_run_validates_schema_compatibility() {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -272,3 +273,62 @@ fn dry_run_validates_schema_compatibility() {
|
|||||||
"Dry-run should succeed with provider data: {result:?}"
|
"Dry-run should succeed with provider data: {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_pricing_is_seeded_on_init() {
|
||||||
|
let db = Database::memory().expect("create memory db");
|
||||||
|
|
||||||
|
let conn = db.conn.lock().expect("lock conn");
|
||||||
|
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
|
||||||
|
.expect("count pricing");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
count > 0,
|
||||||
|
"模型定价数据应该在初始化时自动填充,实际数量: {}",
|
||||||
|
count
|
||||||
|
);
|
||||||
|
|
||||||
|
// 验证包含 Claude 模型
|
||||||
|
let claude_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'claude-%'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("check claude");
|
||||||
|
assert!(
|
||||||
|
claude_count > 0,
|
||||||
|
"应该包含 Claude 模型定价,实际数量: {}",
|
||||||
|
claude_count
|
||||||
|
);
|
||||||
|
|
||||||
|
// 验证包含 GPT 模型
|
||||||
|
let gpt_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gpt-%'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("check gpt");
|
||||||
|
assert!(
|
||||||
|
gpt_count > 0,
|
||||||
|
"应该包含 GPT 模型定价,实际数量: {}",
|
||||||
|
gpt_count
|
||||||
|
);
|
||||||
|
|
||||||
|
// 验证包含 Gemini 模型
|
||||||
|
let gemini_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gemini-%'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("check gemini");
|
||||||
|
assert!(
|
||||||
|
gemini_count > 0,
|
||||||
|
"应该包含 Gemini 模型定价,实际数量: {}",
|
||||||
|
gemini_count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,4 +113,27 @@ pub struct DeepLinkImportRequest {
|
|||||||
/// Remote config URL
|
/// Remote config URL
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub config_url: Option<String>,
|
pub config_url: Option<String>,
|
||||||
|
|
||||||
|
// ============ Usage script fields (v3.9+) ============
|
||||||
|
/// Whether to enable usage query (default: true if usage_script is provided)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_enabled: Option<bool>,
|
||||||
|
/// Base64 encoded usage query script code
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_script: Option<String>,
|
||||||
|
/// Usage query API key (if different from provider API key)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_api_key: Option<String>,
|
||||||
|
/// Usage query base URL (if different from provider endpoint)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_base_url: Option<String>,
|
||||||
|
/// Usage query access token (for NewAPI template)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_access_token: Option<String>,
|
||||||
|
/// Usage query user ID (for NewAPI template)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_user_id: Option<String>,
|
||||||
|
/// Auto query interval in minutes (0 to disable)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_auto_interval: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,19 @@ fn parse_provider_deeplink(
|
|||||||
let config_url = params.get("configUrl").cloned();
|
let config_url = params.get("configUrl").cloned();
|
||||||
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
|
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
|
||||||
|
|
||||||
|
// Extract usage script fields (v3.9+)
|
||||||
|
let usage_enabled = params
|
||||||
|
.get("usageEnabled")
|
||||||
|
.and_then(|v| v.parse::<bool>().ok());
|
||||||
|
let usage_script = params.get("usageScript").cloned();
|
||||||
|
let usage_api_key = params.get("usageApiKey").cloned();
|
||||||
|
let usage_base_url = params.get("usageBaseUrl").cloned();
|
||||||
|
let usage_access_token = params.get("usageAccessToken").cloned();
|
||||||
|
let usage_user_id = params.get("usageUserId").cloned();
|
||||||
|
let usage_auto_interval = params
|
||||||
|
.get("usageAutoInterval")
|
||||||
|
.and_then(|v| v.parse::<u64>().ok());
|
||||||
|
|
||||||
Ok(DeepLinkImportRequest {
|
Ok(DeepLinkImportRequest {
|
||||||
version,
|
version,
|
||||||
resource,
|
resource,
|
||||||
@@ -146,6 +159,13 @@ fn parse_provider_deeplink(
|
|||||||
config,
|
config,
|
||||||
config_format,
|
config_format,
|
||||||
config_url,
|
config_url,
|
||||||
|
usage_enabled,
|
||||||
|
usage_script,
|
||||||
|
usage_api_key,
|
||||||
|
usage_base_url,
|
||||||
|
usage_access_token,
|
||||||
|
usage_user_id,
|
||||||
|
usage_auto_interval,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +226,13 @@ fn parse_prompt_deeplink(
|
|||||||
config: None,
|
config: None,
|
||||||
config_format: None,
|
config_format: None,
|
||||||
config_url: None,
|
config_url: None,
|
||||||
|
usage_enabled: None,
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: None,
|
||||||
|
usage_base_url: None,
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +288,13 @@ fn parse_mcp_deeplink(
|
|||||||
directory: None,
|
directory: None,
|
||||||
branch: None,
|
branch: None,
|
||||||
config_url: None,
|
config_url: None,
|
||||||
|
usage_enabled: None,
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: None,
|
||||||
|
usage_base_url: None,
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,5 +343,12 @@ fn parse_skill_deeplink(
|
|||||||
config: None,
|
config: None,
|
||||||
config_format: None,
|
config_format: None,
|
||||||
config_url: None,
|
config_url: None,
|
||||||
|
usage_enabled: None,
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: None,
|
||||||
|
usage_base_url: None,
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
|
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
|
||||||
use super::DeepLinkImportRequest;
|
use super::DeepLinkImportRequest;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::Provider;
|
use crate::provider::{Provider, ProviderMeta, UsageScript};
|
||||||
use crate::services::ProviderService;
|
use crate::services::ProviderService;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
use crate::AppType;
|
use crate::AppType;
|
||||||
@@ -117,6 +117,9 @@ pub(crate) fn build_provider_from_request(
|
|||||||
AppType::Gemini => build_gemini_settings(request),
|
AppType::Gemini => build_gemini_settings(request),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build usage script configuration if provided
|
||||||
|
let meta = build_provider_meta(request)?;
|
||||||
|
|
||||||
let provider = Provider {
|
let provider = Provider {
|
||||||
id: String::new(), // Will be generated by caller
|
id: String::new(), // Will be generated by caller
|
||||||
name: request.name.clone().unwrap_or_default(),
|
name: request.name.clone().unwrap_or_default(),
|
||||||
@@ -126,14 +129,66 @@ pub(crate) fn build_provider_from_request(
|
|||||||
created_at: None,
|
created_at: None,
|
||||||
sort_index: None,
|
sort_index: None,
|
||||||
notes: request.notes.clone(),
|
notes: request.notes.clone(),
|
||||||
meta: None,
|
meta,
|
||||||
icon: request.icon.clone(),
|
icon: request.icon.clone(),
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(provider)
|
Ok(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build provider meta with usage script configuration
|
||||||
|
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
||||||
|
// Check if any usage script fields are provided
|
||||||
|
if request.usage_script.is_none()
|
||||||
|
&& request.usage_enabled.is_none()
|
||||||
|
&& request.usage_api_key.is_none()
|
||||||
|
&& request.usage_base_url.is_none()
|
||||||
|
&& request.usage_access_token.is_none()
|
||||||
|
&& request.usage_user_id.is_none()
|
||||||
|
&& request.usage_auto_interval.is_none()
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode usage script code if provided
|
||||||
|
let code = if let Some(script_b64) = &request.usage_script {
|
||||||
|
let decoded = decode_base64_param("usage_script", script_b64)?;
|
||||||
|
String::from_utf8(decoded)
|
||||||
|
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))?
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine enabled state: explicit param > has code > false
|
||||||
|
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||||||
|
|
||||||
|
// Build UsageScript - use provider's API key and endpoint as defaults
|
||||||
|
let usage_script = UsageScript {
|
||||||
|
enabled,
|
||||||
|
language: "javascript".to_string(),
|
||||||
|
code,
|
||||||
|
timeout: Some(10),
|
||||||
|
api_key: request
|
||||||
|
.usage_api_key
|
||||||
|
.clone()
|
||||||
|
.or_else(|| request.api_key.clone()),
|
||||||
|
base_url: request
|
||||||
|
.usage_base_url
|
||||||
|
.clone()
|
||||||
|
.or_else(|| request.endpoint.clone()),
|
||||||
|
access_token: request.usage_access_token.clone(),
|
||||||
|
user_id: request.usage_user_id.clone(),
|
||||||
|
auto_query_interval: request.usage_auto_interval,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(ProviderMeta {
|
||||||
|
usage_script: Some(usage_script),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// Build Claude settings configuration
|
/// Build Claude settings configuration
|
||||||
fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||||
let mut env = serde_json::Map::new();
|
let mut env = serde_json::Map::new();
|
||||||
|
|||||||
@@ -145,6 +145,13 @@ fn test_build_gemini_provider_with_model() {
|
|||||||
content: None,
|
content: None,
|
||||||
description: None,
|
description: None,
|
||||||
enabled: None,
|
enabled: None,
|
||||||
|
usage_enabled: None,
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: None,
|
||||||
|
usage_base_url: None,
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
||||||
@@ -191,6 +198,13 @@ fn test_build_gemini_provider_without_model() {
|
|||||||
content: None,
|
content: None,
|
||||||
description: None,
|
description: None,
|
||||||
enabled: None,
|
enabled: None,
|
||||||
|
usage_enabled: None,
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: None,
|
||||||
|
usage_base_url: None,
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
||||||
@@ -232,6 +246,13 @@ fn test_parse_and_merge_config_claude() {
|
|||||||
content: None,
|
content: None,
|
||||||
description: None,
|
description: None,
|
||||||
enabled: None,
|
enabled: None,
|
||||||
|
usage_enabled: None,
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: None,
|
||||||
|
usage_base_url: None,
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let merged = parse_and_merge_config(&request).unwrap();
|
let merged = parse_and_merge_config(&request).unwrap();
|
||||||
@@ -275,6 +296,13 @@ fn test_parse_and_merge_config_url_override() {
|
|||||||
content: None,
|
content: None,
|
||||||
description: None,
|
description: None,
|
||||||
enabled: None,
|
enabled: None,
|
||||||
|
usage_enabled: None,
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: None,
|
||||||
|
usage_base_url: None,
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let merged = parse_and_merge_config(&request).unwrap();
|
let merged = parse_and_merge_config(&request).unwrap();
|
||||||
|
|||||||
@@ -91,12 +91,27 @@ impl<T> From<PoisonError<T>> for AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<rusqlite::Error> for AppError {
|
||||||
|
fn from(err: rusqlite::Error) -> Self {
|
||||||
|
Self::Database(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<AppError> for String {
|
impl From<AppError> for String {
|
||||||
fn from(err: AppError) -> Self {
|
fn from(err: AppError) -> Self {
|
||||||
err.to_string()
|
err.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl serde::Serialize for AppError {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 格式化为 JSON 错误字符串,前端可解析为结构化错误
|
/// 格式化为 JSON 错误字符串,前端可解析为结构化错误
|
||||||
pub fn format_skill_error(
|
pub fn format_skill_error(
|
||||||
code: &str,
|
code: &str,
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
|
|||||||
///
|
///
|
||||||
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
|
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
|
||||||
/// - httpUrl → url + type: "http"
|
/// - httpUrl → url + type: "http"
|
||||||
/// - 仅有 url 字段 → 保持不变(SSE 类型)
|
/// - 仅有 url 字段 → 补齐 type: "sse"(Gemini 以字段名推断传输类型)
|
||||||
/// - 仅有 command 字段 → 保持不变(stdio 类型)
|
/// - 仅有 command 字段 → 补齐 type: "stdio"
|
||||||
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
|
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
|
||||||
let path = user_config_path();
|
let path = user_config_path();
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@@ -65,8 +65,15 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
|
|||||||
obj.insert("url".to_string(), http_url);
|
obj.insert("url".to_string(), http_url);
|
||||||
obj.insert("type".to_string(), Value::String("http".to_string()));
|
obj.insert("type".to_string(), Value::String("http".to_string()));
|
||||||
}
|
}
|
||||||
// 如果有 url 但没有 type,不添加 type(默认为 SSE)
|
|
||||||
// 如果有 command 但没有 type,不添加 type(默认为 stdio)
|
// Gemini CLI 不使用 type 字段:这里补齐成统一结构,便于校验与导入
|
||||||
|
if obj.get("type").is_none() {
|
||||||
|
if obj.contains_key("command") {
|
||||||
|
obj.insert("type".to_string(), Value::String("stdio".to_string()));
|
||||||
|
} else if obj.contains_key("url") {
|
||||||
|
obj.insert("type".to_string(), Value::String("sse".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+202
-42
@@ -17,6 +17,7 @@ mod prompt;
|
|||||||
mod prompt_files;
|
mod prompt_files;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod provider_defaults;
|
mod provider_defaults;
|
||||||
|
mod proxy;
|
||||||
mod services;
|
mod services;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod store;
|
mod store;
|
||||||
@@ -38,8 +39,8 @@ pub use mcp::{
|
|||||||
};
|
};
|
||||||
pub use provider::{Provider, ProviderMeta};
|
pub use provider::{Provider, ProviderMeta};
|
||||||
pub use services::{
|
pub use services::{
|
||||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SkillService,
|
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
|
||||||
SpeedtestService,
|
SkillService, SpeedtestService,
|
||||||
};
|
};
|
||||||
pub use settings::{update_settings, AppSettings};
|
pub use settings::{update_settings, AppSettings};
|
||||||
pub use store::AppState;
|
pub use store::AppState;
|
||||||
@@ -50,7 +51,6 @@ use std::sync::Arc;
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use tauri::image::Image;
|
use tauri::image::Image;
|
||||||
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
use tauri::RunEvent;
|
use tauri::RunEvent;
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
@@ -223,44 +223,6 @@ pub fn run() {
|
|||||||
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
// 设置 macOS 标题栏背景色为主界面蓝色
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
use objc2::rc::Retained;
|
|
||||||
use objc2::runtime::AnyObject;
|
|
||||||
use objc2_app_kit::NSColor;
|
|
||||||
|
|
||||||
match window.ns_window() {
|
|
||||||
Ok(ns_window_ptr) => {
|
|
||||||
if let Some(ns_window) =
|
|
||||||
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject) }
|
|
||||||
{
|
|
||||||
// 使用与主界面 banner 相同的蓝色 #3498db
|
|
||||||
// #3498db = RGB(52, 152, 219)
|
|
||||||
let bg_color = unsafe {
|
|
||||||
NSColor::colorWithRed_green_blue_alpha(
|
|
||||||
52.0 / 255.0, // R: 52
|
|
||||||
152.0 / 255.0, // G: 152
|
|
||||||
219.0 / 255.0, // B: 219
|
|
||||||
1.0, // Alpha: 1.0
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
use objc2::msg_send;
|
|
||||||
let _: () =
|
|
||||||
msg_send![&*ns_window, setBackgroundColor: &*bg_color];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log::warn!("Failed to retain NSWindow reference");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => log::warn!("Failed to get NSWindow pointer: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化日志
|
// 初始化日志
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
app.handle().plugin(
|
app.handle().plugin(
|
||||||
@@ -346,6 +308,9 @@ pub fn run() {
|
|||||||
|
|
||||||
let app_state = AppState::new(db);
|
let app_state = AppState::new(db);
|
||||||
|
|
||||||
|
// 设置 AppHandle 用于代理故障转移时的 UI 更新
|
||||||
|
app_state.proxy_service.set_app_handle(app.handle().clone());
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响)
|
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -551,6 +516,35 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 异常退出恢复 + 代理状态自动恢复
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let state = app_handle.state::<AppState>();
|
||||||
|
|
||||||
|
// 检查是否有 Live 备份(表示上次异常退出时可能处于接管状态)
|
||||||
|
let has_backups = match state.db.has_any_live_backup().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("检查 Live 备份失败: {e}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 检查 Live 配置是否仍处于被接管状态(包含占位符)
|
||||||
|
let live_taken_over = state.proxy_service.detect_takeover_in_live_configs();
|
||||||
|
|
||||||
|
if has_backups || live_taken_over {
|
||||||
|
log::warn!("检测到上次异常退出(存在接管残留),正在恢复 Live 配置...");
|
||||||
|
if let Err(e) = state.proxy_service.recover_from_crash().await {
|
||||||
|
log::error!("恢复 Live 配置失败: {e}");
|
||||||
|
} else {
|
||||||
|
log::info!("Live 配置已恢复");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||||
|
restore_proxy_state_on_startup(&state).await;
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -602,7 +596,7 @@ pub fn run() {
|
|||||||
commands::upsert_mcp_server_in_config,
|
commands::upsert_mcp_server_in_config,
|
||||||
commands::delete_mcp_server_in_config,
|
commands::delete_mcp_server_in_config,
|
||||||
commands::set_mcp_enabled,
|
commands::set_mcp_enabled,
|
||||||
// v3.7.0: Unified MCP management
|
// Unified MCP management
|
||||||
commands::get_mcp_servers,
|
commands::get_mcp_servers,
|
||||||
commands::upsert_mcp_server,
|
commands::upsert_mcp_server,
|
||||||
commands::delete_mcp_server,
|
commands::delete_mcp_server,
|
||||||
@@ -643,14 +637,63 @@ pub fn run() {
|
|||||||
commands::restore_env_backup,
|
commands::restore_env_backup,
|
||||||
// Skill management
|
// Skill management
|
||||||
commands::get_skills,
|
commands::get_skills,
|
||||||
|
commands::get_skills_for_app,
|
||||||
commands::install_skill,
|
commands::install_skill,
|
||||||
|
commands::install_skill_for_app,
|
||||||
commands::uninstall_skill,
|
commands::uninstall_skill,
|
||||||
|
commands::uninstall_skill_for_app,
|
||||||
commands::get_skill_repos,
|
commands::get_skill_repos,
|
||||||
commands::add_skill_repo,
|
commands::add_skill_repo,
|
||||||
commands::remove_skill_repo,
|
commands::remove_skill_repo,
|
||||||
// Auto launch
|
// Auto launch
|
||||||
commands::set_auto_launch,
|
commands::set_auto_launch,
|
||||||
commands::get_auto_launch_status,
|
commands::get_auto_launch_status,
|
||||||
|
// Proxy server management
|
||||||
|
commands::start_proxy_server,
|
||||||
|
commands::stop_proxy_with_restore,
|
||||||
|
commands::get_proxy_takeover_status,
|
||||||
|
commands::set_proxy_takeover_for_app,
|
||||||
|
commands::get_proxy_status,
|
||||||
|
commands::get_proxy_config,
|
||||||
|
commands::update_proxy_config,
|
||||||
|
// Global & Per-App Config
|
||||||
|
commands::get_global_proxy_config,
|
||||||
|
commands::update_global_proxy_config,
|
||||||
|
commands::get_proxy_config_for_app,
|
||||||
|
commands::update_proxy_config_for_app,
|
||||||
|
commands::is_proxy_running,
|
||||||
|
commands::is_live_takeover_active,
|
||||||
|
commands::switch_proxy_provider,
|
||||||
|
// Proxy failover commands
|
||||||
|
commands::get_provider_health,
|
||||||
|
commands::reset_circuit_breaker,
|
||||||
|
commands::get_circuit_breaker_config,
|
||||||
|
commands::update_circuit_breaker_config,
|
||||||
|
commands::get_circuit_breaker_stats,
|
||||||
|
// Failover queue management
|
||||||
|
commands::get_failover_queue,
|
||||||
|
commands::get_available_providers_for_failover,
|
||||||
|
commands::add_to_failover_queue,
|
||||||
|
commands::remove_from_failover_queue,
|
||||||
|
commands::get_auto_failover_enabled,
|
||||||
|
commands::set_auto_failover_enabled,
|
||||||
|
// Usage statistics
|
||||||
|
commands::get_usage_summary,
|
||||||
|
commands::get_usage_trends,
|
||||||
|
commands::get_provider_stats,
|
||||||
|
commands::get_model_stats,
|
||||||
|
commands::get_request_logs,
|
||||||
|
commands::get_request_detail,
|
||||||
|
commands::get_model_pricing,
|
||||||
|
commands::update_model_pricing,
|
||||||
|
commands::delete_model_pricing,
|
||||||
|
commands::check_provider_limits,
|
||||||
|
// Stream health check
|
||||||
|
commands::stream_check_provider,
|
||||||
|
commands::stream_check_all_providers,
|
||||||
|
commands::get_stream_check_config,
|
||||||
|
commands::save_stream_check_config,
|
||||||
|
commands::get_tool_versions,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let app = builder
|
let app = builder
|
||||||
@@ -658,6 +701,26 @@ pub fn run() {
|
|||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
||||||
app.run(|app_handle, event| {
|
app.run(|app_handle, event| {
|
||||||
|
// 处理退出请求(所有平台)
|
||||||
|
if let RunEvent::ExitRequested { api, .. } = &event {
|
||||||
|
log::info!("收到退出请求,开始清理...");
|
||||||
|
// 阻止立即退出,执行清理
|
||||||
|
api.prevent_exit();
|
||||||
|
|
||||||
|
let app_handle = app_handle.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
cleanup_before_exit(&app_handle).await;
|
||||||
|
log::info!("清理完成,退出应用");
|
||||||
|
|
||||||
|
// 短暂等待确保所有 I/O 操作(如数据库写入)刷新到磁盘
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
// 使用 std::process::exit 避免再次触发 ExitRequested
|
||||||
|
std::process::exit(0);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
match event {
|
match event {
|
||||||
@@ -737,6 +800,103 @@ pub fn run() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 应用退出清理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 应用退出前的清理工作
|
||||||
|
///
|
||||||
|
/// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。
|
||||||
|
/// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。
|
||||||
|
/// 使用 stop_with_restore_keep_state 保留 settings 表中的代理状态,下次启动时自动恢复。
|
||||||
|
pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
|
||||||
|
if let Some(state) = app_handle.try_state::<store::AppState>() {
|
||||||
|
let proxy_service = &state.proxy_service;
|
||||||
|
|
||||||
|
// 退出时也需要兜底:代理可能已崩溃/未运行,但 Live 接管残留仍在(占位符/备份)。
|
||||||
|
let has_backups = match state.db.has_any_live_backup().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("退出时检查 Live 备份失败: {e}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let live_taken_over = proxy_service.detect_takeover_in_live_configs();
|
||||||
|
let needs_restore = has_backups || live_taken_over;
|
||||||
|
|
||||||
|
if needs_restore {
|
||||||
|
log::info!("检测到接管残留,开始恢复 Live 配置(保留代理状态)...");
|
||||||
|
// 使用 keep_state 版本,保留 settings 表中的代理状态
|
||||||
|
if let Err(e) = proxy_service.stop_with_restore_keep_state().await {
|
||||||
|
log::error!("退出时恢复 Live 配置失败: {e}");
|
||||||
|
} else {
|
||||||
|
log::info!("已恢复 Live 配置(代理状态已保留,下次启动将自动恢复)");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非接管模式:代理在运行则仅停止代理
|
||||||
|
if proxy_service.is_running().await {
|
||||||
|
log::info!("检测到代理服务器正在运行,开始停止...");
|
||||||
|
if let Err(e) = proxy_service.stop().await {
|
||||||
|
log::error!("退出时停止代理失败: {e}");
|
||||||
|
}
|
||||||
|
log::info!("代理服务器清理完成");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 启动时恢复代理状态
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 启动时根据 proxy_config 表中的代理状态自动恢复代理服务
|
||||||
|
///
|
||||||
|
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`,
|
||||||
|
/// 则自动启动代理服务并接管对应应用的 Live 配置。
|
||||||
|
async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||||
|
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
|
||||||
|
let mut apps_to_restore = Vec::new();
|
||||||
|
for app_type in ["claude", "codex", "gemini"] {
|
||||||
|
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {
|
||||||
|
if config.enabled {
|
||||||
|
apps_to_restore.push(app_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if apps_to_restore.is_empty() {
|
||||||
|
log::debug!("启动时无需恢复代理状态");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("检测到上次代理状态需要恢复,应用列表: {apps_to_restore:?}");
|
||||||
|
|
||||||
|
// 逐个恢复接管状态
|
||||||
|
for app_type in apps_to_restore {
|
||||||
|
match state
|
||||||
|
.proxy_service
|
||||||
|
.set_takeover_for_app(app_type, true)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
log::info!("✓ 已恢复 {app_type} 的代理接管状态");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}");
|
||||||
|
// 失败时清除该应用的状态,避免下次启动再次尝试
|
||||||
|
if let Err(clear_err) = state
|
||||||
|
.proxy_service
|
||||||
|
.set_takeover_for_app(app_type, false)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::error!("清除 {app_type} 代理状态失败: {clear_err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 迁移错误对话框辅助函数
|
// 迁移错误对话框辅助函数
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -388,7 +388,8 @@ pub fn sync_single_server_to_codex(
|
|||||||
doc["mcp_servers"][id] = Item::Table(toml_table);
|
doc["mcp_servers"][id] = Item::Table(toml_table);
|
||||||
|
|
||||||
// 写回文件
|
// 写回文件
|
||||||
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
let new_text = doc.to_string();
|
||||||
|
crate::config::write_text_file(&config_path, &new_text)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -427,7 +428,8 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 写回文件
|
// 写回文件
|
||||||
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
let new_text = doc.to_string();
|
||||||
|
crate::config::write_text_file(&config_path, &new_text)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ pub struct Provider {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "iconColor")]
|
#[serde(rename = "iconColor")]
|
||||||
pub icon_color: Option<String>,
|
pub icon_color: Option<String>,
|
||||||
|
/// 是否加入故障转移队列
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(rename = "inFailoverQueue")]
|
||||||
|
pub in_failover_queue: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Provider {
|
impl Provider {
|
||||||
@@ -58,6 +62,7 @@ impl Provider {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,6 +156,15 @@ pub struct ProviderMeta {
|
|||||||
skip_serializing_if = "Option::is_none"
|
skip_serializing_if = "Option::is_none"
|
||||||
)]
|
)]
|
||||||
pub partner_promotion_key: Option<String>,
|
pub partner_promotion_key: Option<String>,
|
||||||
|
/// 成本倍数(用于计算实际成本)
|
||||||
|
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cost_multiplier: Option<String>,
|
||||||
|
/// 每日消费限额(USD)
|
||||||
|
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub limit_daily_usd: Option<String>,
|
||||||
|
/// 每月消费限额(USD)
|
||||||
|
#[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub limit_monthly_usd: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderManager {
|
impl ProviderManager {
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
//! 熔断器模块
|
||||||
|
//!
|
||||||
|
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
/// 熔断器状态
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CircuitState {
|
||||||
|
/// 关闭状态 - 正常工作
|
||||||
|
Closed,
|
||||||
|
/// 打开状态 - 熔断激活,拒绝请求
|
||||||
|
Open,
|
||||||
|
/// 半开状态 - 尝试恢复,允许部分请求通过
|
||||||
|
HalfOpen,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CircuitState {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
CircuitState::Closed => write!(f, "closed"),
|
||||||
|
CircuitState::Open => write!(f, "open"),
|
||||||
|
CircuitState::HalfOpen => write!(f, "half_open"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 熔断器配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CircuitBreakerConfig {
|
||||||
|
/// 失败阈值 - 连续失败多少次后打开熔断器
|
||||||
|
pub failure_threshold: u32,
|
||||||
|
/// 成功阈值 - 半开状态下成功多少次后关闭熔断器
|
||||||
|
pub success_threshold: u32,
|
||||||
|
/// 超时时间 - 熔断器打开后多久尝试半开(秒)
|
||||||
|
pub timeout_seconds: u64,
|
||||||
|
/// 错误率阈值 - 错误率超过此值时打开熔断器 (0.0-1.0)
|
||||||
|
pub error_rate_threshold: f64,
|
||||||
|
/// 最小请求数 - 计算错误率前的最小请求数
|
||||||
|
pub min_requests: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CircuitBreakerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
failure_threshold: 5,
|
||||||
|
success_threshold: 2,
|
||||||
|
timeout_seconds: 60,
|
||||||
|
error_rate_threshold: 0.5,
|
||||||
|
min_requests: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 熔断器实例
|
||||||
|
pub struct CircuitBreaker {
|
||||||
|
/// 当前状态
|
||||||
|
state: Arc<RwLock<CircuitState>>,
|
||||||
|
/// 连续失败计数
|
||||||
|
consecutive_failures: Arc<AtomicU32>,
|
||||||
|
/// 连续成功计数(半开状态)
|
||||||
|
consecutive_successes: Arc<AtomicU32>,
|
||||||
|
/// 总请求计数
|
||||||
|
total_requests: Arc<AtomicU32>,
|
||||||
|
/// 失败请求计数
|
||||||
|
failed_requests: Arc<AtomicU32>,
|
||||||
|
/// 上次打开时间
|
||||||
|
last_opened_at: Arc<RwLock<Option<Instant>>>,
|
||||||
|
/// 配置(支持热更新)
|
||||||
|
config: Arc<RwLock<CircuitBreakerConfig>>,
|
||||||
|
/// 半开状态已放行的请求数(用于限流)
|
||||||
|
half_open_requests: Arc<AtomicU32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 熔断器放行结果
|
||||||
|
///
|
||||||
|
/// `used_half_open_permit` 表示本次放行是否占用了 HalfOpen 探测名额。
|
||||||
|
/// 调用方应在请求结束后把该值传回 `record_success` / `record_failure` 用于正确释放名额。
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct AllowResult {
|
||||||
|
pub allowed: bool,
|
||||||
|
pub used_half_open_permit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CircuitBreaker {
|
||||||
|
/// 创建新的熔断器
|
||||||
|
pub fn new(config: CircuitBreakerConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
state: Arc::new(RwLock::new(CircuitState::Closed)),
|
||||||
|
consecutive_failures: Arc::new(AtomicU32::new(0)),
|
||||||
|
consecutive_successes: Arc::new(AtomicU32::new(0)),
|
||||||
|
total_requests: Arc::new(AtomicU32::new(0)),
|
||||||
|
failed_requests: Arc::new(AtomicU32::new(0)),
|
||||||
|
last_opened_at: Arc::new(RwLock::new(None)),
|
||||||
|
config: Arc::new(RwLock::new(config)),
|
||||||
|
half_open_requests: Arc::new(AtomicU32::new(0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新熔断器配置(热更新,不重置状态)
|
||||||
|
pub async fn update_config(&self, new_config: CircuitBreakerConfig) {
|
||||||
|
*self.config.write().await = new_config;
|
||||||
|
log::debug!("Circuit breaker config updated");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断当前 Provider 是否“可被纳入候选链路”
|
||||||
|
///
|
||||||
|
/// 这个方法不会占用 HalfOpen 探测名额,仅用于路由选择阶段的“可用性判断”:
|
||||||
|
/// - Closed / HalfOpen:可用(返回 true)
|
||||||
|
/// - Open:若超时到达则切到 HalfOpen 并返回 true,否则返回 false
|
||||||
|
///
|
||||||
|
/// 注意:真正发起请求前仍需调用 `allow_request()` 来获取 HalfOpen 探测名额,
|
||||||
|
/// 并在请求结束后通过 `record_success()` / `record_failure()` 释放。
|
||||||
|
pub async fn is_available(&self) -> bool {
|
||||||
|
let state = *self.state.read().await;
|
||||||
|
let config = self.config.read().await;
|
||||||
|
|
||||||
|
match state {
|
||||||
|
CircuitState::Closed | CircuitState::HalfOpen => true,
|
||||||
|
CircuitState::Open => {
|
||||||
|
if let Some(opened_at) = *self.last_opened_at.read().await {
|
||||||
|
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||||
|
drop(config); // 释放读锁再转换状态
|
||||||
|
log::info!(
|
||||||
|
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
|
||||||
|
);
|
||||||
|
self.transition_to_half_open().await;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否允许请求通过
|
||||||
|
pub async fn allow_request(&self) -> AllowResult {
|
||||||
|
let state = *self.state.read().await;
|
||||||
|
|
||||||
|
match state {
|
||||||
|
CircuitState::Closed => AllowResult {
|
||||||
|
allowed: true,
|
||||||
|
used_half_open_permit: false,
|
||||||
|
},
|
||||||
|
CircuitState::Open => {
|
||||||
|
let config = self.config.read().await;
|
||||||
|
// 检查是否应该尝试半开
|
||||||
|
if let Some(opened_at) = *self.last_opened_at.read().await {
|
||||||
|
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||||
|
drop(config); // 释放读锁再转换状态
|
||||||
|
log::info!(
|
||||||
|
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
|
||||||
|
);
|
||||||
|
self.transition_to_half_open().await;
|
||||||
|
|
||||||
|
// 转换后按当前状态决定是否需要获取 HalfOpen 探测名额
|
||||||
|
let current_state = *self.state.read().await;
|
||||||
|
return match current_state {
|
||||||
|
CircuitState::Closed => AllowResult {
|
||||||
|
allowed: true,
|
||||||
|
used_half_open_permit: false,
|
||||||
|
},
|
||||||
|
CircuitState::HalfOpen => self.allow_half_open_probe(),
|
||||||
|
CircuitState::Open => AllowResult {
|
||||||
|
allowed: false,
|
||||||
|
used_half_open_permit: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AllowResult {
|
||||||
|
allowed: false,
|
||||||
|
used_half_open_permit: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CircuitState::HalfOpen => self.allow_half_open_probe(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录成功
|
||||||
|
pub async fn record_success(&self, used_half_open_permit: bool) {
|
||||||
|
let state = *self.state.read().await;
|
||||||
|
let config = self.config.read().await;
|
||||||
|
|
||||||
|
if used_half_open_permit {
|
||||||
|
self.release_half_open_permit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置失败计数
|
||||||
|
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||||
|
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
||||||
|
|
||||||
|
match state {
|
||||||
|
CircuitState::HalfOpen => {
|
||||||
|
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
log::debug!(
|
||||||
|
"Circuit breaker HalfOpen: {} consecutive successes (threshold: {})",
|
||||||
|
successes,
|
||||||
|
config.success_threshold
|
||||||
|
);
|
||||||
|
|
||||||
|
if successes >= config.success_threshold {
|
||||||
|
drop(config); // 释放读锁再转换状态
|
||||||
|
log::info!("Circuit breaker transitioning from HalfOpen to Closed (success threshold reached)");
|
||||||
|
self.transition_to_closed().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CircuitState::Closed => {
|
||||||
|
log::debug!("Circuit breaker Closed: request succeeded");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录失败
|
||||||
|
pub async fn record_failure(&self, used_half_open_permit: bool) {
|
||||||
|
let state = *self.state.read().await;
|
||||||
|
let config = self.config.read().await;
|
||||||
|
|
||||||
|
if used_half_open_permit {
|
||||||
|
self.release_half_open_permit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新计数器
|
||||||
|
let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
||||||
|
self.failed_requests.fetch_add(1, Ordering::SeqCst);
|
||||||
|
|
||||||
|
// 重置成功计数
|
||||||
|
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"Circuit breaker {:?}: {} consecutive failures (threshold: {})",
|
||||||
|
state,
|
||||||
|
failures,
|
||||||
|
config.failure_threshold
|
||||||
|
);
|
||||||
|
|
||||||
|
// 检查是否应该打开熔断器
|
||||||
|
match state {
|
||||||
|
CircuitState::HalfOpen => {
|
||||||
|
// HalfOpen 状态下失败,立即转为 Open
|
||||||
|
log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open");
|
||||||
|
drop(config);
|
||||||
|
self.transition_to_open().await;
|
||||||
|
}
|
||||||
|
CircuitState::Closed => {
|
||||||
|
// 检查连续失败次数
|
||||||
|
if failures >= config.failure_threshold {
|
||||||
|
log::warn!(
|
||||||
|
"Circuit breaker opening due to {} consecutive failures (threshold: {})",
|
||||||
|
failures,
|
||||||
|
config.failure_threshold
|
||||||
|
);
|
||||||
|
drop(config); // 释放读锁再转换状态
|
||||||
|
self.transition_to_open().await;
|
||||||
|
} else {
|
||||||
|
// 检查错误率
|
||||||
|
let total = self.total_requests.load(Ordering::SeqCst);
|
||||||
|
let failed = self.failed_requests.load(Ordering::SeqCst);
|
||||||
|
|
||||||
|
if total >= config.min_requests {
|
||||||
|
let error_rate = failed as f64 / total as f64;
|
||||||
|
log::debug!(
|
||||||
|
"Circuit breaker error rate: {:.2}% ({}/{} requests)",
|
||||||
|
error_rate * 100.0,
|
||||||
|
failed,
|
||||||
|
total
|
||||||
|
);
|
||||||
|
|
||||||
|
if error_rate >= config.error_rate_threshold {
|
||||||
|
log::warn!(
|
||||||
|
"Circuit breaker opening due to high error rate: {:.2}% (threshold: {:.2}%)",
|
||||||
|
error_rate * 100.0,
|
||||||
|
config.error_rate_threshold * 100.0
|
||||||
|
);
|
||||||
|
drop(config); // 释放读锁再转换状态
|
||||||
|
self.transition_to_open().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前状态
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn get_state(&self) -> CircuitState {
|
||||||
|
*self.state.read().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取统计信息
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn get_stats(&self) -> CircuitBreakerStats {
|
||||||
|
CircuitBreakerStats {
|
||||||
|
state: *self.state.read().await,
|
||||||
|
consecutive_failures: self.consecutive_failures.load(Ordering::SeqCst),
|
||||||
|
consecutive_successes: self.consecutive_successes.load(Ordering::SeqCst),
|
||||||
|
total_requests: self.total_requests.load(Ordering::SeqCst),
|
||||||
|
failed_requests: self.failed_requests.load(Ordering::SeqCst),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置熔断器(手动恢复)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn reset(&self) {
|
||||||
|
log::info!("Circuit breaker manually reset to Closed state");
|
||||||
|
self.transition_to_closed().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allow_half_open_probe(&self) -> AllowResult {
|
||||||
|
// 半开状态限流:只允许有限请求通过进行探测
|
||||||
|
// 默认最多允许 1 个请求(可在配置中扩展)
|
||||||
|
let max_half_open_requests = 1u32;
|
||||||
|
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
|
||||||
|
|
||||||
|
if current < max_half_open_requests {
|
||||||
|
log::debug!(
|
||||||
|
"Circuit breaker HalfOpen: allowing probe request ({}/{})",
|
||||||
|
current + 1,
|
||||||
|
max_half_open_requests
|
||||||
|
);
|
||||||
|
AllowResult {
|
||||||
|
allowed: true,
|
||||||
|
used_half_open_permit: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 超过限额,回退计数,拒绝请求
|
||||||
|
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
log::debug!(
|
||||||
|
"Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
|
||||||
|
);
|
||||||
|
AllowResult {
|
||||||
|
allowed: false,
|
||||||
|
used_half_open_permit: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release_half_open_permit(&self) {
|
||||||
|
let mut current = self.half_open_requests.load(Ordering::SeqCst);
|
||||||
|
loop {
|
||||||
|
if current == 0 {
|
||||||
|
// 理论上不应该发生:说明调用方传入的 used_half_open_permit 与实际占用不一致
|
||||||
|
log::debug!("Circuit breaker HalfOpen permit already released (counter=0)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.half_open_requests.compare_exchange(
|
||||||
|
current,
|
||||||
|
current - 1,
|
||||||
|
Ordering::SeqCst,
|
||||||
|
Ordering::SeqCst,
|
||||||
|
) {
|
||||||
|
Ok(_) => return,
|
||||||
|
Err(actual) => current = actual,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转换到打开状态
|
||||||
|
async fn transition_to_open(&self) {
|
||||||
|
*self.state.write().await = CircuitState::Open;
|
||||||
|
*self.last_opened_at.write().await = Some(Instant::now());
|
||||||
|
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||||
|
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转换到半开状态
|
||||||
|
async fn transition_to_half_open(&self) {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
if *state != CircuitState::Open {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
*state = CircuitState::HalfOpen;
|
||||||
|
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||||
|
// 重置半开状态的请求限流计数
|
||||||
|
self.half_open_requests.store(0, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转换到关闭状态
|
||||||
|
async fn transition_to_closed(&self) {
|
||||||
|
*self.state.write().await = CircuitState::Closed;
|
||||||
|
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||||
|
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||||
|
// 重置计数器
|
||||||
|
self.total_requests.store(0, Ordering::SeqCst);
|
||||||
|
self.failed_requests.store(0, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 熔断器统计信息
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CircuitBreakerStats {
|
||||||
|
pub state: CircuitState,
|
||||||
|
pub consecutive_failures: u32,
|
||||||
|
pub consecutive_successes: u32,
|
||||||
|
pub total_requests: u32,
|
||||||
|
pub failed_requests: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_circuit_breaker_closed_to_open() {
|
||||||
|
let config = CircuitBreakerConfig {
|
||||||
|
failure_threshold: 3,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let breaker = CircuitBreaker::new(config);
|
||||||
|
|
||||||
|
// 初始状态应该是关闭
|
||||||
|
assert_eq!(breaker.get_state().await, CircuitState::Closed);
|
||||||
|
assert!(breaker.allow_request().await.allowed);
|
||||||
|
|
||||||
|
// 记录 3 次失败
|
||||||
|
for _ in 0..3 {
|
||||||
|
breaker.record_failure(false).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应该转换到打开状态
|
||||||
|
assert_eq!(breaker.get_state().await, CircuitState::Open);
|
||||||
|
assert!(!breaker.allow_request().await.allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_circuit_breaker_half_open_to_closed() {
|
||||||
|
let config = CircuitBreakerConfig {
|
||||||
|
failure_threshold: 2,
|
||||||
|
success_threshold: 2,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let breaker = CircuitBreaker::new(config);
|
||||||
|
|
||||||
|
// 打开熔断器
|
||||||
|
breaker.record_failure(false).await;
|
||||||
|
breaker.record_failure(false).await;
|
||||||
|
assert_eq!(breaker.get_state().await, CircuitState::Open);
|
||||||
|
|
||||||
|
// 手动转换到半开状态
|
||||||
|
breaker.transition_to_half_open().await;
|
||||||
|
assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);
|
||||||
|
|
||||||
|
// 记录 2 次成功
|
||||||
|
breaker.record_success(false).await;
|
||||||
|
breaker.record_success(false).await;
|
||||||
|
|
||||||
|
// 应该转换到关闭状态
|
||||||
|
assert_eq!(breaker.get_state().await, CircuitState::Closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_half_open_transition_does_not_reset_inflight_permit() {
|
||||||
|
let config = CircuitBreakerConfig {
|
||||||
|
timeout_seconds: 0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let breaker = CircuitBreaker::new(config);
|
||||||
|
|
||||||
|
// 进入 Open,然后由于 timeout_seconds=0,allow_request 会立即切换到 HalfOpen 并占用探测名额
|
||||||
|
breaker.transition_to_open().await;
|
||||||
|
let first = breaker.allow_request().await;
|
||||||
|
assert!(first.allowed);
|
||||||
|
assert!(first.used_half_open_permit);
|
||||||
|
assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);
|
||||||
|
|
||||||
|
// 模拟并发下的“重复 HalfOpen 转换调用”,不应重置 in-flight 计数
|
||||||
|
breaker.transition_to_half_open().await;
|
||||||
|
|
||||||
|
// 由于名额仍被占用,第二次请求应被拒绝
|
||||||
|
let second = breaker.allow_request().await;
|
||||||
|
assert!(!second.allowed);
|
||||||
|
assert!(!second.used_half_open_permit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_circuit_breaker_reset() {
|
||||||
|
let config = CircuitBreakerConfig {
|
||||||
|
failure_threshold: 2,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let breaker = CircuitBreaker::new(config);
|
||||||
|
|
||||||
|
// 打开熔断器
|
||||||
|
breaker.record_failure(false).await;
|
||||||
|
breaker.record_failure(false).await;
|
||||||
|
assert_eq!(breaker.get_state().await, CircuitState::Open);
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
breaker.reset().await;
|
||||||
|
assert_eq!(breaker.get_state().await, CircuitState::Closed);
|
||||||
|
assert!(breaker.allow_request().await.allowed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ProxyError {
|
||||||
|
#[error("服务器已在运行")]
|
||||||
|
AlreadyRunning,
|
||||||
|
|
||||||
|
#[error("服务器未运行")]
|
||||||
|
NotRunning,
|
||||||
|
|
||||||
|
#[error("地址绑定失败: {0}")]
|
||||||
|
BindFailed(String),
|
||||||
|
|
||||||
|
#[error("请求转发失败: {0}")]
|
||||||
|
ForwardFailed(String),
|
||||||
|
|
||||||
|
#[error("无可用的Provider")]
|
||||||
|
NoAvailableProvider,
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[error("Provider不健康: {0}")]
|
||||||
|
ProviderUnhealthy(String),
|
||||||
|
|
||||||
|
#[error("上游错误 (状态码 {status}): {body:?}")]
|
||||||
|
UpstreamError { status: u16, body: Option<String> },
|
||||||
|
|
||||||
|
#[error("超过最大重试次数")]
|
||||||
|
MaxRetriesExceeded,
|
||||||
|
|
||||||
|
#[error("数据库错误: {0}")]
|
||||||
|
DatabaseError(String),
|
||||||
|
|
||||||
|
#[error("配置错误: {0}")]
|
||||||
|
ConfigError(String),
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[error("格式转换错误: {0}")]
|
||||||
|
TransformError(String),
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[error("无效的请求: {0}")]
|
||||||
|
InvalidRequest(String),
|
||||||
|
|
||||||
|
#[error("超时: {0}")]
|
||||||
|
Timeout(String),
|
||||||
|
|
||||||
|
/// 流式响应空闲超时
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[error("流式响应空闲超时: {0}秒无数据")]
|
||||||
|
StreamIdleTimeout(u64),
|
||||||
|
|
||||||
|
/// 认证错误
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[error("认证失败: {0}")]
|
||||||
|
AuthError(String),
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[error("内部错误: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ProxyError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, body) = match &self {
|
||||||
|
ProxyError::UpstreamError {
|
||||||
|
status: upstream_status,
|
||||||
|
body: upstream_body,
|
||||||
|
} => {
|
||||||
|
let http_status =
|
||||||
|
StatusCode::from_u16(*upstream_status).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||||
|
|
||||||
|
// 尝试解析上游响应体为 JSON,如果失败则包装为字符串
|
||||||
|
let error_body = if let Some(body_str) = upstream_body {
|
||||||
|
if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(body_str) {
|
||||||
|
// 上游返回的是 JSON,直接透传
|
||||||
|
json_body
|
||||||
|
} else {
|
||||||
|
// 上游返回的不是 JSON,包装为错误消息
|
||||||
|
json!({
|
||||||
|
"error": {
|
||||||
|
"message": body_str,
|
||||||
|
"type": "upstream_error",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
json!({
|
||||||
|
"error": {
|
||||||
|
"message": format!("Upstream error (status {})", upstream_status),
|
||||||
|
"type": "upstream_error",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
(http_status, error_body)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let (http_status, message) = match &self {
|
||||||
|
ProxyError::AlreadyRunning => (StatusCode::CONFLICT, self.to_string()),
|
||||||
|
ProxyError::NotRunning => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()),
|
||||||
|
ProxyError::BindFailed(_) => {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||||
|
}
|
||||||
|
ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
|
||||||
|
ProxyError::NoAvailableProvider => {
|
||||||
|
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||||
|
}
|
||||||
|
ProxyError::ProviderUnhealthy(_) => {
|
||||||
|
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||||
|
}
|
||||||
|
ProxyError::MaxRetriesExceeded => {
|
||||||
|
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||||
|
}
|
||||||
|
ProxyError::DatabaseError(_) => {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||||
|
}
|
||||||
|
ProxyError::ConfigError(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||||
|
ProxyError::TransformError(_) => {
|
||||||
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||||
|
}
|
||||||
|
ProxyError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||||
|
ProxyError::Timeout(_) => (StatusCode::GATEWAY_TIMEOUT, self.to_string()),
|
||||||
|
ProxyError::StreamIdleTimeout(_) => {
|
||||||
|
(StatusCode::GATEWAY_TIMEOUT, self.to_string())
|
||||||
|
}
|
||||||
|
ProxyError::AuthError(_) => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||||
|
ProxyError::Internal(_) => {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||||
|
}
|
||||||
|
ProxyError::UpstreamError { .. } => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let error_body = json!({
|
||||||
|
"error": {
|
||||||
|
"message": message,
|
||||||
|
"type": "proxy_error",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(http_status, error_body)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 错误分类
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ErrorCategory {
|
||||||
|
/// 可重试错误(网络问题、5xx)
|
||||||
|
Retryable, // 网络超时、5xx 错误
|
||||||
|
/// 不可重试错误(4xx、认证失败)
|
||||||
|
NonRetryable, // 认证失败、参数错误、4xx 错误
|
||||||
|
#[allow(dead_code)]
|
||||||
|
ClientAbort, // 客户端主动中断
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断错误是否可重试
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn categorize_error(error: &reqwest::Error) -> ErrorCategory {
|
||||||
|
if error.is_timeout() || error.is_connect() {
|
||||||
|
return ErrorCategory::Retryable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(status) = error.status() {
|
||||||
|
if status.is_server_error() {
|
||||||
|
ErrorCategory::Retryable
|
||||||
|
} else if status.is_client_error() {
|
||||||
|
ErrorCategory::NonRetryable
|
||||||
|
} else {
|
||||||
|
ErrorCategory::Retryable
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ErrorCategory::Retryable
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
//! 错误类型到 HTTP 状态码的映射
|
||||||
|
//!
|
||||||
|
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录
|
||||||
|
|
||||||
|
use super::ProxyError;
|
||||||
|
|
||||||
|
/// 将 ProxyError 映射到 HTTP 状态码
|
||||||
|
///
|
||||||
|
/// 映射规则:
|
||||||
|
/// - 上游错误:直接使用上游返回的状态码
|
||||||
|
/// - 超时:504 Gateway Timeout
|
||||||
|
/// - 连接失败:502 Bad Gateway
|
||||||
|
/// - 无可用 Provider:503 Service Unavailable
|
||||||
|
/// - 重试耗尽:503 Service Unavailable
|
||||||
|
/// - 其他错误:500 Internal Server Error
|
||||||
|
pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
|
||||||
|
match error {
|
||||||
|
// 上游错误:使用实际状态码
|
||||||
|
ProxyError::UpstreamError { status, .. } => *status,
|
||||||
|
|
||||||
|
// 超时错误:504 Gateway Timeout
|
||||||
|
ProxyError::Timeout(_) => 504,
|
||||||
|
|
||||||
|
// 转发失败/连接失败:502 Bad Gateway
|
||||||
|
ProxyError::ForwardFailed(_) => 502,
|
||||||
|
|
||||||
|
// 无可用 Provider:503 Service Unavailable
|
||||||
|
ProxyError::NoAvailableProvider => 503,
|
||||||
|
|
||||||
|
// 重试耗尽:503 Service Unavailable
|
||||||
|
ProxyError::MaxRetriesExceeded => 503,
|
||||||
|
|
||||||
|
// Provider 不健康:503 Service Unavailable
|
||||||
|
ProxyError::ProviderUnhealthy(_) => 503,
|
||||||
|
|
||||||
|
// 数据库错误:500 Internal Server Error
|
||||||
|
ProxyError::DatabaseError(_) => 500,
|
||||||
|
|
||||||
|
// 转换错误:500 Internal Server Error
|
||||||
|
ProxyError::TransformError(_) => 500,
|
||||||
|
|
||||||
|
// 其他未知错误:500 Internal Server Error
|
||||||
|
_ => 500,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 ProxyError 转换为用户友好的错误消息
|
||||||
|
pub fn get_error_message(error: &ProxyError) -> String {
|
||||||
|
match error {
|
||||||
|
ProxyError::UpstreamError { status, body } => {
|
||||||
|
if let Some(body) = body {
|
||||||
|
format!("上游错误 ({status}): {body}")
|
||||||
|
} else {
|
||||||
|
format!("上游错误 ({status})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProxyError::Timeout(msg) => format!("请求超时: {msg}"),
|
||||||
|
ProxyError::ForwardFailed(msg) => format!("转发失败: {msg}"),
|
||||||
|
ProxyError::NoAvailableProvider => "无可用 Provider".to_string(),
|
||||||
|
ProxyError::MaxRetriesExceeded => "所有 Provider 都失败,重试耗尽".to_string(),
|
||||||
|
ProxyError::ProviderUnhealthy(msg) => format!("Provider 不健康: {msg}"),
|
||||||
|
ProxyError::DatabaseError(msg) => format!("数据库错误: {msg}"),
|
||||||
|
ProxyError::TransformError(msg) => format!("请求/响应转换错误: {msg}"),
|
||||||
|
_ => error.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_map_upstream_error() {
|
||||||
|
let error = ProxyError::UpstreamError {
|
||||||
|
status: 401,
|
||||||
|
body: Some("Unauthorized".to_string()),
|
||||||
|
};
|
||||||
|
assert_eq!(map_proxy_error_to_status(&error), 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_map_timeout_error() {
|
||||||
|
let error = ProxyError::Timeout("Request timeout".to_string());
|
||||||
|
assert_eq!(map_proxy_error_to_status(&error), 504);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_map_connection_error() {
|
||||||
|
let error = ProxyError::ForwardFailed("Connection refused".to_string());
|
||||||
|
assert_eq!(map_proxy_error_to_status(&error), 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_map_no_provider_error() {
|
||||||
|
let error = ProxyError::NoAvailableProvider;
|
||||||
|
assert_eq!(map_proxy_error_to_status(&error), 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_error_message() {
|
||||||
|
let error = ProxyError::UpstreamError {
|
||||||
|
status: 500,
|
||||||
|
body: Some("Internal Server Error".to_string()),
|
||||||
|
};
|
||||||
|
let msg = get_error_message(&error);
|
||||||
|
assert!(msg.contains("上游错误"));
|
||||||
|
assert!(msg.contains("500"));
|
||||||
|
assert!(msg.contains("Internal Server Error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//! 故障转移切换模块
|
||||||
|
//!
|
||||||
|
//! 处理故障转移成功后的供应商切换逻辑,包括:
|
||||||
|
//! - 去重控制(避免多个请求同时触发)
|
||||||
|
//! - 数据库更新
|
||||||
|
//! - 托盘菜单更新
|
||||||
|
//! - 前端事件发射
|
||||||
|
//! - Live 备份更新
|
||||||
|
|
||||||
|
use crate::database::Database;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
/// 故障转移切换管理器
|
||||||
|
///
|
||||||
|
/// 负责处理故障转移成功后的供应商切换,确保 UI 能够直观反映当前使用的供应商。
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FailoverSwitchManager {
|
||||||
|
/// 正在处理中的切换(key = "app_type:provider_id")
|
||||||
|
pending_switches: Arc<RwLock<HashSet<String>>>,
|
||||||
|
db: Arc<Database>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FailoverSwitchManager {
|
||||||
|
pub fn new(db: Arc<Database>) -> Self {
|
||||||
|
Self {
|
||||||
|
pending_switches: Arc::new(RwLock::new(HashSet::new())),
|
||||||
|
db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 尝试执行故障转移切换
|
||||||
|
///
|
||||||
|
/// 如果相同的切换已在进行中,则跳过;否则执行切换逻辑。
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(true)` - 切换成功执行
|
||||||
|
/// - `Ok(false)` - 切换已在进行中,跳过
|
||||||
|
/// - `Err(e)` - 切换过程中发生错误
|
||||||
|
pub async fn try_switch(
|
||||||
|
&self,
|
||||||
|
app_handle: Option<&tauri::AppHandle>,
|
||||||
|
app_type: &str,
|
||||||
|
provider_id: &str,
|
||||||
|
provider_name: &str,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
let switch_key = format!("{app_type}:{provider_id}");
|
||||||
|
|
||||||
|
// 去重检查:如果相同切换已在进行中,跳过
|
||||||
|
{
|
||||||
|
let mut pending = self.pending_switches.write().await;
|
||||||
|
if pending.contains(&switch_key) {
|
||||||
|
log::debug!("[Failover] 切换已在进行中,跳过: {app_type} -> {provider_id}");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
pending.insert(switch_key.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行切换(确保最后清理 pending 标记)
|
||||||
|
let result = self
|
||||||
|
.do_switch(app_handle, app_type, provider_id, provider_name)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 清理 pending 标记
|
||||||
|
{
|
||||||
|
let mut pending = self.pending_switches.write().await;
|
||||||
|
pending.remove(&switch_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_switch(
|
||||||
|
&self,
|
||||||
|
app_handle: Option<&tauri::AppHandle>,
|
||||||
|
app_type: &str,
|
||||||
|
provider_id: &str,
|
||||||
|
provider_name: &str,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})");
|
||||||
|
|
||||||
|
// 1. 更新数据库 is_current
|
||||||
|
self.db.set_current_provider(app_type, provider_id)?;
|
||||||
|
|
||||||
|
// 2. 更新本地 settings(设备级)
|
||||||
|
let app_type_enum = crate::app_config::AppType::from_str(app_type)
|
||||||
|
.map_err(|_| AppError::Message(format!("无效的应用类型: {app_type}")))?;
|
||||||
|
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?;
|
||||||
|
|
||||||
|
// 3. 更新托盘菜单和发射事件
|
||||||
|
if let Some(app) = app_handle {
|
||||||
|
// 更新托盘菜单
|
||||||
|
if let Some(app_state) = app.try_state::<crate::store::AppState>() {
|
||||||
|
// 更新 Live 备份(确保代理停止时恢复正确配置)
|
||||||
|
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
|
||||||
|
if let Err(e) = app_state
|
||||||
|
.proxy_service
|
||||||
|
.update_live_backup_from_provider(app_type, &provider)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::warn!("[Failover] 更新 Live 备份失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重建托盘菜单
|
||||||
|
if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) {
|
||||||
|
if let Some(tray) = app.tray_by_id("main") {
|
||||||
|
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||||
|
log::error!("[Failover] 更新托盘菜单失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发射事件到前端
|
||||||
|
let event_data = serde_json::json!({
|
||||||
|
"appType": app_type,
|
||||||
|
"providerId": provider_id,
|
||||||
|
"source": "failover" // 标识来源是故障转移
|
||||||
|
});
|
||||||
|
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||||
|
log::error!("[Failover] 发射供应商切换事件失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[Failover] 供应商切换完成: {app_type} -> {provider_name} ({provider_id})");
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
//! 请求转发器
|
||||||
|
//!
|
||||||
|
//! 负责将请求转发到上游Provider,支持重试和故障转移
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
error::*,
|
||||||
|
failover_switch::FailoverSwitchManager,
|
||||||
|
provider_router::ProviderRouter,
|
||||||
|
providers::{get_adapter, ProviderAdapter},
|
||||||
|
types::ProxyStatus,
|
||||||
|
ProxyError,
|
||||||
|
};
|
||||||
|
use crate::{app_config::AppType, provider::Provider};
|
||||||
|
use reqwest::{Client, Response};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
pub struct ForwardResult {
|
||||||
|
pub response: Response,
|
||||||
|
pub provider: Provider,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ForwardError {
|
||||||
|
pub error: ProxyError,
|
||||||
|
pub provider: Option<Provider>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RequestForwarder {
|
||||||
|
client: Client,
|
||||||
|
/// 共享的 ProviderRouter(持有熔断器状态)
|
||||||
|
router: Arc<ProviderRouter>,
|
||||||
|
/// 单个 Provider 内的最大重试次数
|
||||||
|
max_retries: u8,
|
||||||
|
status: Arc<RwLock<ProxyStatus>>,
|
||||||
|
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||||
|
/// 故障转移切换管理器
|
||||||
|
failover_manager: Arc<FailoverSwitchManager>,
|
||||||
|
/// AppHandle,用于发射事件和更新托盘
|
||||||
|
app_handle: Option<tauri::AppHandle>,
|
||||||
|
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
||||||
|
current_provider_id_at_start: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestForwarder {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new(
|
||||||
|
router: Arc<ProviderRouter>,
|
||||||
|
non_streaming_timeout: u64,
|
||||||
|
max_retries: u8,
|
||||||
|
status: Arc<RwLock<ProxyStatus>>,
|
||||||
|
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||||
|
failover_manager: Arc<FailoverSwitchManager>,
|
||||||
|
app_handle: Option<tauri::AppHandle>,
|
||||||
|
current_provider_id_at_start: String,
|
||||||
|
_streaming_first_byte_timeout: u64,
|
||||||
|
_streaming_idle_timeout: u64,
|
||||||
|
) -> Self {
|
||||||
|
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
|
||||||
|
// 参考 Claude Code Hub 的 undici 全局超时设计
|
||||||
|
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
|
||||||
|
|
||||||
|
let mut client_builder = Client::builder();
|
||||||
|
if non_streaming_timeout > 0 {
|
||||||
|
// 使用配置的非流式超时
|
||||||
|
client_builder = client_builder.timeout(Duration::from_secs(non_streaming_timeout));
|
||||||
|
} else {
|
||||||
|
// 禁用超时时使用全局超时作为保底
|
||||||
|
client_builder = client_builder.timeout(Duration::from_secs(GLOBAL_TIMEOUT_SECS));
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = client_builder
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create HTTP client");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
router,
|
||||||
|
max_retries,
|
||||||
|
status,
|
||||||
|
current_providers,
|
||||||
|
failover_manager,
|
||||||
|
app_handle,
|
||||||
|
current_provider_id_at_start,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对单个 Provider 执行请求(带重试)
|
||||||
|
///
|
||||||
|
/// 在同一个 Provider 上最多重试 max_retries 次,使用指数退避
|
||||||
|
async fn forward_with_provider_retry(
|
||||||
|
&self,
|
||||||
|
provider: &Provider,
|
||||||
|
endpoint: &str,
|
||||||
|
body: &Value,
|
||||||
|
headers: &axum::http::HeaderMap,
|
||||||
|
adapter: &dyn ProviderAdapter,
|
||||||
|
) -> Result<Response, ProxyError> {
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
|
for attempt in 0..=self.max_retries {
|
||||||
|
if attempt > 0 {
|
||||||
|
// 指数退避:100ms, 200ms, 400ms, ...
|
||||||
|
let delay_ms = 100 * 2u64.pow(attempt as u32 - 1);
|
||||||
|
log::info!(
|
||||||
|
"[{}] 重试第 {}/{} 次(等待 {}ms)",
|
||||||
|
adapter.name(),
|
||||||
|
attempt,
|
||||||
|
self.max_retries,
|
||||||
|
delay_ms
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self
|
||||||
|
.forward(provider, endpoint, body, headers, adapter)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => return Ok(response),
|
||||||
|
Err(e) => {
|
||||||
|
// 只有“同一 Provider 内可重试”的错误才继续重试
|
||||||
|
if !self.should_retry_same_provider(&e) {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"[{}] Provider {} 第 {} 次请求失败: {}",
|
||||||
|
adapter.name(),
|
||||||
|
provider.name,
|
||||||
|
attempt + 1,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
last_error = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转发请求(带故障转移)
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `app_type` - 应用类型
|
||||||
|
/// * `endpoint` - API 端点
|
||||||
|
/// * `body` - 请求体
|
||||||
|
/// * `headers` - 请求头
|
||||||
|
/// * `providers` - 已选择的 Provider 列表(由 RequestContext 提供,避免重复调用 select_providers)
|
||||||
|
pub async fn forward_with_retry(
|
||||||
|
&self,
|
||||||
|
app_type: &AppType,
|
||||||
|
endpoint: &str,
|
||||||
|
body: Value,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
providers: Vec<Provider>,
|
||||||
|
) -> Result<ForwardResult, ForwardError> {
|
||||||
|
// 获取适配器
|
||||||
|
let adapter = get_adapter(app_type);
|
||||||
|
let app_type_str = app_type.as_str();
|
||||||
|
|
||||||
|
if providers.is_empty() {
|
||||||
|
return Err(ForwardError {
|
||||||
|
error: ProxyError::NoAvailableProvider,
|
||||||
|
provider: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[{}] 故障转移链: {} 个可用供应商",
|
||||||
|
app_type_str,
|
||||||
|
providers.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut last_error = None;
|
||||||
|
let mut last_provider = None;
|
||||||
|
let mut attempted_providers = 0usize;
|
||||||
|
|
||||||
|
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||||
|
let bypass_circuit_breaker = providers.len() == 1;
|
||||||
|
|
||||||
|
// 依次尝试每个供应商
|
||||||
|
for provider in providers.iter() {
|
||||||
|
// 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额)
|
||||||
|
// 单 Provider 场景下跳过此检查,避免熔断器阻塞所有请求
|
||||||
|
let (allowed, used_half_open_permit) = if bypass_circuit_breaker {
|
||||||
|
(true, false)
|
||||||
|
} else {
|
||||||
|
let permit = self
|
||||||
|
.router
|
||||||
|
.allow_provider_request(&provider.id, app_type_str)
|
||||||
|
.await;
|
||||||
|
(permit.allowed, permit.used_half_open_permit)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !allowed {
|
||||||
|
log::debug!(
|
||||||
|
"[{}] Provider {} 熔断器拒绝本次请求,跳过",
|
||||||
|
app_type_str,
|
||||||
|
provider.name
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempted_providers += 1;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[{}] 尝试 {}/{} - 使用Provider: {} (sort_index: {})",
|
||||||
|
app_type_str,
|
||||||
|
attempted_providers,
|
||||||
|
providers.len(),
|
||||||
|
provider.name,
|
||||||
|
provider.sort_index.unwrap_or(999999)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新状态中的当前Provider信息
|
||||||
|
{
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.current_provider = Some(provider.name.clone());
|
||||||
|
status.current_provider_id = Some(provider.id.clone());
|
||||||
|
status.total_requests += 1;
|
||||||
|
status.last_request_at = Some(chrono::Utc::now().to_rfc3339());
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// 转发请求(带单 Provider 内重试)
|
||||||
|
match self
|
||||||
|
.forward_with_provider_retry(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => {
|
||||||
|
let latency = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
// 成功:记录成功并更新熔断器
|
||||||
|
if let Err(e) = self
|
||||||
|
.router
|
||||||
|
.record_result(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::warn!("Failed to record success: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新当前应用类型使用的 provider
|
||||||
|
{
|
||||||
|
let mut current_providers = self.current_providers.write().await;
|
||||||
|
current_providers.insert(
|
||||||
|
app_type_str.to_string(),
|
||||||
|
(provider.id.clone(), provider.name.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新成功统计
|
||||||
|
{
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.success_requests += 1;
|
||||||
|
status.last_error = None;
|
||||||
|
let should_switch =
|
||||||
|
self.current_provider_id_at_start.as_str() != provider.id.as_str();
|
||||||
|
if should_switch {
|
||||||
|
status.failover_count += 1;
|
||||||
|
log::info!(
|
||||||
|
"[{}] 代理目标已切换到 Provider: {} (耗时: {}ms)",
|
||||||
|
app_type_str,
|
||||||
|
provider.name,
|
||||||
|
latency
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步触发供应商切换,更新 UI/托盘,并把“当前供应商”同步为实际使用的 provider
|
||||||
|
let fm = self.failover_manager.clone();
|
||||||
|
let ah = self.app_handle.clone();
|
||||||
|
let pid = provider.id.clone();
|
||||||
|
let pname = provider.name.clone();
|
||||||
|
let at = app_type_str.to_string();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await
|
||||||
|
{
|
||||||
|
log::error!("[Failover] 切换供应商失败: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 重新计算成功率
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate = (status.success_requests as f32
|
||||||
|
/ status.total_requests as f32)
|
||||||
|
* 100.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[{}] 请求成功 - Provider: {} - {}ms",
|
||||||
|
app_type_str,
|
||||||
|
provider.name,
|
||||||
|
latency
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(ForwardResult {
|
||||||
|
response,
|
||||||
|
provider: provider.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let latency = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
// 失败:记录失败并更新熔断器
|
||||||
|
if let Err(record_err) = self
|
||||||
|
.router
|
||||||
|
.record_result(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
false,
|
||||||
|
Some(e.to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::warn!("Failed to record failure: {record_err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分类错误
|
||||||
|
let category = self.categorize_proxy_error(&e);
|
||||||
|
|
||||||
|
match category {
|
||||||
|
ErrorCategory::Retryable => {
|
||||||
|
// 可重试:更新错误信息,继续尝试下一个供应商
|
||||||
|
{
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.last_error =
|
||||||
|
Some(format!("Provider {} 失败: {}", provider.name, e));
|
||||||
|
}
|
||||||
|
|
||||||
|
log::warn!(
|
||||||
|
"[{}] Provider {} 失败(可重试): {} - {}ms",
|
||||||
|
app_type_str,
|
||||||
|
provider.name,
|
||||||
|
e,
|
||||||
|
latency
|
||||||
|
);
|
||||||
|
|
||||||
|
last_error = Some(e);
|
||||||
|
last_provider = Some(provider.clone());
|
||||||
|
// 继续尝试下一个供应商
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => {
|
||||||
|
// 不可重试:直接返回错误
|
||||||
|
{
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.failed_requests += 1;
|
||||||
|
status.last_error = Some(e.to_string());
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate = (status.success_requests as f32
|
||||||
|
/ status.total_requests as f32)
|
||||||
|
* 100.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log::error!(
|
||||||
|
"[{}] Provider {} 失败(不可重试): {}",
|
||||||
|
app_type_str,
|
||||||
|
provider.name,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return Err(ForwardError {
|
||||||
|
error: e,
|
||||||
|
provider: Some(provider.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempted_providers == 0 {
|
||||||
|
// providers 列表非空,但全部被熔断器拒绝(典型:HalfOpen 探测名额被占用)
|
||||||
|
{
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.failed_requests += 1;
|
||||||
|
status.last_error = Some("所有供应商暂时不可用(熔断器限制)".to_string());
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate =
|
||||||
|
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(ForwardError {
|
||||||
|
error: ProxyError::NoAvailableProvider,
|
||||||
|
provider: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有供应商都失败了
|
||||||
|
{
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.failed_requests += 1;
|
||||||
|
status.last_error = Some("所有供应商都失败".to_string());
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate =
|
||||||
|
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::error!(
|
||||||
|
"[{}] 所有 {} 个供应商都失败了",
|
||||||
|
app_type_str,
|
||||||
|
providers.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
Err(ForwardError {
|
||||||
|
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
|
||||||
|
provider: last_provider,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转发单个请求(使用适配器)
|
||||||
|
async fn forward(
|
||||||
|
&self,
|
||||||
|
provider: &Provider,
|
||||||
|
endpoint: &str,
|
||||||
|
body: &Value,
|
||||||
|
headers: &axum::http::HeaderMap,
|
||||||
|
adapter: &dyn ProviderAdapter,
|
||||||
|
) -> Result<Response, ProxyError> {
|
||||||
|
// 使用适配器提取 base_url
|
||||||
|
let base_url = adapter.extract_base_url(provider)?;
|
||||||
|
log::info!("[{}] base_url: {}", adapter.name(), base_url);
|
||||||
|
|
||||||
|
// 检查是否需要格式转换
|
||||||
|
let needs_transform = adapter.needs_transform(provider);
|
||||||
|
|
||||||
|
let effective_endpoint =
|
||||||
|
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
|
||||||
|
"/v1/chat/completions"
|
||||||
|
} else {
|
||||||
|
endpoint
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用适配器构建 URL
|
||||||
|
let url = adapter.build_url(&base_url, effective_endpoint);
|
||||||
|
|
||||||
|
// 记录原始请求 JSON
|
||||||
|
log::info!(
|
||||||
|
"[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}",
|
||||||
|
adapter.name(),
|
||||||
|
serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
// 应用模型映射(独立于格式转换)
|
||||||
|
let (mapped_body, _original_model, mapped_model) =
|
||||||
|
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||||
|
|
||||||
|
if let Some(ref mapped) = mapped_model {
|
||||||
|
log::info!(
|
||||||
|
"[{}] >>> 模型映射后的请求 JSON:\n{}",
|
||||||
|
adapter.name(),
|
||||||
|
serde_json::to_string_pretty(&mapped_body).unwrap_or_default()
|
||||||
|
);
|
||||||
|
log::info!("[{}] 模型已映射到: {}", adapter.name(), mapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换请求体(如果需要)
|
||||||
|
let request_body = if needs_transform {
|
||||||
|
log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name());
|
||||||
|
let transformed = adapter.transform_request(mapped_body, provider)?;
|
||||||
|
log::info!(
|
||||||
|
"[{}] >>> 转换后的请求 JSON:\n{}",
|
||||||
|
adapter.name(),
|
||||||
|
serde_json::to_string_pretty(&transformed).unwrap_or_default()
|
||||||
|
);
|
||||||
|
transformed
|
||||||
|
} else {
|
||||||
|
mapped_body
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[{}] 转发请求: {} -> {}",
|
||||||
|
adapter.name(),
|
||||||
|
provider.name,
|
||||||
|
url
|
||||||
|
);
|
||||||
|
|
||||||
|
// 构建请求
|
||||||
|
let mut request = self.client.post(&url);
|
||||||
|
|
||||||
|
// 只透传必要的 Headers(白名单模式)
|
||||||
|
let allowed_headers = [
|
||||||
|
"accept",
|
||||||
|
"user-agent",
|
||||||
|
"x-request-id",
|
||||||
|
"x-stainless-arch",
|
||||||
|
"x-stainless-lang",
|
||||||
|
"x-stainless-os",
|
||||||
|
"x-stainless-package-version",
|
||||||
|
"x-stainless-runtime",
|
||||||
|
"x-stainless-runtime-version",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (key, value) in headers {
|
||||||
|
let key_str = key.as_str().to_lowercase();
|
||||||
|
if allowed_headers.contains(&key_str.as_str()) {
|
||||||
|
request = request.header(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 Content-Type 是 json
|
||||||
|
request = request.header("Content-Type", "application/json");
|
||||||
|
|
||||||
|
// 使用适配器添加认证头
|
||||||
|
if let Some(auth) = adapter.extract_auth(provider) {
|
||||||
|
log::debug!(
|
||||||
|
"[{}] 使用认证: {:?} (key: {})",
|
||||||
|
adapter.name(),
|
||||||
|
auth.strategy,
|
||||||
|
auth.masked_key()
|
||||||
|
);
|
||||||
|
request = adapter.add_auth_headers(request, &auth);
|
||||||
|
} else {
|
||||||
|
log::error!(
|
||||||
|
"[{}] 未找到 API Key!Provider: {}",
|
||||||
|
adapter.name(),
|
||||||
|
provider.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
log::info!("[{}] 发送请求到: {}", adapter.name(), url);
|
||||||
|
let response = request.json(&request_body).send().await.map_err(|e| {
|
||||||
|
log::error!("[{}] 请求失败: {}", adapter.name(), e);
|
||||||
|
if e.is_timeout() {
|
||||||
|
ProxyError::Timeout(format!("请求超时: {e}"))
|
||||||
|
} else if e.is_connect() {
|
||||||
|
ProxyError::ForwardFailed(format!("连接失败: {e}"))
|
||||||
|
} else {
|
||||||
|
ProxyError::ForwardFailed(e.to_string())
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 检查响应状态
|
||||||
|
let status = response.status();
|
||||||
|
log::info!("[{}] 响应状态: {}", adapter.name(), status);
|
||||||
|
|
||||||
|
if status.is_success() {
|
||||||
|
Ok(response)
|
||||||
|
} else {
|
||||||
|
let status_code = status.as_u16();
|
||||||
|
let body_text = response.text().await.ok();
|
||||||
|
log::error!(
|
||||||
|
"[{}] 上游错误 ({}): {:?}",
|
||||||
|
adapter.name(),
|
||||||
|
status_code,
|
||||||
|
body_text
|
||||||
|
);
|
||||||
|
|
||||||
|
Err(ProxyError::UpstreamError {
|
||||||
|
status: status_code,
|
||||||
|
body: body_text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分类ProxyError
|
||||||
|
///
|
||||||
|
/// 决定哪些错误应该触发故障转移到下一个 Provider
|
||||||
|
///
|
||||||
|
/// 设计原则:既然用户配置了多个供应商,就应该让所有供应商都尝试一遍。
|
||||||
|
/// 只有明确是客户端中断的情况才不重试。
|
||||||
|
fn should_retry_same_provider(&self, error: &ProxyError) -> bool {
|
||||||
|
match error {
|
||||||
|
// 网络类错误:短暂抖动时同一 Provider 内重试有意义
|
||||||
|
ProxyError::Timeout(_) => true,
|
||||||
|
ProxyError::ForwardFailed(_) => true,
|
||||||
|
// 上游 HTTP 错误:只对“可能瞬态”的状态码做同 Provider 重试(其余交给 failover)
|
||||||
|
ProxyError::UpstreamError { status, .. } => {
|
||||||
|
*status == 408 || *status == 429 || *status >= 500
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
|
||||||
|
match error {
|
||||||
|
// 网络和上游错误:都应该尝试下一个供应商
|
||||||
|
ProxyError::Timeout(_) => ErrorCategory::Retryable,
|
||||||
|
ProxyError::ForwardFailed(_) => ErrorCategory::Retryable,
|
||||||
|
ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable,
|
||||||
|
// 上游 HTTP 错误:无论状态码如何,都尝试下一个供应商
|
||||||
|
// 原因:不同供应商有不同的限制和认证,一个供应商的 4xx 错误
|
||||||
|
// 不代表其他供应商也会失败
|
||||||
|
ProxyError::UpstreamError { .. } => ErrorCategory::Retryable,
|
||||||
|
// Provider 级配置/转换问题:换一个 Provider 可能就能成功
|
||||||
|
ProxyError::ConfigError(_) => ErrorCategory::Retryable,
|
||||||
|
ProxyError::TransformError(_) => ErrorCategory::Retryable,
|
||||||
|
ProxyError::AuthError(_) => ErrorCategory::Retryable,
|
||||||
|
ProxyError::StreamIdleTimeout(_) => ErrorCategory::Retryable,
|
||||||
|
ProxyError::MaxRetriesExceeded => ErrorCategory::Retryable,
|
||||||
|
// 无可用供应商:所有供应商都试过了,无法重试
|
||||||
|
ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
|
||||||
|
// 其他错误(数据库/内部错误等):不是换供应商能解决的问题
|
||||||
|
_ => ErrorCategory::NonRetryable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//! Handler 配置模块
|
||||||
|
//!
|
||||||
|
//! 定义各 API 处理器的配置结构和使用量解析器
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::proxy::usage::parser::TokenUsage;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// 使用量解析器类型别名
|
||||||
|
pub type StreamUsageParser = fn(&[Value]) -> Option<TokenUsage>;
|
||||||
|
pub type ResponseUsageParser = fn(&Value) -> Option<TokenUsage>;
|
||||||
|
|
||||||
|
/// 模型提取器类型别名
|
||||||
|
/// 参数: (流式事件列表, 请求中的模型名称) -> 最终使用的模型名称
|
||||||
|
pub type StreamModelExtractor = fn(&[Value], &str) -> String;
|
||||||
|
|
||||||
|
/// 各 API 的使用量解析配置
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct UsageParserConfig {
|
||||||
|
/// 流式响应解析器
|
||||||
|
pub stream_parser: StreamUsageParser,
|
||||||
|
/// 非流式响应解析器
|
||||||
|
pub response_parser: ResponseUsageParser,
|
||||||
|
/// 流式响应中的模型提取器
|
||||||
|
pub model_extractor: StreamModelExtractor,
|
||||||
|
/// 应用类型字符串(用于日志记录)
|
||||||
|
pub app_type_str: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 模型提取器实现
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Claude 流式响应模型提取(优先使用 usage.model)
|
||||||
|
fn claude_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||||
|
// 首先尝试从解析的 usage 中获取模型
|
||||||
|
if let Some(usage) = TokenUsage::from_claude_stream_events(events) {
|
||||||
|
if let Some(model) = usage.model {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request_model.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model)
|
||||||
|
fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||||
|
// 首先尝试从解析的 usage 中获取模型
|
||||||
|
if let Some(usage) = TokenUsage::from_openai_stream_events(events) {
|
||||||
|
if let Some(model) = usage.model {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 回退:从事件中直接提取
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.find_map(|e| e.get("model")?.as_str())
|
||||||
|
.unwrap_or(request_model)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Codex Responses API 流式响应模型提取(优先使用 usage.model)
|
||||||
|
fn codex_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||||
|
// 首先尝试从解析的 usage 中获取模型
|
||||||
|
if let Some(usage) = TokenUsage::from_codex_stream_events(events) {
|
||||||
|
if let Some(model) = usage.model {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 回退:从 response.completed 事件中提取
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.find_map(|e| {
|
||||||
|
if e.get("type")?.as_str()? == "response.completed" {
|
||||||
|
e.get("response")?.get("model")?.as_str()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or(request_model)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gemini 流式响应模型提取(优先使用 usage.model)
|
||||||
|
fn gemini_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||||
|
// 首先尝试从解析的 usage 中获取模型
|
||||||
|
if let Some(usage) = TokenUsage::from_gemini_stream_chunks(events) {
|
||||||
|
if let Some(model) = usage.model {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request_model.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 预定义配置
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Claude API 解析配置
|
||||||
|
pub const CLAUDE_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||||
|
stream_parser: TokenUsage::from_claude_stream_events,
|
||||||
|
response_parser: TokenUsage::from_claude_response,
|
||||||
|
model_extractor: claude_model_extractor,
|
||||||
|
app_type_str: "claude",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// OpenAI Chat Completions API 解析配置(用于 Codex /v1/chat/completions)
|
||||||
|
pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||||
|
stream_parser: TokenUsage::from_openai_stream_events,
|
||||||
|
response_parser: TokenUsage::from_openai_response,
|
||||||
|
model_extractor: openai_model_extractor,
|
||||||
|
app_type_str: "codex",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Codex Responses API 解析配置(用于 /v1/responses)
|
||||||
|
pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||||
|
stream_parser: TokenUsage::from_codex_stream_events,
|
||||||
|
response_parser: TokenUsage::from_codex_response,
|
||||||
|
model_extractor: codex_model_extractor,
|
||||||
|
app_type_str: "codex",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Gemini API 解析配置
|
||||||
|
pub const GEMINI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||||
|
stream_parser: TokenUsage::from_gemini_stream_chunks,
|
||||||
|
response_parser: TokenUsage::from_gemini_response,
|
||||||
|
model_extractor: gemini_model_extractor,
|
||||||
|
app_type_str: "gemini",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Handler 配置(预留,用于进一步简化)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Handler 基础配置
|
||||||
|
///
|
||||||
|
/// 预留结构,可用于进一步统一各 handler 的配置
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HandlerConfig {
|
||||||
|
/// 应用类型
|
||||||
|
pub app_type: AppType,
|
||||||
|
/// 日志标签
|
||||||
|
pub tag: &'static str,
|
||||||
|
/// 应用类型字符串
|
||||||
|
pub app_type_str: &'static str,
|
||||||
|
/// 使用量解析配置
|
||||||
|
pub parser_config: &'static UsageParserConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude Handler 配置
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const CLAUDE_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
|
||||||
|
app_type: AppType::Claude,
|
||||||
|
tag: "Claude",
|
||||||
|
app_type_str: "claude",
|
||||||
|
parser_config: &CLAUDE_PARSER_CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Codex Chat Completions Handler 配置
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const CODEX_CHAT_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
|
||||||
|
app_type: AppType::Codex,
|
||||||
|
tag: "Codex",
|
||||||
|
app_type_str: "codex",
|
||||||
|
parser_config: &OPENAI_PARSER_CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Codex Responses Handler 配置
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const CODEX_RESPONSES_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
|
||||||
|
app_type: AppType::Codex,
|
||||||
|
tag: "Codex",
|
||||||
|
app_type_str: "codex",
|
||||||
|
parser_config: &CODEX_PARSER_CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Gemini Handler 配置
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const GEMINI_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
|
||||||
|
app_type: AppType::Gemini,
|
||||||
|
tag: "Gemini",
|
||||||
|
app_type_str: "gemini",
|
||||||
|
parser_config: &GEMINI_PARSER_CONFIG,
|
||||||
|
};
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
//! 请求上下文模块
|
||||||
|
//!
|
||||||
|
//! 提供请求生命周期的上下文管理,封装通用初始化逻辑
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::proxy::{
|
||||||
|
forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, ProxyError,
|
||||||
|
};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// 流式超时配置
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct StreamingTimeoutConfig {
|
||||||
|
/// 首字节超时(秒),0 表示禁用
|
||||||
|
pub first_byte_timeout: u64,
|
||||||
|
/// 静默期超时(秒),0 表示禁用
|
||||||
|
pub idle_timeout: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求上下文
|
||||||
|
///
|
||||||
|
/// 贯穿整个请求生命周期,包含:
|
||||||
|
/// - 计时信息
|
||||||
|
/// - 应用级代理配置(per-app)
|
||||||
|
/// - 选中的 Provider 列表(用于故障转移)
|
||||||
|
/// - 请求模型名称
|
||||||
|
/// - 日志标签
|
||||||
|
pub struct RequestContext {
|
||||||
|
/// 请求开始时间
|
||||||
|
pub start_time: Instant,
|
||||||
|
/// 应用级代理配置(per-app,包含重试次数和超时配置)
|
||||||
|
pub app_config: AppProxyConfig,
|
||||||
|
/// 选中的 Provider(故障转移链的第一个)
|
||||||
|
pub provider: Provider,
|
||||||
|
/// 完整的 Provider 列表(用于故障转移)
|
||||||
|
providers: Vec<Provider>,
|
||||||
|
/// 请求开始时的“当前供应商”(用于判断是否需要同步 UI/托盘)
|
||||||
|
///
|
||||||
|
/// 这里使用本地 settings 的设备级 current provider。
|
||||||
|
/// 代理模式下如果实际使用的 provider 与此不一致,会触发切换以确保 UI 始终准确。
|
||||||
|
pub current_provider_id: String,
|
||||||
|
/// 请求中的模型名称
|
||||||
|
pub request_model: String,
|
||||||
|
/// 日志标签(如 "Claude"、"Codex"、"Gemini")
|
||||||
|
pub tag: &'static str,
|
||||||
|
/// 应用类型字符串(如 "claude"、"codex"、"gemini")
|
||||||
|
pub app_type_str: &'static str,
|
||||||
|
/// 应用类型(预留,目前通过 app_type_str 使用)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub app_type: AppType,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestContext {
|
||||||
|
/// 创建请求上下文
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `state` - 代理服务器状态
|
||||||
|
/// * `body` - 请求体 JSON
|
||||||
|
/// * `app_type` - 应用类型
|
||||||
|
/// * `tag` - 日志标签
|
||||||
|
/// * `app_type_str` - 应用类型字符串
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 返回 `ProxyError` 如果 Provider 选择失败
|
||||||
|
pub async fn new(
|
||||||
|
state: &ProxyState,
|
||||||
|
body: &serde_json::Value,
|
||||||
|
app_type: AppType,
|
||||||
|
tag: &'static str,
|
||||||
|
app_type_str: &'static str,
|
||||||
|
) -> Result<Self, ProxyError> {
|
||||||
|
let start_time = Instant::now();
|
||||||
|
|
||||||
|
// 从数据库读取应用级代理配置(per-app)
|
||||||
|
let app_config = state
|
||||||
|
.db
|
||||||
|
.get_proxy_config_for_app(app_type_str)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||||
|
|
||||||
|
let current_provider_id =
|
||||||
|
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||||
|
|
||||||
|
// 从请求体提取模型名称
|
||||||
|
let request_model = body
|
||||||
|
.get("model")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// 使用共享的 ProviderRouter 选择 Provider(熔断器状态跨请求保持)
|
||||||
|
// 注意:只在这里调用一次,结果传递给 forwarder,避免重复消耗 HalfOpen 名额
|
||||||
|
let providers = state
|
||||||
|
.provider_router
|
||||||
|
.select_providers(app_type_str)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||||
|
|
||||||
|
let provider = providers
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.ok_or(ProxyError::NoAvailableProvider)?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[{}] Provider: {}, model: {}, failover chain: {} providers",
|
||||||
|
tag,
|
||||||
|
provider.name,
|
||||||
|
request_model,
|
||||||
|
providers.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
start_time,
|
||||||
|
app_config,
|
||||||
|
provider,
|
||||||
|
providers,
|
||||||
|
current_provider_id,
|
||||||
|
request_model,
|
||||||
|
tag,
|
||||||
|
app_type_str,
|
||||||
|
app_type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 URI 提取模型名称(Gemini 专用)
|
||||||
|
///
|
||||||
|
/// Gemini API 的模型名称在 URI 中,格式如:
|
||||||
|
/// `/v1beta/models/gemini-pro:generateContent`
|
||||||
|
pub fn with_model_from_uri(mut self, uri: &axum::http::Uri) -> Self {
|
||||||
|
let endpoint = uri
|
||||||
|
.path_and_query()
|
||||||
|
.map(|pq| pq.as_str())
|
||||||
|
.unwrap_or(uri.path());
|
||||||
|
|
||||||
|
self.request_model = endpoint
|
||||||
|
.split('/')
|
||||||
|
.find(|s| s.starts_with("models/"))
|
||||||
|
.and_then(|s| s.strip_prefix("models/"))
|
||||||
|
.map(|s| s.split(':').next().unwrap_or(s))
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
log::info!("[{}] 从 URI 提取模型: {}", self.tag, self.request_model);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建 RequestForwarder
|
||||||
|
///
|
||||||
|
/// 使用共享的 ProviderRouter,确保熔断器状态跨请求保持
|
||||||
|
pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder {
|
||||||
|
RequestForwarder::new(
|
||||||
|
state.provider_router.clone(),
|
||||||
|
self.app_config.non_streaming_timeout as u64,
|
||||||
|
self.app_config.max_retries as u8,
|
||||||
|
state.status.clone(),
|
||||||
|
state.current_providers.clone(),
|
||||||
|
state.failover_manager.clone(),
|
||||||
|
state.app_handle.clone(),
|
||||||
|
self.current_provider_id.clone(),
|
||||||
|
self.app_config.streaming_first_byte_timeout as u64,
|
||||||
|
self.app_config.streaming_idle_timeout as u64,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Provider 列表(用于故障转移)
|
||||||
|
///
|
||||||
|
/// 返回在创建上下文时已选择的 providers,避免重复调用 select_providers()
|
||||||
|
pub fn get_providers(&self) -> Vec<Provider> {
|
||||||
|
self.providers.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算请求延迟(毫秒)
|
||||||
|
#[inline]
|
||||||
|
pub fn latency_ms(&self) -> u64 {
|
||||||
|
self.start_time.elapsed().as_millis() as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取流式超时配置
|
||||||
|
#[inline]
|
||||||
|
pub fn streaming_timeout_config(&self) -> StreamingTimeoutConfig {
|
||||||
|
StreamingTimeoutConfig {
|
||||||
|
first_byte_timeout: self.app_config.streaming_first_byte_timeout as u64,
|
||||||
|
idle_timeout: self.app_config.streaming_idle_timeout as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
//! 请求处理器
|
||||||
|
//!
|
||||||
|
//! 处理各种API端点的HTTP请求
|
||||||
|
//!
|
||||||
|
//! 重构后的结构:
|
||||||
|
//! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块
|
||||||
|
//! - 各 handler 只保留独特的业务逻辑
|
||||||
|
//! - Claude 的格式转换逻辑保留在此文件(用于 OpenRouter 旧接口回退)
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
error_mapper::{get_error_message, map_proxy_error_to_status},
|
||||||
|
handler_config::{
|
||||||
|
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
|
||||||
|
},
|
||||||
|
handler_context::RequestContext,
|
||||||
|
providers::{get_adapter, streaming::create_anthropic_sse_stream, transform},
|
||||||
|
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
|
||||||
|
server::ProxyState,
|
||||||
|
types::*,
|
||||||
|
usage::parser::TokenUsage,
|
||||||
|
ProxyError,
|
||||||
|
};
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 健康检查和状态查询(简单端点)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 健康检查
|
||||||
|
pub async fn health_check() -> (StatusCode, Json<Value>) {
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(json!({
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取服务状态
|
||||||
|
pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxyStatus>, ProxyError> {
|
||||||
|
let status = state.status.read().await.clone();
|
||||||
|
Ok(Json(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Claude API 处理器(包含格式转换逻辑)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 处理 /v1/messages 请求(Claude API)
|
||||||
|
///
|
||||||
|
/// Claude 处理器包含独特的格式转换逻辑:
|
||||||
|
/// - 过去用于 OpenRouter 的 OpenAI Chat Completions 兼容接口(Anthropic ↔ OpenAI 转换)
|
||||||
|
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
|
||||||
|
pub async fn handle_messages(
|
||||||
|
State(state): State<ProxyState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Json(body): Json<Value>,
|
||||||
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
|
let mut ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
|
||||||
|
|
||||||
|
let is_stream = body
|
||||||
|
.get("stream")
|
||||||
|
.and_then(|s| s.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// 转发请求
|
||||||
|
let forwarder = ctx.create_forwarder(&state);
|
||||||
|
let result = match forwarder
|
||||||
|
.forward_with_retry(
|
||||||
|
&AppType::Claude,
|
||||||
|
"/v1/messages",
|
||||||
|
body.clone(),
|
||||||
|
headers,
|
||||||
|
ctx.get_providers(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(mut err) => {
|
||||||
|
if let Some(provider) = err.provider.take() {
|
||||||
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||||
|
return Err(err.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.provider = result.provider;
|
||||||
|
let response = result.response;
|
||||||
|
|
||||||
|
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||||||
|
let adapter = get_adapter(&AppType::Claude);
|
||||||
|
let needs_transform = adapter.needs_transform(&ctx.provider);
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
|
||||||
|
ctx.provider.name,
|
||||||
|
needs_transform,
|
||||||
|
is_stream
|
||||||
|
);
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
log::info!("[Claude] 上游响应状态: {status}");
|
||||||
|
|
||||||
|
// Claude 特有:格式转换处理
|
||||||
|
if needs_transform {
|
||||||
|
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用响应处理(透传模式)
|
||||||
|
process_response(response, &ctx, &state, &CLAUDE_PARSER_CONFIG).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude 格式转换处理(独有逻辑)
|
||||||
|
///
|
||||||
|
/// 处理 OpenRouter 旧 OpenAI 兼容接口的回退方案(当前默认不启用)
|
||||||
|
async fn handle_claude_transform(
|
||||||
|
response: reqwest::Response,
|
||||||
|
ctx: &RequestContext,
|
||||||
|
state: &ProxyState,
|
||||||
|
_original_body: &Value,
|
||||||
|
is_stream: bool,
|
||||||
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
|
let status = response.status();
|
||||||
|
|
||||||
|
if is_stream {
|
||||||
|
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
|
||||||
|
log::info!("[Claude] 开始流式响应转换 (OpenAI SSE → Anthropic SSE)");
|
||||||
|
|
||||||
|
let stream = response.bytes_stream();
|
||||||
|
let sse_stream = create_anthropic_sse_stream(stream);
|
||||||
|
|
||||||
|
// 创建使用量收集器
|
||||||
|
let usage_collector = {
|
||||||
|
let state = state.clone();
|
||||||
|
let provider_id = ctx.provider.id.clone();
|
||||||
|
let model = ctx.request_model.clone();
|
||||||
|
let status_code = status.as_u16();
|
||||||
|
let start_time = ctx.start_time;
|
||||||
|
|
||||||
|
SseUsageCollector::new(start_time, move |events, first_token_ms| {
|
||||||
|
if let Some(usage) = TokenUsage::from_claude_stream_events(&events) {
|
||||||
|
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||||
|
let state = state.clone();
|
||||||
|
let provider_id = provider_id.clone();
|
||||||
|
let model = model.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
log_usage(
|
||||||
|
&state,
|
||||||
|
&provider_id,
|
||||||
|
"claude",
|
||||||
|
&model,
|
||||||
|
usage,
|
||||||
|
latency_ms,
|
||||||
|
first_token_ms,
|
||||||
|
true,
|
||||||
|
status_code,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log::debug!("[Claude] OpenRouter 流式响应缺少 usage 统计,跳过消费记录");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取流式超时配置
|
||||||
|
let timeout_config = ctx.streaming_timeout_config();
|
||||||
|
|
||||||
|
let logged_stream = create_logged_passthrough_stream(
|
||||||
|
sse_stream,
|
||||||
|
"Claude/OpenRouter",
|
||||||
|
Some(usage_collector),
|
||||||
|
timeout_config,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut headers = axum::http::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"Content-Type",
|
||||||
|
axum::http::HeaderValue::from_static("text/event-stream"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
"Cache-Control",
|
||||||
|
axum::http::HeaderValue::from_static("no-cache"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
"Connection",
|
||||||
|
axum::http::HeaderValue::from_static("keep-alive"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = axum::body::Body::from_stream(logged_stream);
|
||||||
|
log::info!("[Claude] ====== 请求结束 (流式转换) ======");
|
||||||
|
return Ok((headers, body).into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非流式响应转换 (OpenAI → Anthropic)
|
||||||
|
log::info!("[Claude] 开始转换响应 (OpenAI → Anthropic)");
|
||||||
|
|
||||||
|
let response_headers = response.headers().clone();
|
||||||
|
|
||||||
|
let body_bytes = response.bytes().await.map_err(|e| {
|
||||||
|
log::error!("[Claude] 读取响应体失败: {e}");
|
||||||
|
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||||
|
log::info!("[Claude] OpenAI 响应长度: {} bytes", body_bytes.len());
|
||||||
|
log::debug!("[Claude] OpenAI 原始响应: {body_str}");
|
||||||
|
|
||||||
|
let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||||
|
log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}");
|
||||||
|
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log::info!("[Claude] 解析 OpenAI 响应成功");
|
||||||
|
log::info!(
|
||||||
|
"[Claude] <<< OpenAI 响应 JSON:\n{}",
|
||||||
|
serde_json::to_string_pretty(&openai_response).unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
|
let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| {
|
||||||
|
log::error!("[Claude] 转换响应失败: {e}");
|
||||||
|
e
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log::info!("[Claude] 转换响应成功");
|
||||||
|
log::info!(
|
||||||
|
"[Claude] <<< Anthropic 响应 JSON:\n{}",
|
||||||
|
serde_json::to_string_pretty(&anthropic_response).unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 记录使用量
|
||||||
|
if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {
|
||||||
|
let model = anthropic_response
|
||||||
|
.get("model")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let latency_ms = ctx.latency_ms();
|
||||||
|
|
||||||
|
tokio::spawn({
|
||||||
|
let state = state.clone();
|
||||||
|
let provider_id = ctx.provider.id.clone();
|
||||||
|
let model = model.to_string();
|
||||||
|
async move {
|
||||||
|
log_usage(
|
||||||
|
&state,
|
||||||
|
&provider_id,
|
||||||
|
"claude",
|
||||||
|
&model,
|
||||||
|
usage,
|
||||||
|
latency_ms,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
status.as_u16(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[Claude] ====== 请求结束 ======");
|
||||||
|
|
||||||
|
// 构建响应
|
||||||
|
let mut builder = axum::response::Response::builder().status(status);
|
||||||
|
|
||||||
|
for (key, value) in response_headers.iter() {
|
||||||
|
if key.as_str().to_lowercase() != "content-length"
|
||||||
|
&& key.as_str().to_lowercase() != "transfer-encoding"
|
||||||
|
{
|
||||||
|
builder = builder.header(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder = builder.header("content-type", "application/json");
|
||||||
|
|
||||||
|
let response_body = serde_json::to_vec(&anthropic_response).map_err(|e| {
|
||||||
|
log::error!("[Claude] 序列化响应失败: {e}");
|
||||||
|
ProxyError::TransformError(format!("Failed to serialize response: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[Claude] 返回转换后的响应, 长度: {} bytes",
|
||||||
|
response_body.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = axum::body::Body::from(response_body);
|
||||||
|
Ok(builder.body(body).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Codex API 处理器
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI)
|
||||||
|
pub async fn handle_chat_completions(
|
||||||
|
State(state): State<ProxyState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Json(body): Json<Value>,
|
||||||
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
|
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
|
||||||
|
|
||||||
|
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||||
|
|
||||||
|
let is_stream = body
|
||||||
|
.get("stream")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[Codex] 请求模型: {}, 流式: {}",
|
||||||
|
ctx.request_model,
|
||||||
|
is_stream
|
||||||
|
);
|
||||||
|
|
||||||
|
let forwarder = ctx.create_forwarder(&state);
|
||||||
|
let result = match forwarder
|
||||||
|
.forward_with_retry(
|
||||||
|
&AppType::Codex,
|
||||||
|
"/v1/chat/completions",
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
ctx.get_providers(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(mut err) => {
|
||||||
|
if let Some(provider) = err.provider.take() {
|
||||||
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||||
|
return Err(err.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.provider = result.provider;
|
||||||
|
let response = result.response;
|
||||||
|
|
||||||
|
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||||
|
|
||||||
|
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传)
|
||||||
|
pub async fn handle_responses(
|
||||||
|
State(state): State<ProxyState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Json(body): Json<Value>,
|
||||||
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
|
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||||
|
|
||||||
|
let is_stream = body
|
||||||
|
.get("stream")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let forwarder = ctx.create_forwarder(&state);
|
||||||
|
let result = match forwarder
|
||||||
|
.forward_with_retry(
|
||||||
|
&AppType::Codex,
|
||||||
|
"/v1/responses",
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
ctx.get_providers(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(mut err) => {
|
||||||
|
if let Some(provider) = err.provider.take() {
|
||||||
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||||
|
return Err(err.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.provider = result.provider;
|
||||||
|
let response = result.response;
|
||||||
|
|
||||||
|
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||||
|
|
||||||
|
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Gemini API 处理器
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 处理 Gemini API 请求(透传,包括查询参数)
|
||||||
|
pub async fn handle_gemini(
|
||||||
|
State(state): State<ProxyState>,
|
||||||
|
uri: axum::http::Uri,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Json(body): Json<Value>,
|
||||||
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
|
// Gemini 的模型名称在 URI 中
|
||||||
|
let mut ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
|
||||||
|
.await?
|
||||||
|
.with_model_from_uri(&uri);
|
||||||
|
|
||||||
|
// 提取完整的路径和查询参数
|
||||||
|
let endpoint = uri
|
||||||
|
.path_and_query()
|
||||||
|
.map(|pq| pq.as_str())
|
||||||
|
.unwrap_or(uri.path());
|
||||||
|
|
||||||
|
log::info!("[Gemini] 请求端点: {endpoint}");
|
||||||
|
|
||||||
|
let is_stream = body
|
||||||
|
.get("stream")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let forwarder = ctx.create_forwarder(&state);
|
||||||
|
let result = match forwarder
|
||||||
|
.forward_with_retry(
|
||||||
|
&AppType::Gemini,
|
||||||
|
endpoint,
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
ctx.get_providers(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(mut err) => {
|
||||||
|
if let Some(provider) = err.provider.take() {
|
||||||
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||||
|
return Err(err.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.provider = result.provider;
|
||||||
|
let response = result.response;
|
||||||
|
|
||||||
|
log::info!("[Gemini] 上游响应状态: {}", response.status());
|
||||||
|
|
||||||
|
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 使用量记录(保留用于 Claude 转换逻辑)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
fn log_forward_error(
|
||||||
|
state: &ProxyState,
|
||||||
|
ctx: &RequestContext,
|
||||||
|
is_streaming: bool,
|
||||||
|
error: &ProxyError,
|
||||||
|
) {
|
||||||
|
use super::usage::logger::UsageLogger;
|
||||||
|
|
||||||
|
let logger = UsageLogger::new(&state.db);
|
||||||
|
let status_code = map_proxy_error_to_status(error);
|
||||||
|
let error_message = get_error_message(error);
|
||||||
|
let request_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
if let Err(e) = logger.log_error_with_context(
|
||||||
|
request_id.clone(),
|
||||||
|
ctx.provider.id.clone(),
|
||||||
|
ctx.app_type_str.to_string(),
|
||||||
|
ctx.request_model.clone(),
|
||||||
|
status_code,
|
||||||
|
error_message,
|
||||||
|
ctx.latency_ms(),
|
||||||
|
is_streaming,
|
||||||
|
Some(request_id),
|
||||||
|
None,
|
||||||
|
) {
|
||||||
|
log::warn!("记录失败请求日志失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录请求使用量
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn log_usage(
|
||||||
|
state: &ProxyState,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
model: &str,
|
||||||
|
usage: TokenUsage,
|
||||||
|
latency_ms: u64,
|
||||||
|
first_token_ms: Option<u64>,
|
||||||
|
is_streaming: bool,
|
||||||
|
status_code: u16,
|
||||||
|
) {
|
||||||
|
use super::usage::logger::UsageLogger;
|
||||||
|
|
||||||
|
let logger = UsageLogger::new(&state.db);
|
||||||
|
|
||||||
|
// 获取 provider 的 cost_multiplier
|
||||||
|
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
|
||||||
|
Ok(Some(p)) => {
|
||||||
|
if let Some(meta) = p.meta {
|
||||||
|
if let Some(cm) = meta.cost_multiplier {
|
||||||
|
Decimal::from_str(&cm).unwrap_or(Decimal::from(1))
|
||||||
|
} else {
|
||||||
|
Decimal::from(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Decimal::from(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Decimal::from(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
let request_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
if let Err(e) = logger.log_with_calculation(
|
||||||
|
request_id,
|
||||||
|
provider_id.to_string(),
|
||||||
|
app_type.to_string(),
|
||||||
|
model.to_string(),
|
||||||
|
usage,
|
||||||
|
multiplier,
|
||||||
|
latency_ms,
|
||||||
|
first_token_ms,
|
||||||
|
status_code,
|
||||||
|
None,
|
||||||
|
None, // provider_type
|
||||||
|
is_streaming,
|
||||||
|
) {
|
||||||
|
log::warn!("记录使用量失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//! 健康检查器
|
||||||
|
//!
|
||||||
|
//! 负责定期检查Provider健康状态(占位实现)
|
||||||
|
|
||||||
|
// 占位实现,稍后添加完整逻辑
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct HealthChecker;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//! 代理服务器模块
|
||||||
|
//!
|
||||||
|
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
|
||||||
|
|
||||||
|
pub mod circuit_breaker;
|
||||||
|
pub mod error;
|
||||||
|
pub mod error_mapper;
|
||||||
|
pub(crate) mod failover_switch;
|
||||||
|
mod forwarder;
|
||||||
|
pub mod handler_config;
|
||||||
|
pub mod handler_context;
|
||||||
|
mod handlers;
|
||||||
|
mod health;
|
||||||
|
pub mod model_mapper;
|
||||||
|
pub mod provider_router;
|
||||||
|
pub mod providers;
|
||||||
|
pub mod response_handler;
|
||||||
|
pub mod response_processor;
|
||||||
|
pub(crate) mod server;
|
||||||
|
pub mod session;
|
||||||
|
pub(crate) mod types;
|
||||||
|
pub mod usage;
|
||||||
|
|
||||||
|
// 公开导出给外部使用(commands, services等模块需要)
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use circuit_breaker::{
|
||||||
|
CircuitBreaker, CircuitBreakerConfig, CircuitBreakerStats, CircuitState,
|
||||||
|
};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use error::ProxyError;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use provider_router::ProviderRouter;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use session::{ClientFormat, ProxySession};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};
|
||||||
|
|
||||||
|
// 内部模块间共享(供子模块使用)
|
||||||
|
// 注意:这个导出用于模块内部,编译器可能警告未使用但实际被子模块使用
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub(crate) use types::*;
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
//! 模型映射模块
|
||||||
|
//!
|
||||||
|
//! 在请求转发前,根据 Provider 配置替换请求中的模型名称
|
||||||
|
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// 模型映射配置
|
||||||
|
pub struct ModelMapping {
|
||||||
|
pub haiku_model: Option<String>,
|
||||||
|
pub sonnet_model: Option<String>,
|
||||||
|
pub opus_model: Option<String>,
|
||||||
|
pub default_model: Option<String>,
|
||||||
|
pub reasoning_model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelMapping {
|
||||||
|
/// 从 Provider 配置中提取模型映射
|
||||||
|
pub fn from_provider(provider: &Provider) -> Self {
|
||||||
|
let env = provider.settings_config.get("env");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
haiku_model: env
|
||||||
|
.and_then(|e| e.get("ANTHROPIC_DEFAULT_HAIKU_MODEL"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from),
|
||||||
|
sonnet_model: env
|
||||||
|
.and_then(|e| e.get("ANTHROPIC_DEFAULT_SONNET_MODEL"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from),
|
||||||
|
opus_model: env
|
||||||
|
.and_then(|e| e.get("ANTHROPIC_DEFAULT_OPUS_MODEL"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from),
|
||||||
|
default_model: env
|
||||||
|
.and_then(|e| e.get("ANTHROPIC_MODEL"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from),
|
||||||
|
reasoning_model: env
|
||||||
|
.and_then(|e| e.get("ANTHROPIC_REASONING_MODEL"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否配置了任何模型映射
|
||||||
|
pub fn has_mapping(&self) -> bool {
|
||||||
|
self.haiku_model.is_some()
|
||||||
|
|| self.sonnet_model.is_some()
|
||||||
|
|| self.opus_model.is_some()
|
||||||
|
|| self.default_model.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据原始模型名称获取映射后的模型
|
||||||
|
pub fn map_model(&self, original_model: &str, has_thinking: bool) -> String {
|
||||||
|
let model_lower = original_model.to_lowercase();
|
||||||
|
|
||||||
|
// 1. thinking 模式优先使用推理模型
|
||||||
|
if has_thinking {
|
||||||
|
if let Some(ref m) = self.reasoning_model {
|
||||||
|
return m.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 按模型类型匹配
|
||||||
|
if model_lower.contains("haiku") {
|
||||||
|
if let Some(ref m) = self.haiku_model {
|
||||||
|
return m.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model_lower.contains("opus") {
|
||||||
|
if let Some(ref m) = self.opus_model {
|
||||||
|
return m.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model_lower.contains("sonnet") {
|
||||||
|
if let Some(ref m) = self.sonnet_model {
|
||||||
|
return m.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 默认模型
|
||||||
|
if let Some(ref m) = self.default_model {
|
||||||
|
return m.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 无映射,保持原样
|
||||||
|
original_model.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检测请求是否启用了 thinking 模式
|
||||||
|
pub fn has_thinking_enabled(body: &Value) -> bool {
|
||||||
|
body.get("thinking")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|o| o.get("type"))
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
== Some("enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对请求体应用模型映射
|
||||||
|
///
|
||||||
|
/// 返回 (映射后的请求体, 原始模型名, 映射后模型名)
|
||||||
|
pub fn apply_model_mapping(
|
||||||
|
mut body: Value,
|
||||||
|
provider: &Provider,
|
||||||
|
) -> (Value, Option<String>, Option<String>) {
|
||||||
|
let mapping = ModelMapping::from_provider(provider);
|
||||||
|
|
||||||
|
// 如果没有配置映射,直接返回
|
||||||
|
if !mapping.has_mapping() {
|
||||||
|
let original = body.get("model").and_then(|m| m.as_str()).map(String::from);
|
||||||
|
return (body, original, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取原始模型名
|
||||||
|
let original_model = body.get("model").and_then(|m| m.as_str()).map(String::from);
|
||||||
|
|
||||||
|
if let Some(ref original) = original_model {
|
||||||
|
let has_thinking = has_thinking_enabled(&body);
|
||||||
|
let mapped = mapping.map_model(original, has_thinking);
|
||||||
|
|
||||||
|
if mapped != *original {
|
||||||
|
log::info!("[ModelMapper] 模型映射: {original} → {mapped}");
|
||||||
|
body["model"] = serde_json::json!(mapped);
|
||||||
|
return (body, Some(original.clone()), Some(mapped));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(body, original_model, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn create_provider_with_mapping() -> Provider {
|
||||||
|
Provider {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: "Test".to_string(),
|
||||||
|
settings_config: json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_MODEL": "default-model",
|
||||||
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
|
||||||
|
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped",
|
||||||
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped",
|
||||||
|
"ANTHROPIC_REASONING_MODEL": "reasoning-model"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
website_url: None,
|
||||||
|
category: None,
|
||||||
|
created_at: None,
|
||||||
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
|
meta: None,
|
||||||
|
icon: None,
|
||||||
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_provider_without_mapping() -> Provider {
|
||||||
|
Provider {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: "Test".to_string(),
|
||||||
|
settings_config: json!({}),
|
||||||
|
website_url: None,
|
||||||
|
category: None,
|
||||||
|
created_at: None,
|
||||||
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
|
meta: None,
|
||||||
|
icon: None,
|
||||||
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sonnet_mapping() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({"model": "claude-sonnet-4-5-20250929"});
|
||||||
|
let (result, original, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "sonnet-mapped");
|
||||||
|
assert_eq!(original, Some("claude-sonnet-4-5-20250929".to_string()));
|
||||||
|
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_haiku_mapping() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({"model": "claude-haiku-4-5"});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "haiku-mapped");
|
||||||
|
assert_eq!(mapped, Some("haiku-mapped".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_opus_mapping() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({"model": "claude-opus-4-5"});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "opus-mapped");
|
||||||
|
assert_eq!(mapped, Some("opus-mapped".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_thinking_mode() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"thinking": {"type": "enabled"}
|
||||||
|
});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "reasoning-model");
|
||||||
|
assert_eq!(mapped, Some("reasoning-model".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_thinking_disabled() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"thinking": {"type": "disabled"}
|
||||||
|
});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "sonnet-mapped");
|
||||||
|
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unknown_model_uses_default() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({"model": "some-unknown-model"});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "default-model");
|
||||||
|
assert_eq!(mapped, Some("default-model".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_mapping_configured() {
|
||||||
|
let provider = create_provider_without_mapping();
|
||||||
|
let body = json!({"model": "claude-sonnet-4-5"});
|
||||||
|
let (result, original, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "claude-sonnet-4-5");
|
||||||
|
assert_eq!(original, Some("claude-sonnet-4-5".to_string()));
|
||||||
|
assert!(mapped.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_case_insensitive() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({"model": "Claude-SONNET-4-5"});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "sonnet-mapped");
|
||||||
|
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
//! 供应商路由器模块
|
||||||
|
//!
|
||||||
|
//! 负责选择和管理代理目标供应商,实现智能故障转移
|
||||||
|
|
||||||
|
use crate::database::Database;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::proxy::circuit_breaker::{AllowResult, CircuitBreaker, CircuitBreakerConfig};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
/// 供应商路由器
|
||||||
|
pub struct ProviderRouter {
|
||||||
|
/// 数据库连接
|
||||||
|
db: Arc<Database>,
|
||||||
|
/// 熔断器管理器 - key 格式: "app_type:provider_id"
|
||||||
|
circuit_breakers: Arc<RwLock<HashMap<String, Arc<CircuitBreaker>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderRouter {
|
||||||
|
/// 创建新的供应商路由器
|
||||||
|
pub fn new(db: Arc<Database>) -> Self {
|
||||||
|
Self {
|
||||||
|
db,
|
||||||
|
circuit_breakers: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择可用的供应商(支持故障转移)
|
||||||
|
///
|
||||||
|
/// 返回按优先级排序的可用供应商列表:
|
||||||
|
/// - 故障转移关闭时:仅返回当前供应商
|
||||||
|
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
|
||||||
|
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
|
||||||
|
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
|
||||||
|
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
|
||||||
|
Ok(config) => {
|
||||||
|
let enabled = config.auto_failover_enabled;
|
||||||
|
log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}");
|
||||||
|
enabled
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if auto_failover_enabled {
|
||||||
|
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
|
||||||
|
let failover_providers = self.db.get_failover_providers(app_type)?;
|
||||||
|
log::info!(
|
||||||
|
"[{}] Failover enabled, using queue order ({} items)",
|
||||||
|
app_type,
|
||||||
|
failover_providers.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
for provider in failover_providers {
|
||||||
|
// 检查熔断器状态
|
||||||
|
let circuit_key = format!("{}:{}", app_type, provider.id);
|
||||||
|
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||||
|
|
||||||
|
if breaker.is_available().await {
|
||||||
|
log::info!(
|
||||||
|
"[{}] Queue provider available: {} ({}) at sort_index {:?}",
|
||||||
|
app_type,
|
||||||
|
provider.name,
|
||||||
|
provider.id,
|
||||||
|
provider.sort_index
|
||||||
|
);
|
||||||
|
result.push(provider);
|
||||||
|
} else {
|
||||||
|
log::debug!(
|
||||||
|
"[{}] Queue provider {} circuit breaker open, skipping",
|
||||||
|
app_type,
|
||||||
|
provider.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
|
||||||
|
// 原因:单 Provider 场景下,熔断器打开会导致所有请求失败,用户体验差
|
||||||
|
log::info!("[{app_type}] Failover disabled, using current provider only (circuit breaker bypassed)");
|
||||||
|
|
||||||
|
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
||||||
|
if let Some(current) = self.db.get_provider_by_id(¤t_id, app_type)? {
|
||||||
|
log::info!(
|
||||||
|
"[{}] Current provider: {} ({})",
|
||||||
|
app_type,
|
||||||
|
current.name,
|
||||||
|
current.id
|
||||||
|
);
|
||||||
|
result.push(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.is_empty() {
|
||||||
|
return Err(AppError::Config(format!(
|
||||||
|
"No available provider for {app_type} (all circuit breakers open or no providers configured)"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[{}] Provider chain: {} provider(s) available",
|
||||||
|
app_type,
|
||||||
|
result.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求执行前获取熔断器“放行许可”
|
||||||
|
///
|
||||||
|
/// - Closed:直接放行
|
||||||
|
/// - Open:超时到达后切到 HalfOpen 并放行一次探测
|
||||||
|
/// - HalfOpen:按限流规则放行探测
|
||||||
|
///
|
||||||
|
/// 注意:调用方必须在请求结束后通过 `record_result()` 释放 HalfOpen 名额,
|
||||||
|
/// 否则会导致该 Provider 长时间无法进入探测状态。
|
||||||
|
pub async fn allow_provider_request(&self, provider_id: &str, app_type: &str) -> AllowResult {
|
||||||
|
let circuit_key = format!("{app_type}:{provider_id}");
|
||||||
|
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||||
|
breaker.allow_request().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录供应商请求结果
|
||||||
|
pub async fn record_result(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
used_half_open_permit: bool,
|
||||||
|
success: bool,
|
||||||
|
error_msg: Option<String>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用)
|
||||||
|
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
|
||||||
|
Ok(app_config) => app_config.circuit_failure_threshold,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to load circuit config for {app_type}, using default threshold: {e}"
|
||||||
|
);
|
||||||
|
5 // 默认值
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 更新熔断器状态
|
||||||
|
let circuit_key = format!("{app_type}:{provider_id}");
|
||||||
|
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||||
|
|
||||||
|
if success {
|
||||||
|
breaker.record_success(used_half_open_permit).await;
|
||||||
|
log::debug!("Provider {provider_id} request succeeded");
|
||||||
|
} else {
|
||||||
|
breaker.record_failure(used_half_open_permit).await;
|
||||||
|
log::warn!(
|
||||||
|
"Provider {} request failed: {}",
|
||||||
|
provider_id,
|
||||||
|
error_msg.as_deref().unwrap_or("Unknown error")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 更新数据库健康状态(使用配置的阈值)
|
||||||
|
self.db
|
||||||
|
.update_provider_health_with_threshold(
|
||||||
|
provider_id,
|
||||||
|
app_type,
|
||||||
|
success,
|
||||||
|
error_msg.clone(),
|
||||||
|
failure_threshold,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置熔断器(手动恢复)
|
||||||
|
pub async fn reset_circuit_breaker(&self, circuit_key: &str) {
|
||||||
|
let breakers = self.circuit_breakers.read().await;
|
||||||
|
if let Some(breaker) = breakers.get(circuit_key) {
|
||||||
|
log::info!("Manually resetting circuit breaker for {circuit_key}");
|
||||||
|
breaker.reset().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置指定供应商的熔断器
|
||||||
|
pub async fn reset_provider_breaker(&self, provider_id: &str, app_type: &str) {
|
||||||
|
let circuit_key = format!("{app_type}:{provider_id}");
|
||||||
|
self.reset_circuit_breaker(&circuit_key).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新所有熔断器的配置(热更新)
|
||||||
|
///
|
||||||
|
/// 当用户在 UI 中修改熔断器配置后调用此方法,
|
||||||
|
/// 所有现有的熔断器会立即使用新配置
|
||||||
|
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
|
||||||
|
let breakers = self.circuit_breakers.read().await;
|
||||||
|
let count = breakers.len();
|
||||||
|
|
||||||
|
for breaker in breakers.values() {
|
||||||
|
breaker.update_config(config.clone()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("已更新 {count} 个熔断器的配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取熔断器状态
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn get_circuit_breaker_stats(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Option<crate::proxy::circuit_breaker::CircuitBreakerStats> {
|
||||||
|
let circuit_key = format!("{app_type}:{provider_id}");
|
||||||
|
let breakers = self.circuit_breakers.read().await;
|
||||||
|
|
||||||
|
if let Some(breaker) = breakers.get(&circuit_key) {
|
||||||
|
Some(breaker.get_stats().await)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取或创建熔断器
|
||||||
|
async fn get_or_create_circuit_breaker(&self, key: &str) -> Arc<CircuitBreaker> {
|
||||||
|
// 先尝试读锁获取
|
||||||
|
{
|
||||||
|
let breakers = self.circuit_breakers.read().await;
|
||||||
|
if let Some(breaker) = breakers.get(key) {
|
||||||
|
return breaker.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不存在,获取写锁创建
|
||||||
|
let mut breakers = self.circuit_breakers.write().await;
|
||||||
|
|
||||||
|
// 双重检查,防止竞争条件
|
||||||
|
if let Some(breaker) = breakers.get(key) {
|
||||||
|
return breaker.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 key 中提取 app_type (格式: "app_type:provider_id")
|
||||||
|
let app_type = key.split(':').next().unwrap_or("claude");
|
||||||
|
|
||||||
|
// 按应用独立读取熔断器配置
|
||||||
|
let config = match self.db.get_proxy_config_for_app(app_type).await {
|
||||||
|
Ok(app_config) => {
|
||||||
|
log::debug!(
|
||||||
|
"Loading circuit breaker config for {key} (app={app_type}): \
|
||||||
|
failure_threshold={}, success_threshold={}, timeout={}s",
|
||||||
|
app_config.circuit_failure_threshold,
|
||||||
|
app_config.circuit_success_threshold,
|
||||||
|
app_config.circuit_timeout_seconds
|
||||||
|
);
|
||||||
|
crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
||||||
|
failure_threshold: app_config.circuit_failure_threshold,
|
||||||
|
success_threshold: app_config.circuit_success_threshold,
|
||||||
|
timeout_seconds: app_config.circuit_timeout_seconds as u64,
|
||||||
|
error_rate_threshold: app_config.circuit_error_rate_threshold,
|
||||||
|
min_requests: app_config.circuit_min_requests,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to load circuit breaker config for {key} (app={app_type}): {e}, using default"
|
||||||
|
);
|
||||||
|
crate::proxy::circuit_breaker::CircuitBreakerConfig::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log::debug!("Creating new circuit breaker for {key} with config: {config:?}");
|
||||||
|
|
||||||
|
let breaker = Arc::new(CircuitBreaker::new(config));
|
||||||
|
breakers.insert(key.to_string(), breaker.clone());
|
||||||
|
|
||||||
|
breaker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::database::Database;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_provider_router_creation() {
|
||||||
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
let router = ProviderRouter::new(db);
|
||||||
|
|
||||||
|
let breaker = router.get_or_create_circuit_breaker("claude:test").await;
|
||||||
|
assert!(breaker.allow_request().await.allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_failover_disabled_uses_current_provider() {
|
||||||
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
|
let provider_a =
|
||||||
|
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||||
|
let provider_b =
|
||||||
|
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||||
|
|
||||||
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
|
db.save_provider("claude", &provider_b).unwrap();
|
||||||
|
db.set_current_provider("claude", "a").unwrap();
|
||||||
|
db.add_to_failover_queue("claude", "b").unwrap();
|
||||||
|
|
||||||
|
let router = ProviderRouter::new(db.clone());
|
||||||
|
let providers = router.select_providers("claude").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(providers.len(), 1);
|
||||||
|
assert_eq!(providers[0].id, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_failover_enabled_uses_queue_order() {
|
||||||
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
|
// 设置 sort_index 来控制顺序:b=1, a=2
|
||||||
|
let mut provider_a =
|
||||||
|
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||||
|
provider_a.sort_index = Some(2);
|
||||||
|
let mut provider_b =
|
||||||
|
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||||
|
provider_b.sort_index = Some(1);
|
||||||
|
|
||||||
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
|
db.save_provider("claude", &provider_b).unwrap();
|
||||||
|
db.set_current_provider("claude", "a").unwrap();
|
||||||
|
|
||||||
|
db.add_to_failover_queue("claude", "b").unwrap();
|
||||||
|
db.add_to_failover_queue("claude", "a").unwrap();
|
||||||
|
|
||||||
|
// 启用自动故障转移(使用新的 proxy_config API)
|
||||||
|
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
|
||||||
|
config.auto_failover_enabled = true;
|
||||||
|
db.update_proxy_config_for_app(config).await.unwrap();
|
||||||
|
|
||||||
|
let router = ProviderRouter::new(db.clone());
|
||||||
|
let providers = router.select_providers("claude").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(providers.len(), 2);
|
||||||
|
// 按 sort_index 排序:b(1) 在前,a(2) 在后
|
||||||
|
assert_eq!(providers[0].id, "b");
|
||||||
|
assert_eq!(providers[1].id, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_select_providers_does_not_consume_half_open_permit() {
|
||||||
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
|
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
||||||
|
failure_threshold: 1,
|
||||||
|
timeout_seconds: 0,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let provider_a =
|
||||||
|
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||||
|
let provider_b =
|
||||||
|
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||||
|
|
||||||
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
|
db.save_provider("claude", &provider_b).unwrap();
|
||||||
|
|
||||||
|
db.add_to_failover_queue("claude", "a").unwrap();
|
||||||
|
db.add_to_failover_queue("claude", "b").unwrap();
|
||||||
|
|
||||||
|
// 启用自动故障转移(使用新的 proxy_config API)
|
||||||
|
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
|
||||||
|
config.auto_failover_enabled = true;
|
||||||
|
db.update_proxy_config_for_app(config).await.unwrap();
|
||||||
|
|
||||||
|
let router = ProviderRouter::new(db.clone());
|
||||||
|
|
||||||
|
router
|
||||||
|
.record_result("b", "claude", false, false, Some("fail".to_string()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let providers = router.select_providers("claude").await.unwrap();
|
||||||
|
assert_eq!(providers.len(), 2);
|
||||||
|
|
||||||
|
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
//! Provider Adapter Trait
|
||||||
|
//!
|
||||||
|
//! 定义供应商适配器的统一接口,抽象不同上游供应商的处理逻辑。
|
||||||
|
|
||||||
|
use super::auth::AuthInfo;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::proxy::error::ProxyError;
|
||||||
|
use reqwest::RequestBuilder;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// 供应商适配器 Trait
|
||||||
|
///
|
||||||
|
/// 所有供应商适配器都需要实现此 trait,提供统一的接口来处理:
|
||||||
|
/// - URL 构建
|
||||||
|
/// - 认证信息提取和头部注入
|
||||||
|
/// - 请求/响应格式转换(可选)
|
||||||
|
///
|
||||||
|
/// # 示例
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// pub struct ClaudeAdapter;
|
||||||
|
///
|
||||||
|
/// impl ProviderAdapter for ClaudeAdapter {
|
||||||
|
/// fn name(&self) -> &'static str { "Claude" }
|
||||||
|
///
|
||||||
|
/// fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||||
|
/// // 从 provider 配置中提取 base_url
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||||
|
/// // 从 provider 配置中提取认证信息
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||||
|
/// format!("{}{}", base_url.trim_end_matches('/'), endpoint)
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||||
|
/// // 添加认证头
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub trait ProviderAdapter: Send + Sync {
|
||||||
|
/// 适配器名称(用于日志和调试)
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// 从 Provider 配置中提取 base_url
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `provider` - Provider 配置
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `Ok(String)` - 提取到的 base_url(已去除尾部斜杠)
|
||||||
|
/// * `Err(ProxyError)` - 提取失败
|
||||||
|
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError>;
|
||||||
|
|
||||||
|
/// 从 Provider 配置中提取认证信息
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `provider` - Provider 配置
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `Some(AuthInfo)` - 提取到的认证信息
|
||||||
|
/// * `None` - 未找到认证信息
|
||||||
|
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo>;
|
||||||
|
|
||||||
|
/// 构建请求 URL
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `base_url` - 基础 URL
|
||||||
|
/// * `endpoint` - 请求端点(如 `/v1/messages`)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// 完整的请求 URL
|
||||||
|
fn build_url(&self, base_url: &str, endpoint: &str) -> String;
|
||||||
|
|
||||||
|
/// 添加认证头到请求
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `request` - reqwest RequestBuilder
|
||||||
|
/// * `auth` - 认证信息
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// 添加了认证头的 RequestBuilder
|
||||||
|
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder;
|
||||||
|
|
||||||
|
/// 是否需要格式转换
|
||||||
|
///
|
||||||
|
/// 默认返回 `false`(透传模式)。
|
||||||
|
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `provider` - Provider 配置
|
||||||
|
fn needs_transform(&self, _provider: &Provider) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转换请求体
|
||||||
|
///
|
||||||
|
/// 将请求体从一种格式转换为另一种格式(如 Anthropic → OpenAI)。
|
||||||
|
/// 默认实现直接返回原始请求体(透传)。
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `body` - 原始请求体
|
||||||
|
/// * `provider` - Provider 配置(用于获取模型映射等)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `Ok(Value)` - 转换后的请求体
|
||||||
|
/// * `Err(ProxyError)` - 转换失败
|
||||||
|
fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> {
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转换响应体
|
||||||
|
///
|
||||||
|
/// 将响应体从一种格式转换为另一种格式(如 OpenAI → Anthropic)。
|
||||||
|
/// 默认实现直接返回原始响应体(透传)。
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `body` - 原始响应体
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `Ok(Value)` - 转换后的响应体
|
||||||
|
/// * `Err(ProxyError)` - 转换失败
|
||||||
|
///
|
||||||
|
/// Note: 响应转换将在 handler 层集成,目前预留接口
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
//! Authentication Types
|
||||||
|
//!
|
||||||
|
//! 定义认证信息和认证策略,支持多种上游供应商的认证方式。
|
||||||
|
|
||||||
|
/// 认证信息
|
||||||
|
///
|
||||||
|
/// 包含 API Key 和对应的认证策略
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthInfo {
|
||||||
|
/// API Key
|
||||||
|
pub api_key: String,
|
||||||
|
/// 认证策略
|
||||||
|
pub strategy: AuthStrategy,
|
||||||
|
/// OAuth access_token(用于 GoogleOAuth 策略)
|
||||||
|
pub access_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthInfo {
|
||||||
|
/// 创建新的认证信息
|
||||||
|
pub fn new(api_key: String, strategy: AuthStrategy) -> Self {
|
||||||
|
Self {
|
||||||
|
api_key,
|
||||||
|
strategy,
|
||||||
|
access_token: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建带有 access_token 的认证信息(用于 OAuth)
|
||||||
|
pub fn with_access_token(api_key: String, access_token: String) -> Self {
|
||||||
|
Self {
|
||||||
|
api_key,
|
||||||
|
strategy: AuthStrategy::GoogleOAuth,
|
||||||
|
access_token: Some(access_token),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回遮蔽后的 API Key(用于日志输出)
|
||||||
|
///
|
||||||
|
/// 显示前4位和后4位,中间用 `...` 代替
|
||||||
|
/// 如果 key 长度不足8位,则返回 `***`
|
||||||
|
pub fn masked_key(&self) -> String {
|
||||||
|
if self.api_key.len() > 8 {
|
||||||
|
format!(
|
||||||
|
"{}...{}",
|
||||||
|
&self.api_key[..4],
|
||||||
|
&self.api_key[self.api_key.len() - 4..]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"***".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回遮蔽后的 access_token(用于日志输出)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn masked_access_token(&self) -> Option<String> {
|
||||||
|
self.access_token.as_ref().map(|token| {
|
||||||
|
if token.len() > 8 {
|
||||||
|
format!("{}...{}", &token[..4], &token[token.len() - 4..])
|
||||||
|
} else {
|
||||||
|
"***".to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 认证策略
|
||||||
|
///
|
||||||
|
/// 不同供应商使用不同的认证方式
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AuthStrategy {
|
||||||
|
/// Anthropic 认证方式
|
||||||
|
/// - Header: `x-api-key: <api_key>`
|
||||||
|
/// - Header: `anthropic-version: 2023-06-01`
|
||||||
|
Anthropic,
|
||||||
|
|
||||||
|
/// Claude 中转服务认证方式(仅 Bearer,无 x-api-key)
|
||||||
|
///
|
||||||
|
/// - Header: `Authorization: Bearer <api_key>`
|
||||||
|
///
|
||||||
|
/// 用于不支持 x-api-key 的中转服务
|
||||||
|
ClaudeAuth,
|
||||||
|
|
||||||
|
/// Bearer Token 认证方式(OpenAI 等)
|
||||||
|
///
|
||||||
|
/// - Header: `Authorization: Bearer <api_key>`
|
||||||
|
Bearer,
|
||||||
|
|
||||||
|
/// Google API Key 认证方式
|
||||||
|
///
|
||||||
|
/// - Header: `x-goog-api-key: <api_key>`
|
||||||
|
Google,
|
||||||
|
|
||||||
|
/// Google OAuth 认证方式
|
||||||
|
///
|
||||||
|
/// - Header: `Authorization: Bearer <access_token>`
|
||||||
|
///
|
||||||
|
/// 用于 Gemini CLI 等需要 OAuth 的场景
|
||||||
|
GoogleOAuth,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_masked_key_long() {
|
||||||
|
let auth = AuthInfo::new("sk-1234567890abcdef".to_string(), AuthStrategy::Bearer);
|
||||||
|
assert_eq!(auth.masked_key(), "sk-1...cdef");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_masked_key_short() {
|
||||||
|
let auth = AuthInfo::new("short".to_string(), AuthStrategy::Bearer);
|
||||||
|
assert_eq!(auth.masked_key(), "***");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_masked_key_exactly_8() {
|
||||||
|
let auth = AuthInfo::new("12345678".to_string(), AuthStrategy::Bearer);
|
||||||
|
assert_eq!(auth.masked_key(), "***");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_masked_key_9_chars() {
|
||||||
|
let auth = AuthInfo::new("123456789".to_string(), AuthStrategy::Bearer);
|
||||||
|
assert_eq!(auth.masked_key(), "1234...6789");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_strategy_equality() {
|
||||||
|
assert_eq!(AuthStrategy::Anthropic, AuthStrategy::Anthropic);
|
||||||
|
assert_ne!(AuthStrategy::Anthropic, AuthStrategy::Bearer);
|
||||||
|
assert_ne!(AuthStrategy::Bearer, AuthStrategy::Google);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_info_new_has_no_access_token() {
|
||||||
|
let auth = AuthInfo::new("api-key".to_string(), AuthStrategy::Bearer);
|
||||||
|
assert!(auth.access_token.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_info_with_access_token() {
|
||||||
|
let auth = AuthInfo::with_access_token(
|
||||||
|
"refresh-token".to_string(),
|
||||||
|
"ya29.access-token-12345".to_string(),
|
||||||
|
);
|
||||||
|
assert_eq!(auth.api_key, "refresh-token");
|
||||||
|
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||||
|
assert_eq!(
|
||||||
|
auth.access_token,
|
||||||
|
Some("ya29.access-token-12345".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_masked_access_token_long() {
|
||||||
|
let auth =
|
||||||
|
AuthInfo::with_access_token("refresh".to_string(), "ya29.1234567890abcdef".to_string());
|
||||||
|
assert_eq!(auth.masked_access_token(), Some("ya29...cdef".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_masked_access_token_short() {
|
||||||
|
let auth = AuthInfo::with_access_token("refresh".to_string(), "short".to_string());
|
||||||
|
assert_eq!(auth.masked_access_token(), Some("***".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_masked_access_token_none() {
|
||||||
|
let auth = AuthInfo::new("api-key".to_string(), AuthStrategy::Bearer);
|
||||||
|
assert!(auth.masked_access_token().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_claude_auth_strategy() {
|
||||||
|
let auth = AuthInfo::new("sk-test".to_string(), AuthStrategy::ClaudeAuth);
|
||||||
|
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||||||
|
assert_ne!(auth.strategy, AuthStrategy::Anthropic);
|
||||||
|
assert_ne!(auth.strategy, AuthStrategy::Bearer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_google_oauth_strategy() {
|
||||||
|
let auth = AuthInfo::new("refresh-token".to_string(), AuthStrategy::GoogleOAuth);
|
||||||
|
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||||
|
assert_ne!(auth.strategy, AuthStrategy::Google);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_strategies_are_distinct() {
|
||||||
|
let strategies = [
|
||||||
|
AuthStrategy::Anthropic,
|
||||||
|
AuthStrategy::ClaudeAuth,
|
||||||
|
AuthStrategy::Bearer,
|
||||||
|
AuthStrategy::Google,
|
||||||
|
AuthStrategy::GoogleOAuth,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (i, s1) in strategies.iter().enumerate() {
|
||||||
|
for (j, s2) in strategies.iter().enumerate() {
|
||||||
|
if i == j {
|
||||||
|
assert_eq!(s1, s2);
|
||||||
|
} else {
|
||||||
|
assert_ne!(s1, s2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
//! Claude (Anthropic) Provider Adapter
|
||||||
|
//!
|
||||||
|
//! 支持透传模式和 OpenRouter 兼容模式
|
||||||
|
//!
|
||||||
|
//! ## 认证模式
|
||||||
|
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||||||
|
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||||||
|
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用)
|
||||||
|
|
||||||
|
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::proxy::error::ProxyError;
|
||||||
|
use reqwest::RequestBuilder;
|
||||||
|
|
||||||
|
/// Claude 适配器
|
||||||
|
pub struct ClaudeAdapter;
|
||||||
|
|
||||||
|
impl ClaudeAdapter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取供应商类型
|
||||||
|
///
|
||||||
|
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
|
||||||
|
/// - OpenRouter: base_url 包含 openrouter.ai
|
||||||
|
/// - ClaudeAuth: auth_mode 为 bearer_only
|
||||||
|
/// - Claude: 默认 Anthropic 官方
|
||||||
|
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||||||
|
// 检测 OpenRouter
|
||||||
|
if self.is_openrouter(provider) {
|
||||||
|
return ProviderType::OpenRouter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测 ClaudeAuth (仅 Bearer 认证)
|
||||||
|
if self.is_bearer_only_mode(provider) {
|
||||||
|
return ProviderType::ClaudeAuth;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderType::Claude
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检测是否使用 OpenRouter
|
||||||
|
fn is_openrouter(&self, provider: &Provider) -> bool {
|
||||||
|
if let Ok(base_url) = self.extract_base_url(provider) {
|
||||||
|
return base_url.contains("openrouter.ai");
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检测 OpenRouter 是否启用兼容模式
|
||||||
|
fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool {
|
||||||
|
if !self.is_openrouter(provider) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = provider.settings_config.get("openrouter_compat_mode");
|
||||||
|
match raw {
|
||||||
|
Some(serde_json::Value::Bool(enabled)) => *enabled,
|
||||||
|
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
|
||||||
|
Some(serde_json::Value::String(value)) => {
|
||||||
|
let normalized = value.trim().to_lowercase();
|
||||||
|
normalized == "true" || normalized == "1"
|
||||||
|
}
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检测是否为仅 Bearer 认证模式
|
||||||
|
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
|
||||||
|
// 检查 settings_config 中的 auth_mode
|
||||||
|
if let Some(auth_mode) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("auth_mode")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
if auth_mode == "bearer_only" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 env 中的 AUTH_MODE
|
||||||
|
if let Some(env) = provider.settings_config.get("env") {
|
||||||
|
if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) {
|
||||||
|
if auth_mode == "bearer_only" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Provider 配置中提取 API Key
|
||||||
|
fn extract_key(&self, provider: &Provider) -> Option<String> {
|
||||||
|
if let Some(env) = provider.settings_config.get("env") {
|
||||||
|
// Anthropic 标准 key
|
||||||
|
if let Some(key) = env
|
||||||
|
.get("ANTHROPIC_AUTH_TOKEN")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
|
||||||
|
return Some(key.to_string());
|
||||||
|
}
|
||||||
|
if let Some(key) = env
|
||||||
|
.get("ANTHROPIC_API_KEY")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
log::debug!("[Claude] 使用 ANTHROPIC_API_KEY");
|
||||||
|
return Some(key.to_string());
|
||||||
|
}
|
||||||
|
// OpenRouter key
|
||||||
|
if let Some(key) = env
|
||||||
|
.get("OPENROUTER_API_KEY")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
log::debug!("[Claude] 使用 OPENROUTER_API_KEY");
|
||||||
|
return Some(key.to_string());
|
||||||
|
}
|
||||||
|
// 备选 OpenAI key (用于 OpenRouter)
|
||||||
|
if let Some(key) = env
|
||||||
|
.get("OPENAI_API_KEY")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
log::debug!("[Claude] 使用 OPENAI_API_KEY");
|
||||||
|
return Some(key.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试直接获取
|
||||||
|
if let Some(key) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("apiKey")
|
||||||
|
.or_else(|| provider.settings_config.get("api_key"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
log::debug!("[Claude] 使用 apiKey/api_key");
|
||||||
|
return Some(key.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::warn!("[Claude] 未找到有效的 API Key");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ClaudeAdapter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderAdapter for ClaudeAdapter {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Claude"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||||
|
// 1. 从 env 中获取
|
||||||
|
if let Some(env) = provider.settings_config.get("env") {
|
||||||
|
if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
||||||
|
return Ok(url.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 尝试直接获取
|
||||||
|
if let Some(url) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("base_url")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
return Ok(url.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(url) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("baseURL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
return Ok(url.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(url) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("apiEndpoint")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
return Ok(url.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ProxyError::ConfigError(
|
||||||
|
"Claude Provider 缺少 base_url 配置".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||||
|
let provider_type = self.provider_type(provider);
|
||||||
|
let strategy = match provider_type {
|
||||||
|
ProviderType::OpenRouter => AuthStrategy::Bearer,
|
||||||
|
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
|
||||||
|
_ => AuthStrategy::Anthropic,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.extract_key(provider)
|
||||||
|
.map(|key| AuthInfo::new(key, strategy))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||||
|
// NOTE:
|
||||||
|
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages`
|
||||||
|
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。
|
||||||
|
//
|
||||||
|
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||||
|
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
base_url.trim_end_matches('/'),
|
||||||
|
endpoint.trim_start_matches('/')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||||
|
match auth.strategy {
|
||||||
|
// Anthropic 官方: Authorization Bearer + x-api-key + anthropic-version
|
||||||
|
AuthStrategy::Anthropic => request
|
||||||
|
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||||
|
.header("x-api-key", &auth.api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01"),
|
||||||
|
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
|
||||||
|
AuthStrategy::ClaudeAuth => request
|
||||||
|
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||||
|
.header("anthropic-version", "2023-06-01"),
|
||||||
|
// OpenRouter: Bearer
|
||||||
|
AuthStrategy::Bearer => request
|
||||||
|
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||||
|
.header("anthropic-version", "2023-06-01"),
|
||||||
|
_ => request,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn needs_transform(&self, _provider: &Provider) -> bool {
|
||||||
|
// NOTE:
|
||||||
|
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
|
||||||
|
// Anthropic ↔ OpenAI 的格式转换。
|
||||||
|
//
|
||||||
|
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
|
||||||
|
self.is_openrouter_compat_enabled(_provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform_request(
|
||||||
|
&self,
|
||||||
|
body: serde_json::Value,
|
||||||
|
provider: &Provider,
|
||||||
|
) -> Result<serde_json::Value, ProxyError> {
|
||||||
|
super::transform::anthropic_to_openai(body, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
||||||
|
super::transform::openai_to_anthropic(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn create_provider(config: serde_json::Value) -> Provider {
|
||||||
|
Provider {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: "Test Claude".to_string(),
|
||||||
|
settings_config: config,
|
||||||
|
website_url: None,
|
||||||
|
category: Some("claude".to_string()),
|
||||||
|
created_at: None,
|
||||||
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
|
meta: None,
|
||||||
|
icon: None,
|
||||||
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_base_url_from_env() {
|
||||||
|
let adapter = ClaudeAdapter::new();
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let url = adapter.extract_base_url(&provider).unwrap();
|
||||||
|
assert_eq!(url, "https://api.anthropic.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_auth_anthropic() {
|
||||||
|
let adapter = ClaudeAdapter::new();
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test-key"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let auth = adapter.extract_auth(&provider).unwrap();
|
||||||
|
assert_eq!(auth.api_key, "sk-ant-test-key");
|
||||||
|
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_auth_anthropic_api_key() {
|
||||||
|
let adapter = ClaudeAdapter::new();
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||||
|
"ANTHROPIC_API_KEY": "sk-ant-test-key"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let auth = adapter.extract_auth(&provider).unwrap();
|
||||||
|
assert_eq!(auth.api_key, "sk-ant-test-key");
|
||||||
|
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_auth_openrouter() {
|
||||||
|
let adapter = ClaudeAdapter::new();
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||||||
|
"OPENROUTER_API_KEY": "sk-or-test-key"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let auth = adapter.extract_auth(&provider).unwrap();
|
||||||
|
assert_eq!(auth.api_key, "sk-or-test-key");
|
||||||
|
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_auth_claude_auth_mode() {
|
||||||
|
let adapter = ClaudeAdapter::new();
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key"
|
||||||
|
},
|
||||||
|
"auth_mode": "bearer_only"
|
||||||
|
}));
|
||||||
|
|
||||||
|
let auth = adapter.extract_auth(&provider).unwrap();
|
||||||
|
assert_eq!(auth.api_key, "sk-proxy-key");
|
||||||
|
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_auth_claude_auth_env_mode() {
|
||||||
|
let adapter = ClaudeAdapter::new();
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key",
|
||||||
|
"AUTH_MODE": "bearer_only"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let auth = adapter.extract_auth(&provider).unwrap();
|
||||||
|
assert_eq!(auth.api_key, "sk-proxy-key");
|
||||||
|
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_type_detection() {
|
||||||
|
let adapter = ClaudeAdapter::new();
|
||||||
|
|
||||||
|
// Anthropic 官方
|
||||||
|
let anthropic = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
assert_eq!(adapter.provider_type(&anthropic), ProviderType::Claude);
|
||||||
|
|
||||||
|
// OpenRouter
|
||||||
|
let openrouter = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||||||
|
"OPENROUTER_API_KEY": "sk-or-test"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
assert_eq!(adapter.provider_type(&openrouter), ProviderType::OpenRouter);
|
||||||
|
|
||||||
|
// ClaudeAuth
|
||||||
|
let claude_auth = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "sk-test"
|
||||||
|
},
|
||||||
|
"auth_mode": "bearer_only"
|
||||||
|
}));
|
||||||
|
assert_eq!(
|
||||||
|
adapter.provider_type(&claude_auth),
|
||||||
|
ProviderType::ClaudeAuth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_url_anthropic() {
|
||||||
|
let adapter = ClaudeAdapter::new();
|
||||||
|
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
|
||||||
|
assert_eq!(url, "https://api.anthropic.com/v1/messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_url_openrouter() {
|
||||||
|
let adapter = ClaudeAdapter::new();
|
||||||
|
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
|
||||||
|
assert_eq!(url, "https://openrouter.ai/api/v1/messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_needs_transform() {
|
||||||
|
let adapter = ClaudeAdapter::new();
|
||||||
|
|
||||||
|
let anthropic_provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
assert!(!adapter.needs_transform(&anthropic_provider));
|
||||||
|
|
||||||
|
let openrouter_provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
assert!(adapter.needs_transform(&openrouter_provider));
|
||||||
|
|
||||||
|
let openrouter_disabled = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||||
|
},
|
||||||
|
"openrouter_compat_mode": false
|
||||||
|
}));
|
||||||
|
assert!(!adapter.needs_transform(&openrouter_disabled));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
//! Codex (OpenAI) Provider Adapter
|
||||||
|
//!
|
||||||
|
//! 仅透传模式,支持直连 OpenAI API
|
||||||
|
//!
|
||||||
|
//! ## 客户端检测
|
||||||
|
//! 支持检测官方 Codex 客户端 (codex_vscode, codex_cli_rs)
|
||||||
|
|
||||||
|
use super::{AuthInfo, AuthStrategy, ProviderAdapter};
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::proxy::error::ProxyError;
|
||||||
|
use regex::Regex;
|
||||||
|
use reqwest::RequestBuilder;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
/// 官方 Codex 客户端 User-Agent 正则
|
||||||
|
#[allow(dead_code)]
|
||||||
|
static CODEX_CLIENT_REGEX: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^(codex_vscode|codex_cli_rs)/[\d.]+").unwrap());
|
||||||
|
|
||||||
|
/// Codex 适配器
|
||||||
|
pub struct CodexAdapter;
|
||||||
|
|
||||||
|
impl CodexAdapter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检测是否为官方 Codex 客户端
|
||||||
|
///
|
||||||
|
/// 匹配 User-Agent 模式: `^(codex_vscode|codex_cli_rs)/[\d.]+`
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn is_official_client(user_agent: &str) -> bool {
|
||||||
|
CODEX_CLIENT_REGEX.is_match(user_agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Provider 配置中提取 API Key
|
||||||
|
fn extract_key(&self, provider: &Provider) -> Option<String> {
|
||||||
|
// 1. 尝试从 env 中获取
|
||||||
|
if let Some(env) = provider.settings_config.get("env") {
|
||||||
|
if let Some(key) = env.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
|
||||||
|
return Some(key.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 尝试从 auth 中获取 (Codex CLI 格式)
|
||||||
|
if let Some(auth) = provider.settings_config.get("auth") {
|
||||||
|
if let Some(key) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
|
||||||
|
return Some(key.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 尝试直接获取
|
||||||
|
if let Some(key) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("apiKey")
|
||||||
|
.or_else(|| provider.settings_config.get("api_key"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
return Some(key.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 尝试从 config 对象中获取
|
||||||
|
if let Some(config) = provider.settings_config.get("config") {
|
||||||
|
if let Some(key) = config
|
||||||
|
.get("api_key")
|
||||||
|
.or_else(|| config.get("apiKey"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
return Some(key.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CodexAdapter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderAdapter for CodexAdapter {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Codex"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||||
|
// 1. 尝试直接获取 base_url 字段
|
||||||
|
if let Some(url) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("base_url")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
return Ok(url.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 尝试 baseURL
|
||||||
|
if let Some(url) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("baseURL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
return Ok(url.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 尝试从 config 对象中获取
|
||||||
|
if let Some(config) = provider.settings_config.get("config") {
|
||||||
|
if let Some(url) = config.get("base_url").and_then(|v| v.as_str()) {
|
||||||
|
return Ok(url.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试解析 TOML 字符串格式
|
||||||
|
if let Some(config_str) = config.as_str() {
|
||||||
|
if let Some(start) = config_str.find("base_url = \"") {
|
||||||
|
let rest = &config_str[start + 12..];
|
||||||
|
if let Some(end) = rest.find('"') {
|
||||||
|
return Ok(rest[..end].trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(start) = config_str.find("base_url = '") {
|
||||||
|
let rest = &config_str[start + 12..];
|
||||||
|
if let Some(end) = rest.find('\'') {
|
||||||
|
return Ok(rest[..end].trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ProxyError::ConfigError(
|
||||||
|
"Codex Provider 缺少 base_url 配置".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||||
|
self.extract_key(provider)
|
||||||
|
.map(|key| AuthInfo::new(key, AuthStrategy::Bearer))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||||
|
let base_trimmed = base_url.trim_end_matches('/');
|
||||||
|
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||||
|
|
||||||
|
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
|
||||||
|
|
||||||
|
// 去除重复的 /v1/v1
|
||||||
|
if url.contains("/v1/v1") {
|
||||||
|
url = url.replace("/v1/v1", "/v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
url
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||||
|
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn create_provider(config: serde_json::Value) -> Provider {
|
||||||
|
Provider {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: "Test Codex".to_string(),
|
||||||
|
settings_config: config,
|
||||||
|
website_url: None,
|
||||||
|
category: Some("codex".to_string()),
|
||||||
|
created_at: None,
|
||||||
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
|
meta: None,
|
||||||
|
icon: None,
|
||||||
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_base_url_direct() {
|
||||||
|
let adapter = CodexAdapter::new();
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"base_url": "https://api.openai.com/v1"
|
||||||
|
}));
|
||||||
|
|
||||||
|
let url = adapter.extract_base_url(&provider).unwrap();
|
||||||
|
assert_eq!(url, "https://api.openai.com/v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_auth_from_auth_field() {
|
||||||
|
let adapter = CodexAdapter::new();
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"auth": {
|
||||||
|
"OPENAI_API_KEY": "sk-test-key-12345678"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let auth = adapter.extract_auth(&provider).unwrap();
|
||||||
|
assert_eq!(auth.api_key, "sk-test-key-12345678");
|
||||||
|
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_auth_from_env() {
|
||||||
|
let adapter = CodexAdapter::new();
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"OPENAI_API_KEY": "sk-env-key-12345678"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let auth = adapter.extract_auth(&provider).unwrap();
|
||||||
|
assert_eq!(auth.api_key, "sk-env-key-12345678");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_url() {
|
||||||
|
let adapter = CodexAdapter::new();
|
||||||
|
let url = adapter.build_url("https://api.openai.com/v1", "/responses");
|
||||||
|
assert_eq!(url, "https://api.openai.com/v1/responses");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_url_dedup_v1() {
|
||||||
|
let adapter = CodexAdapter::new();
|
||||||
|
// base_url 已包含 /v1,endpoint 也包含 /v1
|
||||||
|
let url = adapter.build_url("https://www.packyapi.com/v1", "/v1/responses");
|
||||||
|
assert_eq!(url, "https://www.packyapi.com/v1/responses");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 官方客户端检测测试
|
||||||
|
#[test]
|
||||||
|
fn test_is_official_client_vscode() {
|
||||||
|
assert!(CodexAdapter::is_official_client("codex_vscode/1.0.0"));
|
||||||
|
assert!(CodexAdapter::is_official_client("codex_vscode/2.3.4"));
|
||||||
|
assert!(CodexAdapter::is_official_client("codex_vscode/0.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_official_client_cli() {
|
||||||
|
assert!(CodexAdapter::is_official_client("codex_cli_rs/1.0.0"));
|
||||||
|
assert!(CodexAdapter::is_official_client("codex_cli_rs/0.5.2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_not_official_client() {
|
||||||
|
assert!(!CodexAdapter::is_official_client("Mozilla/5.0"));
|
||||||
|
assert!(!CodexAdapter::is_official_client("curl/7.68.0"));
|
||||||
|
assert!(!CodexAdapter::is_official_client("python-requests/2.25.1"));
|
||||||
|
assert!(!CodexAdapter::is_official_client("codex_other/1.0.0"));
|
||||||
|
assert!(!CodexAdapter::is_official_client(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_official_client_partial_match() {
|
||||||
|
// 必须从开头匹配
|
||||||
|
assert!(!CodexAdapter::is_official_client("some codex_vscode/1.0.0"));
|
||||||
|
assert!(!CodexAdapter::is_official_client(
|
||||||
|
"prefix_codex_cli_rs/1.0.0"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
//! Gemini (Google) Provider Adapter
|
||||||
|
//!
|
||||||
|
//! 支持 API Key 和 OAuth 两种认证方式
|
||||||
|
//!
|
||||||
|
//! ## 认证模式
|
||||||
|
//! - **Gemini**: API Key 认证 (x-goog-api-key)
|
||||||
|
//! - **GeminiCli**: OAuth Bearer 认证 (用于 Gemini CLI)
|
||||||
|
|
||||||
|
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::proxy::error::ProxyError;
|
||||||
|
use reqwest::RequestBuilder;
|
||||||
|
|
||||||
|
/// Gemini 适配器
|
||||||
|
pub struct GeminiAdapter;
|
||||||
|
|
||||||
|
/// OAuth 凭证结构
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct OAuthCredentials {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
pub client_id: Option<String>,
|
||||||
|
pub client_secret: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl OAuthCredentials {
|
||||||
|
/// 检查是否需要刷新 token(有 refresh_token 但没有有效的 access_token)
|
||||||
|
pub fn needs_refresh(&self) -> bool {
|
||||||
|
self.refresh_token.is_some() && self.access_token.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否可以刷新 token
|
||||||
|
pub fn can_refresh(&self) -> bool {
|
||||||
|
self.refresh_token.is_some() && self.client_id.is_some() && self.client_secret.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GeminiAdapter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取供应商类型
|
||||||
|
///
|
||||||
|
/// 根据 API Key 格式检测:
|
||||||
|
/// - GeminiCli: access_token (ya29. 开头) 或 JSON 格式凭证
|
||||||
|
/// - Gemini: 普通 API Key
|
||||||
|
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||||||
|
if let Some(key) = self.extract_key_raw(provider) {
|
||||||
|
// OAuth access_token 以 ya29. 开头
|
||||||
|
if key.starts_with("ya29.") {
|
||||||
|
return ProviderType::GeminiCli;
|
||||||
|
}
|
||||||
|
// JSON 格式的 OAuth 凭证
|
||||||
|
if key.starts_with('{') {
|
||||||
|
return ProviderType::GeminiCli;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProviderType::Gemini
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检测认证类型
|
||||||
|
pub fn detect_auth_type(&self, provider: &Provider) -> AuthStrategy {
|
||||||
|
match self.provider_type(provider) {
|
||||||
|
ProviderType::GeminiCli => AuthStrategy::GoogleOAuth,
|
||||||
|
_ => AuthStrategy::Google,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 OAuth 凭证
|
||||||
|
pub fn parse_oauth_credentials(&self, key: &str) -> Option<OAuthCredentials> {
|
||||||
|
// 直接是 access_token
|
||||||
|
if key.starts_with("ya29.") {
|
||||||
|
return Some(OAuthCredentials {
|
||||||
|
access_token: key.to_string(),
|
||||||
|
refresh_token: None,
|
||||||
|
client_id: None,
|
||||||
|
client_secret: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON 格式
|
||||||
|
if key.starts_with('{') {
|
||||||
|
if let Ok(json) = serde_json::from_str::<serde_json::Value>(key) {
|
||||||
|
let access_token = json
|
||||||
|
.get("access_token")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let refresh_token = json
|
||||||
|
.get("refresh_token")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
let client_id = json
|
||||||
|
.get("client_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
let client_secret = json
|
||||||
|
.get("client_secret")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
// 如果有 access_token 或 refresh_token,返回凭证
|
||||||
|
if !access_token.is_empty() || refresh_token.is_some() {
|
||||||
|
return Some(OAuthCredentials {
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Provider 配置中提取原始 API Key
|
||||||
|
fn extract_key_raw(&self, provider: &Provider) -> Option<String> {
|
||||||
|
if let Some(env) = provider.settings_config.get("env") {
|
||||||
|
// 使用 GEMINI_API_KEY
|
||||||
|
if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
||||||
|
return Some(key.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试直接获取
|
||||||
|
if let Some(key) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("apiKey")
|
||||||
|
.or_else(|| provider.settings_config.get("api_key"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
return Some(key.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GeminiAdapter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderAdapter for GeminiAdapter {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Gemini"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||||
|
// 从 env 中获取
|
||||||
|
if let Some(env) = provider.settings_config.get("env") {
|
||||||
|
if let Some(url) = env.get("GOOGLE_GEMINI_BASE_URL").and_then(|v| v.as_str()) {
|
||||||
|
return Ok(url.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试直接获取
|
||||||
|
if let Some(url) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("base_url")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
return Ok(url.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(url) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("baseURL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
return Ok(url.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ProxyError::ConfigError(
|
||||||
|
"Gemini Provider 缺少 base_url 配置".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||||
|
let key = self.extract_key_raw(provider)?;
|
||||||
|
let strategy = self.detect_auth_type(provider);
|
||||||
|
|
||||||
|
match strategy {
|
||||||
|
AuthStrategy::GoogleOAuth => {
|
||||||
|
// 解析 OAuth 凭证
|
||||||
|
if let Some(creds) = self.parse_oauth_credentials(&key) {
|
||||||
|
Some(AuthInfo::with_access_token(key, creds.access_token))
|
||||||
|
} else {
|
||||||
|
// 回退到普通 API Key
|
||||||
|
Some(AuthInfo::new(key, AuthStrategy::Google))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Some(AuthInfo::new(key, AuthStrategy::Google)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||||
|
let base_trimmed = base_url.trim_end_matches('/');
|
||||||
|
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||||
|
|
||||||
|
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
|
||||||
|
|
||||||
|
// 处理 /v1beta 路径去重
|
||||||
|
let version_patterns = ["/v1beta", "/v1"];
|
||||||
|
for pattern in &version_patterns {
|
||||||
|
let duplicate = format!("{pattern}{pattern}");
|
||||||
|
if url.contains(&duplicate) {
|
||||||
|
url = url.replace(&duplicate, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
url
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||||
|
match auth.strategy {
|
||||||
|
// OAuth Bearer 认证
|
||||||
|
AuthStrategy::GoogleOAuth => {
|
||||||
|
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
|
||||||
|
request
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.header("x-goog-api-client", "GeminiCLI/1.0")
|
||||||
|
}
|
||||||
|
// API Key 认证
|
||||||
|
_ => request.header("x-goog-api-key", &auth.api_key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn create_provider(config: serde_json::Value) -> Provider {
|
||||||
|
Provider {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: "Test Gemini".to_string(),
|
||||||
|
settings_config: config,
|
||||||
|
website_url: None,
|
||||||
|
category: Some("gemini".to_string()),
|
||||||
|
created_at: None,
|
||||||
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
|
meta: None,
|
||||||
|
icon: None,
|
||||||
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_base_url_from_env() {
|
||||||
|
let adapter = GeminiAdapter::new();
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let url = adapter.extract_base_url(&provider).unwrap();
|
||||||
|
assert_eq!(url, "https://generativelanguage.googleapis.com/v1beta");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_auth_api_key() {
|
||||||
|
let adapter = GeminiAdapter::new();
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "AIza-test-key-12345678"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let auth = adapter.extract_auth(&provider).unwrap();
|
||||||
|
assert_eq!(auth.api_key, "AIza-test-key-12345678");
|
||||||
|
assert_eq!(auth.strategy, AuthStrategy::Google);
|
||||||
|
assert!(auth.access_token.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_auth_oauth_access_token() {
|
||||||
|
let adapter = GeminiAdapter::new();
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "ya29.test-access-token-12345"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let auth = adapter.extract_auth(&provider).unwrap();
|
||||||
|
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||||
|
assert_eq!(
|
||||||
|
auth.access_token,
|
||||||
|
Some("ya29.test-access-token-12345".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_auth_oauth_json() {
|
||||||
|
let adapter = GeminiAdapter::new();
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test-token\",\"refresh_token\":\"1//refresh\"}"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let auth = adapter.extract_auth(&provider).unwrap();
|
||||||
|
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||||
|
assert_eq!(auth.access_token, Some("ya29.test-token".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_type_detection() {
|
||||||
|
let adapter = GeminiAdapter::new();
|
||||||
|
|
||||||
|
// API Key
|
||||||
|
let api_key_provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "AIza-test-key"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
assert_eq!(
|
||||||
|
adapter.provider_type(&api_key_provider),
|
||||||
|
ProviderType::Gemini
|
||||||
|
);
|
||||||
|
|
||||||
|
// OAuth access_token
|
||||||
|
let oauth_provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "ya29.test-token"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
assert_eq!(
|
||||||
|
adapter.provider_type(&oauth_provider),
|
||||||
|
ProviderType::GeminiCli
|
||||||
|
);
|
||||||
|
|
||||||
|
// OAuth JSON
|
||||||
|
let oauth_json_provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test\"}"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
assert_eq!(
|
||||||
|
adapter.provider_type(&oauth_json_provider),
|
||||||
|
ProviderType::GeminiCli
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_auth_fallback() {
|
||||||
|
let adapter = GeminiAdapter::new();
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "AIza-fallback-key"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let auth = adapter.extract_auth(&provider).unwrap();
|
||||||
|
assert_eq!(auth.api_key, "AIza-fallback-key");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_url_dedup() {
|
||||||
|
let adapter = GeminiAdapter::new();
|
||||||
|
// 模拟 base_url 已包含 /v1beta,endpoint 也包含 /v1beta
|
||||||
|
let url = adapter.build_url(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
"/v1beta/models/gemini-pro:generateContent",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_url_normal() {
|
||||||
|
let adapter = GeminiAdapter::new();
|
||||||
|
let url = adapter.build_url(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
"/models/gemini-pro:generateContent",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_oauth_credentials_direct_token() {
|
||||||
|
let adapter = GeminiAdapter::new();
|
||||||
|
let creds = adapter
|
||||||
|
.parse_oauth_credentials("ya29.test-access-token")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(creds.access_token, "ya29.test-access-token");
|
||||||
|
assert!(creds.refresh_token.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_oauth_credentials_json() {
|
||||||
|
let adapter = GeminiAdapter::new();
|
||||||
|
let creds = adapter
|
||||||
|
.parse_oauth_credentials(
|
||||||
|
"{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//refresh\"}",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(creds.access_token, "ya29.test");
|
||||||
|
assert_eq!(creds.refresh_token, Some("1//refresh".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_oauth_credentials_invalid() {
|
||||||
|
let adapter = GeminiAdapter::new();
|
||||||
|
assert!(adapter.parse_oauth_credentials("AIza-api-key").is_none());
|
||||||
|
assert!(adapter.parse_oauth_credentials("invalid-json{").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
//! Provider Adapters Module
|
||||||
|
//!
|
||||||
|
//! 供应商适配器模块,提供统一的接口抽象不同上游供应商的处理逻辑。
|
||||||
|
//!
|
||||||
|
//! ## 模块结构
|
||||||
|
//! - `adapter`: 定义 `ProviderAdapter` trait
|
||||||
|
//! - `auth`: 认证类型和策略
|
||||||
|
//! - `claude`: Claude (Anthropic) 适配器
|
||||||
|
//! - `codex`: Codex (OpenAI) 适配器
|
||||||
|
//! - `gemini`: Gemini (Google) 适配器
|
||||||
|
//! - `models`: API 数据模型
|
||||||
|
//! - `transform`: 格式转换
|
||||||
|
|
||||||
|
mod adapter;
|
||||||
|
mod auth;
|
||||||
|
mod claude;
|
||||||
|
mod codex;
|
||||||
|
mod gemini;
|
||||||
|
pub mod models;
|
||||||
|
pub mod streaming;
|
||||||
|
pub mod transform;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// 公开导出
|
||||||
|
pub use adapter::ProviderAdapter;
|
||||||
|
pub use auth::{AuthInfo, AuthStrategy};
|
||||||
|
pub use claude::ClaudeAdapter;
|
||||||
|
pub use codex::CodexAdapter;
|
||||||
|
pub use gemini::GeminiAdapter;
|
||||||
|
|
||||||
|
/// 供应商类型枚举
|
||||||
|
///
|
||||||
|
/// 区分不同供应商的具体实现方式,决定认证和请求处理逻辑。
|
||||||
|
/// 比 AppType 更细粒度,支持同一 AppType 下的多种变体。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ProviderType {
|
||||||
|
/// Anthropic 官方 API (x-api-key + anthropic-version)
|
||||||
|
Claude,
|
||||||
|
/// Claude 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||||||
|
ClaudeAuth,
|
||||||
|
/// OpenAI Codex Response API
|
||||||
|
Codex,
|
||||||
|
/// Google Gemini API (x-goog-api-key)
|
||||||
|
Gemini,
|
||||||
|
/// Google Gemini CLI (OAuth Bearer)
|
||||||
|
GeminiCli,
|
||||||
|
/// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用)
|
||||||
|
OpenRouter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderType {
|
||||||
|
/// 是否需要格式转换
|
||||||
|
///
|
||||||
|
/// 过去 OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式;
|
||||||
|
/// 现在默认关闭转换(因为 OpenRouter 已支持 Claude Code 兼容接口)。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn needs_transform(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
ProviderType::OpenRouter => false,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取默认端点
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn default_endpoint(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ProviderType::Claude | ProviderType::ClaudeAuth => "https://api.anthropic.com",
|
||||||
|
ProviderType::Codex => "https://api.openai.com",
|
||||||
|
ProviderType::Gemini | ProviderType::GeminiCli => {
|
||||||
|
"https://generativelanguage.googleapis.com"
|
||||||
|
}
|
||||||
|
ProviderType::OpenRouter => "https://openrouter.ai/api",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 AppType 和 Provider 配置推断供应商类型
|
||||||
|
///
|
||||||
|
/// 根据配置中的 base_url、auth_mode、api_key 格式等信息推断具体的供应商类型
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
|
||||||
|
match app_type {
|
||||||
|
AppType::Claude => {
|
||||||
|
// 检测是否为 OpenRouter
|
||||||
|
let adapter = ClaudeAdapter::new();
|
||||||
|
if let Ok(base_url) = adapter.extract_base_url(provider) {
|
||||||
|
if base_url.contains("openrouter.ai") {
|
||||||
|
return ProviderType::OpenRouter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 检测是否为中转服务(仅 Bearer 认证)
|
||||||
|
// 注意:ProviderMeta 没有直接的 auth_mode 字段,
|
||||||
|
// 我们通过检查 settings_config 中的配置来判断
|
||||||
|
// 检查 settings_config 中的 auth_mode
|
||||||
|
if let Some(auth_mode) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("auth_mode")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
if auth_mode == "bearer_only" {
|
||||||
|
return ProviderType::ClaudeAuth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 检查 env 中的 auth_mode
|
||||||
|
if let Some(env) = provider.settings_config.get("env") {
|
||||||
|
if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) {
|
||||||
|
if auth_mode == "bearer_only" {
|
||||||
|
return ProviderType::ClaudeAuth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProviderType::Claude
|
||||||
|
}
|
||||||
|
AppType::Codex => ProviderType::Codex,
|
||||||
|
AppType::Gemini => {
|
||||||
|
// 检测是否为 CLI 模式(OAuth)
|
||||||
|
let adapter = GeminiAdapter::new();
|
||||||
|
if let Some(auth) = adapter.extract_auth(provider) {
|
||||||
|
let key = &auth.api_key;
|
||||||
|
// OAuth access_token 以 ya29. 开头
|
||||||
|
if key.starts_with("ya29.") {
|
||||||
|
return ProviderType::GeminiCli;
|
||||||
|
}
|
||||||
|
// JSON 格式的 OAuth 凭证
|
||||||
|
if key.starts_with('{') {
|
||||||
|
return ProviderType::GeminiCli;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProviderType::Gemini
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转换为字符串表示
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ProviderType::Claude => "claude",
|
||||||
|
ProviderType::ClaudeAuth => "claude_auth",
|
||||||
|
ProviderType::Codex => "codex",
|
||||||
|
ProviderType::Gemini => "gemini",
|
||||||
|
ProviderType::GeminiCli => "gemini_cli",
|
||||||
|
ProviderType::OpenRouter => "openrouter",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ProviderType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for ProviderType {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"claude" => Ok(ProviderType::Claude),
|
||||||
|
"claude_auth" | "claude-auth" => Ok(ProviderType::ClaudeAuth),
|
||||||
|
"codex" => Ok(ProviderType::Codex),
|
||||||
|
"gemini" => Ok(ProviderType::Gemini),
|
||||||
|
"gemini_cli" | "gemini-cli" => Ok(ProviderType::GeminiCli),
|
||||||
|
"openrouter" => Ok(ProviderType::OpenRouter),
|
||||||
|
_ => Err(format!("Invalid provider type: {s}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据 AppType 获取对应的适配器
|
||||||
|
pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||||
|
match app_type {
|
||||||
|
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
||||||
|
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||||
|
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据 ProviderType 获取对应的适配器
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn ProviderAdapter> {
|
||||||
|
match provider_type {
|
||||||
|
ProviderType::Claude | ProviderType::ClaudeAuth | ProviderType::OpenRouter => {
|
||||||
|
Box::new(ClaudeAdapter::new())
|
||||||
|
}
|
||||||
|
ProviderType::Codex => Box::new(CodexAdapter::new()),
|
||||||
|
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn create_provider(config: serde_json::Value) -> Provider {
|
||||||
|
Provider {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: "Test Provider".to_string(),
|
||||||
|
settings_config: config,
|
||||||
|
website_url: None,
|
||||||
|
category: None,
|
||||||
|
created_at: None,
|
||||||
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
|
meta: None,
|
||||||
|
icon: None,
|
||||||
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_type_needs_transform() {
|
||||||
|
assert!(!ProviderType::Claude.needs_transform());
|
||||||
|
assert!(!ProviderType::ClaudeAuth.needs_transform());
|
||||||
|
assert!(!ProviderType::Codex.needs_transform());
|
||||||
|
assert!(!ProviderType::Gemini.needs_transform());
|
||||||
|
assert!(!ProviderType::GeminiCli.needs_transform());
|
||||||
|
assert!(!ProviderType::OpenRouter.needs_transform());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_type_default_endpoint() {
|
||||||
|
assert_eq!(
|
||||||
|
ProviderType::Claude.default_endpoint(),
|
||||||
|
"https://api.anthropic.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ProviderType::ClaudeAuth.default_endpoint(),
|
||||||
|
"https://api.anthropic.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ProviderType::Codex.default_endpoint(),
|
||||||
|
"https://api.openai.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ProviderType::Gemini.default_endpoint(),
|
||||||
|
"https://generativelanguage.googleapis.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ProviderType::GeminiCli.default_endpoint(),
|
||||||
|
"https://generativelanguage.googleapis.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ProviderType::OpenRouter.default_endpoint(),
|
||||||
|
"https://openrouter.ai/api"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_type_from_str() {
|
||||||
|
assert_eq!(
|
||||||
|
"claude".parse::<ProviderType>().unwrap(),
|
||||||
|
ProviderType::Claude
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
"claude_auth".parse::<ProviderType>().unwrap(),
|
||||||
|
ProviderType::ClaudeAuth
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
"claude-auth".parse::<ProviderType>().unwrap(),
|
||||||
|
ProviderType::ClaudeAuth
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
"codex".parse::<ProviderType>().unwrap(),
|
||||||
|
ProviderType::Codex
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
"gemini".parse::<ProviderType>().unwrap(),
|
||||||
|
ProviderType::Gemini
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
"gemini_cli".parse::<ProviderType>().unwrap(),
|
||||||
|
ProviderType::GeminiCli
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
"gemini-cli".parse::<ProviderType>().unwrap(),
|
||||||
|
ProviderType::GeminiCli
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
"openrouter".parse::<ProviderType>().unwrap(),
|
||||||
|
ProviderType::OpenRouter
|
||||||
|
);
|
||||||
|
assert!("invalid".parse::<ProviderType>().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_type_as_str() {
|
||||||
|
assert_eq!(ProviderType::Claude.as_str(), "claude");
|
||||||
|
assert_eq!(ProviderType::ClaudeAuth.as_str(), "claude_auth");
|
||||||
|
assert_eq!(ProviderType::Codex.as_str(), "codex");
|
||||||
|
assert_eq!(ProviderType::Gemini.as_str(), "gemini");
|
||||||
|
assert_eq!(ProviderType::GeminiCli.as_str(), "gemini_cli");
|
||||||
|
assert_eq!(ProviderType::OpenRouter.as_str(), "openrouter");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_type_serde() {
|
||||||
|
// Test serialization
|
||||||
|
let claude = ProviderType::Claude;
|
||||||
|
let serialized = serde_json::to_string(&claude).unwrap();
|
||||||
|
assert_eq!(serialized, "\"claude\"");
|
||||||
|
|
||||||
|
let claude_auth = ProviderType::ClaudeAuth;
|
||||||
|
let serialized = serde_json::to_string(&claude_auth).unwrap();
|
||||||
|
assert_eq!(serialized, "\"claude_auth\"");
|
||||||
|
|
||||||
|
// Test deserialization
|
||||||
|
let deserialized: ProviderType = serde_json::from_str("\"claude\"").unwrap();
|
||||||
|
assert_eq!(deserialized, ProviderType::Claude);
|
||||||
|
|
||||||
|
let deserialized: ProviderType = serde_json::from_str("\"gemini_cli\"").unwrap();
|
||||||
|
assert_eq!(deserialized, ProviderType::GeminiCli);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_app_type_claude_direct() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
|
||||||
|
assert_eq!(provider_type, ProviderType::Claude);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_app_type_claude_openrouter() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||||||
|
"OPENROUTER_API_KEY": "sk-or-test"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
|
||||||
|
assert_eq!(provider_type, ProviderType::OpenRouter);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_app_type_claude_auth() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "sk-test"
|
||||||
|
},
|
||||||
|
"auth_mode": "bearer_only"
|
||||||
|
}));
|
||||||
|
|
||||||
|
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
|
||||||
|
assert_eq!(provider_type, ProviderType::ClaudeAuth);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_app_type_codex() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"OPENAI_API_KEY": "sk-test"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let provider_type = ProviderType::from_app_type_and_config(&AppType::Codex, &provider);
|
||||||
|
assert_eq!(provider_type, ProviderType::Codex);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_app_type_gemini_api_key() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "AIza-test-key"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
|
||||||
|
assert_eq!(provider_type, ProviderType::Gemini);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_app_type_gemini_cli_oauth() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "ya29.test-access-token"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
|
||||||
|
assert_eq!(provider_type, ProviderType::GeminiCli);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_app_type_gemini_cli_json() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//test\"}"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
|
||||||
|
assert_eq!(provider_type, ProviderType::GeminiCli);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_adapter_for_provider_type() {
|
||||||
|
let adapter = get_adapter_for_provider_type(&ProviderType::Claude);
|
||||||
|
assert_eq!(adapter.name(), "Claude");
|
||||||
|
|
||||||
|
let adapter = get_adapter_for_provider_type(&ProviderType::ClaudeAuth);
|
||||||
|
assert_eq!(adapter.name(), "Claude");
|
||||||
|
|
||||||
|
let adapter = get_adapter_for_provider_type(&ProviderType::OpenRouter);
|
||||||
|
assert_eq!(adapter.name(), "Claude");
|
||||||
|
|
||||||
|
let adapter = get_adapter_for_provider_type(&ProviderType::Codex);
|
||||||
|
assert_eq!(adapter.name(), "Codex");
|
||||||
|
|
||||||
|
let adapter = get_adapter_for_provider_type(&ProviderType::Gemini);
|
||||||
|
assert_eq!(adapter.name(), "Gemini");
|
||||||
|
|
||||||
|
let adapter = get_adapter_for_provider_type(&ProviderType::GeminiCli);
|
||||||
|
assert_eq!(adapter.name(), "Gemini");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
//! Anthropic API 数据模型
|
||||||
|
//!
|
||||||
|
//! 用于 Anthropic Messages API 的请求/响应格式转换
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// Anthropic 请求
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AnthropicRequest {
|
||||||
|
pub model: String,
|
||||||
|
pub messages: Vec<AnthropicMessage>,
|
||||||
|
pub max_tokens: u32,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub system: Option<Value>, // 可以是 String 或 Vec<SystemBlock>
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub temperature: Option<f32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stream: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tools: Option<Vec<AnthropicTool>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_choice: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anthropic 消息
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AnthropicMessage {
|
||||||
|
pub role: String,
|
||||||
|
pub content: Value, // String 或 Vec<ContentBlock>
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anthropic 内容块
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum AnthropicContentBlock {
|
||||||
|
#[serde(rename = "text")]
|
||||||
|
Text { text: String },
|
||||||
|
#[serde(rename = "image")]
|
||||||
|
Image { source: ImageSource },
|
||||||
|
#[serde(rename = "tool_use")]
|
||||||
|
ToolUse {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
input: Value,
|
||||||
|
},
|
||||||
|
#[serde(rename = "tool_result")]
|
||||||
|
ToolResult { tool_use_id: String, content: Value },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图片来源
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ImageSource {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub source_type: String,
|
||||||
|
pub media_type: String,
|
||||||
|
pub data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anthropic 工具定义
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AnthropicTool {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub input_schema: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anthropic 响应
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AnthropicResponse {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub response_type: String,
|
||||||
|
pub role: String,
|
||||||
|
pub content: Vec<AnthropicResponseContent>,
|
||||||
|
pub model: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stop_reason: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stop_sequence: Option<String>,
|
||||||
|
pub usage: AnthropicUsage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anthropic 响应内容
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum AnthropicResponseContent {
|
||||||
|
#[serde(rename = "text")]
|
||||||
|
Text { text: String },
|
||||||
|
#[serde(rename = "tool_use")]
|
||||||
|
ToolUse {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
input: Value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anthropic 使用量
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AnthropicUsage {
|
||||||
|
pub input_tokens: u32,
|
||||||
|
pub output_tokens: u32,
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//! API 数据模型
|
||||||
|
//!
|
||||||
|
//! 定义 Anthropic 和 OpenAI API 的请求/响应结构
|
||||||
|
|
||||||
|
pub mod anthropic;
|
||||||
|
pub mod openai;
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
//! OpenAI API 数据模型
|
||||||
|
//!
|
||||||
|
//! 用于 OpenAI Chat Completions API 的请求/响应格式转换
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// OpenAI 请求
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAIRequest {
|
||||||
|
pub model: String,
|
||||||
|
pub messages: Vec<OpenAIMessage>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_tokens: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub temperature: Option<f32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stream: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tools: Option<Vec<OpenAITool>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_choice: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI 消息
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAIMessage {
|
||||||
|
pub role: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content: Option<Value>, // String 或 Vec<ContentPart>
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_calls: Option<Vec<OpenAIToolCall>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_call_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI 内容部分
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum OpenAIContentPart {
|
||||||
|
#[serde(rename = "text")]
|
||||||
|
Text { text: String },
|
||||||
|
#[serde(rename = "image_url")]
|
||||||
|
ImageUrl { image_url: ImageUrl },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图片 URL
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ImageUrl {
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI 工具调用
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAIToolCall {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub call_type: String,
|
||||||
|
pub function: OpenAIFunction,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI 函数
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAIFunction {
|
||||||
|
pub name: String,
|
||||||
|
pub arguments: String, // JSON 字符串
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI 工具定义
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAITool {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub tool_type: String,
|
||||||
|
pub function: OpenAIFunctionDef,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI 函数定义
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAIFunctionDef {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub parameters: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI 响应
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAIResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub object: String,
|
||||||
|
pub created: u64,
|
||||||
|
pub model: String,
|
||||||
|
pub choices: Vec<OpenAIChoice>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage: Option<OpenAIUsage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI 选择
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAIChoice {
|
||||||
|
pub index: u32,
|
||||||
|
pub message: OpenAIMessage,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub finish_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI 使用量
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAIUsage {
|
||||||
|
pub prompt_tokens: u32,
|
||||||
|
pub completion_tokens: u32,
|
||||||
|
pub total_tokens: u32,
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
//! 流式响应转换模块
|
||||||
|
//!
|
||||||
|
//! 实现 OpenAI SSE → Anthropic SSE 格式转换
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures::stream::{Stream, StreamExt};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// OpenAI 流式响应数据结构
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenAIStreamChunk {
|
||||||
|
id: String,
|
||||||
|
model: String,
|
||||||
|
choices: Vec<StreamChoice>,
|
||||||
|
#[serde(default)]
|
||||||
|
usage: Option<Usage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct StreamChoice {
|
||||||
|
delta: Delta,
|
||||||
|
#[serde(default)]
|
||||||
|
finish_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Delta {
|
||||||
|
#[serde(default)]
|
||||||
|
content: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
reasoning: Option<String>, // OpenRouter 的推理内容
|
||||||
|
#[serde(default)]
|
||||||
|
tool_calls: Option<Vec<DeltaToolCall>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
struct DeltaToolCall {
|
||||||
|
index: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
id: Option<String>,
|
||||||
|
#[serde(rename = "type", default)]
|
||||||
|
call_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
function: Option<DeltaFunction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
struct DeltaFunction {
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
arguments: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI 流式响应的 usage 信息(完整版)
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Usage {
|
||||||
|
#[serde(default)]
|
||||||
|
prompt_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
completion_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建 Anthropic SSE 流
|
||||||
|
pub fn create_anthropic_sse_stream(
|
||||||
|
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
|
||||||
|
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||||
|
async_stream::stream! {
|
||||||
|
let mut buffer = String::new();
|
||||||
|
let mut message_id = None;
|
||||||
|
let mut current_model = None;
|
||||||
|
let mut content_index = 0;
|
||||||
|
let mut has_sent_message_start = false;
|
||||||
|
let mut current_block_type: Option<String> = None;
|
||||||
|
let mut tool_call_id = None;
|
||||||
|
|
||||||
|
log::info!("[Claude/OpenRouter] ====== 开始流式响应转换 ======");
|
||||||
|
|
||||||
|
tokio::pin!(stream);
|
||||||
|
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
match chunk {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
|
buffer.push_str(&text);
|
||||||
|
|
||||||
|
while let Some(pos) = buffer.find("\n\n") {
|
||||||
|
let line = buffer[..pos].to_string();
|
||||||
|
buffer = buffer[pos + 2..].to_string();
|
||||||
|
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for l in line.lines() {
|
||||||
|
if let Some(data) = l.strip_prefix("data: ") {
|
||||||
|
if data.trim() == "[DONE]" {
|
||||||
|
log::info!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
|
||||||
|
let event = json!({"type": "message_stop"});
|
||||||
|
let sse_data = format!("event: message_stop\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&event).unwrap_or_default());
|
||||||
|
log::info!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
|
||||||
|
yield Ok(Bytes::from(sse_data));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
|
||||||
|
// 记录原始 OpenAI 事件(格式化显示)
|
||||||
|
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(data) {
|
||||||
|
log::info!(
|
||||||
|
"[Claude/OpenRouter] <<< OpenAI SSE 事件:\n{}",
|
||||||
|
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::info!("[Claude/OpenRouter] <<< OpenAI SSE 数据: {data}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if message_id.is_none() {
|
||||||
|
message_id = Some(chunk.id.clone());
|
||||||
|
}
|
||||||
|
if current_model.is_none() {
|
||||||
|
current_model = Some(chunk.model.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(choice) = chunk.choices.first() {
|
||||||
|
if !has_sent_message_start {
|
||||||
|
let event = json!({
|
||||||
|
"type": "message_start",
|
||||||
|
"message": {
|
||||||
|
"id": message_id.clone().unwrap_or_default(),
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": current_model.clone().unwrap_or_default(),
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let sse_data = format!("event: message_start\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&event).unwrap_or_default());
|
||||||
|
yield Ok(Bytes::from(sse_data));
|
||||||
|
has_sent_message_start = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 reasoning(thinking)
|
||||||
|
if let Some(reasoning) = &choice.delta.reasoning {
|
||||||
|
if current_block_type.is_none() {
|
||||||
|
let event = json!({
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": content_index,
|
||||||
|
"content_block": {
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&event).unwrap_or_default());
|
||||||
|
yield Ok(Bytes::from(sse_data));
|
||||||
|
current_block_type = Some("thinking".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let event = json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": content_index,
|
||||||
|
"delta": {
|
||||||
|
"type": "thinking_delta",
|
||||||
|
"thinking": reasoning
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&event).unwrap_or_default());
|
||||||
|
yield Ok(Bytes::from(sse_data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理文本内容
|
||||||
|
if let Some(content) = &choice.delta.content {
|
||||||
|
if !content.is_empty() {
|
||||||
|
if current_block_type.as_deref() != Some("text") {
|
||||||
|
if current_block_type.is_some() {
|
||||||
|
let event = json!({
|
||||||
|
"type": "content_block_stop",
|
||||||
|
"index": content_index
|
||||||
|
});
|
||||||
|
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&event).unwrap_or_default());
|
||||||
|
yield Ok(Bytes::from(sse_data));
|
||||||
|
content_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let event = json!({
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": content_index,
|
||||||
|
"content_block": {
|
||||||
|
"type": "text",
|
||||||
|
"text": ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&event).unwrap_or_default());
|
||||||
|
yield Ok(Bytes::from(sse_data));
|
||||||
|
current_block_type = Some("text".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let event = json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": content_index,
|
||||||
|
"delta": {
|
||||||
|
"type": "text_delta",
|
||||||
|
"text": content
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&event).unwrap_or_default());
|
||||||
|
yield Ok(Bytes::from(sse_data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理工具调用
|
||||||
|
if let Some(tool_calls) = &choice.delta.tool_calls {
|
||||||
|
for tool_call in tool_calls {
|
||||||
|
if let Some(id) = &tool_call.id {
|
||||||
|
if current_block_type.is_some() {
|
||||||
|
let event = json!({
|
||||||
|
"type": "content_block_stop",
|
||||||
|
"index": content_index
|
||||||
|
});
|
||||||
|
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&event).unwrap_or_default());
|
||||||
|
yield Ok(Bytes::from(sse_data));
|
||||||
|
content_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
tool_call_id = Some(id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(function) = &tool_call.function {
|
||||||
|
if let Some(name) = &function.name {
|
||||||
|
let event = json!({
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": content_index,
|
||||||
|
"content_block": {
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": tool_call_id.clone().unwrap_or_default(),
|
||||||
|
"name": name
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&event).unwrap_or_default());
|
||||||
|
yield Ok(Bytes::from(sse_data));
|
||||||
|
current_block_type = Some("tool_use".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(args) = &function.arguments {
|
||||||
|
let event = json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": content_index,
|
||||||
|
"delta": {
|
||||||
|
"type": "input_json_delta",
|
||||||
|
"partial_json": args
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&event).unwrap_or_default());
|
||||||
|
yield Ok(Bytes::from(sse_data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 finish_reason
|
||||||
|
if let Some(finish_reason) = &choice.finish_reason {
|
||||||
|
if current_block_type.is_some() {
|
||||||
|
let event = json!({
|
||||||
|
"type": "content_block_stop",
|
||||||
|
"index": content_index
|
||||||
|
});
|
||||||
|
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&event).unwrap_or_default());
|
||||||
|
yield Ok(Bytes::from(sse_data));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stop_reason = map_stop_reason(Some(finish_reason));
|
||||||
|
// 构建 usage 信息,包含 input_tokens 和 output_tokens
|
||||||
|
let usage_json = chunk.usage.as_ref().map(|u| json!({
|
||||||
|
"input_tokens": u.prompt_tokens,
|
||||||
|
"output_tokens": u.completion_tokens
|
||||||
|
}));
|
||||||
|
let event = json!({
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": {
|
||||||
|
"stop_reason": stop_reason,
|
||||||
|
"stop_sequence": null
|
||||||
|
},
|
||||||
|
"usage": usage_json
|
||||||
|
});
|
||||||
|
let sse_data = format!("event: message_delta\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&event).unwrap_or_default());
|
||||||
|
yield Ok(Bytes::from(sse_data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Stream error: {e}");
|
||||||
|
let error_event = json!({
|
||||||
|
"type": "error",
|
||||||
|
"error": {
|
||||||
|
"type": "stream_error",
|
||||||
|
"message": format!("Stream error: {e}")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let sse_data = format!("event: error\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&error_event).unwrap_or_default());
|
||||||
|
yield Ok(Bytes::from(sse_data));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 映射停止原因
|
||||||
|
fn map_stop_reason(finish_reason: Option<&str>) -> Option<String> {
|
||||||
|
finish_reason.map(|r| {
|
||||||
|
match r {
|
||||||
|
"tool_calls" => "tool_use",
|
||||||
|
"stop" => "end_turn",
|
||||||
|
"length" => "max_tokens",
|
||||||
|
_ => "end_turn",
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,640 @@
|
|||||||
|
//! 格式转换模块
|
||||||
|
//!
|
||||||
|
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
|
||||||
|
//! 参考: anthropic-proxy-rs
|
||||||
|
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::proxy::error::ProxyError;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
/// 从 Provider 配置中获取模型映射
|
||||||
|
fn get_model_from_provider(model: &str, provider: &Provider, body: &Value) -> String {
|
||||||
|
let env = provider.settings_config.get("env");
|
||||||
|
let model_lower = model.to_lowercase();
|
||||||
|
|
||||||
|
// 检测 thinking 参数
|
||||||
|
let has_thinking = body
|
||||||
|
.get("thinking")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|o| o.get("type"))
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
== Some("enabled");
|
||||||
|
|
||||||
|
if let Some(env) = env {
|
||||||
|
// 如果启用 thinking,优先使用推理模型
|
||||||
|
if has_thinking {
|
||||||
|
if let Some(m) = env
|
||||||
|
.get("ANTHROPIC_REASONING_MODEL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
log::debug!("[Transform] 使用推理模型: {m}");
|
||||||
|
return m.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据模型类型选择配置模型
|
||||||
|
if model_lower.contains("haiku") {
|
||||||
|
if let Some(m) = env
|
||||||
|
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
return m.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model_lower.contains("opus") {
|
||||||
|
if let Some(m) = env
|
||||||
|
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
return m.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model_lower.contains("sonnet") {
|
||||||
|
if let Some(m) = env
|
||||||
|
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
return m.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 默认使用 ANTHROPIC_MODEL
|
||||||
|
if let Some(m) = env.get("ANTHROPIC_MODEL").and_then(|v| v.as_str()) {
|
||||||
|
return m.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anthropic 请求 → OpenAI 请求
|
||||||
|
pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result<Value, ProxyError> {
|
||||||
|
let mut result = json!({});
|
||||||
|
|
||||||
|
// 模型映射:使用 Provider 配置中的模型(支持 thinking 参数)
|
||||||
|
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
|
||||||
|
let mapped_model = get_model_from_provider(model, provider, &body);
|
||||||
|
result["model"] = json!(mapped_model);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
|
||||||
|
// 处理 system prompt
|
||||||
|
if let Some(system) = body.get("system") {
|
||||||
|
if let Some(text) = system.as_str() {
|
||||||
|
// 单个字符串
|
||||||
|
messages.push(json!({"role": "system", "content": text}));
|
||||||
|
} else if let Some(arr) = system.as_array() {
|
||||||
|
// 多个 system message
|
||||||
|
for msg in arr {
|
||||||
|
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
|
||||||
|
messages.push(json!({"role": "system", "content": text}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 messages
|
||||||
|
if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) {
|
||||||
|
for msg in msgs {
|
||||||
|
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
|
||||||
|
let content = msg.get("content");
|
||||||
|
let converted = convert_message_to_openai(role, content)?;
|
||||||
|
messages.extend(converted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result["messages"] = json!(messages);
|
||||||
|
|
||||||
|
// 转换参数
|
||||||
|
if let Some(v) = body.get("max_tokens") {
|
||||||
|
result["max_tokens"] = v.clone();
|
||||||
|
}
|
||||||
|
if let Some(v) = body.get("temperature") {
|
||||||
|
result["temperature"] = v.clone();
|
||||||
|
}
|
||||||
|
if let Some(v) = body.get("top_p") {
|
||||||
|
result["top_p"] = v.clone();
|
||||||
|
}
|
||||||
|
if let Some(v) = body.get("stop_sequences") {
|
||||||
|
result["stop"] = v.clone();
|
||||||
|
}
|
||||||
|
if let Some(v) = body.get("stream") {
|
||||||
|
result["stream"] = v.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 tools (过滤 BatchTool)
|
||||||
|
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
|
||||||
|
let openai_tools: Vec<Value> = tools
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
|
||||||
|
.map(|t| {
|
||||||
|
json!({
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
|
||||||
|
"description": t.get("description"),
|
||||||
|
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !openai_tools.is_empty() {
|
||||||
|
result["tools"] = json!(openai_tools);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(v) = body.get("tool_choice") {
|
||||||
|
result["tool_choice"] = v.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
|
||||||
|
fn convert_message_to_openai(
|
||||||
|
role: &str,
|
||||||
|
content: Option<&Value>,
|
||||||
|
) -> Result<Vec<Value>, ProxyError> {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
|
||||||
|
let content = match content {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
result.push(json!({"role": role, "content": null}));
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 字符串内容
|
||||||
|
if let Some(text) = content.as_str() {
|
||||||
|
result.push(json!({"role": role, "content": text}));
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数组内容(多模态/工具调用)
|
||||||
|
if let Some(blocks) = content.as_array() {
|
||||||
|
let mut content_parts = Vec::new();
|
||||||
|
let mut tool_calls = Vec::new();
|
||||||
|
|
||||||
|
for block in blocks {
|
||||||
|
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
match block_type {
|
||||||
|
"text" => {
|
||||||
|
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||||
|
content_parts.push(json!({"type": "text", "text": text}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"image" => {
|
||||||
|
if let Some(source) = block.get("source") {
|
||||||
|
let media_type = source
|
||||||
|
.get("media_type")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.unwrap_or("image/png");
|
||||||
|
let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
|
||||||
|
content_parts.push(json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": format!("data:{};base64,{}", media_type, data)}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"tool_use" => {
|
||||||
|
let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||||
|
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||||
|
let input = block.get("input").cloned().unwrap_or(json!({}));
|
||||||
|
tool_calls.push(json!({
|
||||||
|
"id": id,
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": name,
|
||||||
|
"arguments": serde_json::to_string(&input).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
"tool_result" => {
|
||||||
|
// tool_result 变成单独的 tool role 消息
|
||||||
|
let tool_use_id = block
|
||||||
|
.get("tool_use_id")
|
||||||
|
.and_then(|i| i.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let content_val = block.get("content");
|
||||||
|
let content_str = match content_val {
|
||||||
|
Some(Value::String(s)) => s.clone(),
|
||||||
|
Some(v) => serde_json::to_string(v).unwrap_or_default(),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
result.push(json!({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tool_use_id,
|
||||||
|
"content": content_str
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
"thinking" => {
|
||||||
|
// 跳过 thinking blocks
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加带内容和/或工具调用的消息
|
||||||
|
if !content_parts.is_empty() || !tool_calls.is_empty() {
|
||||||
|
let mut msg = json!({"role": role});
|
||||||
|
|
||||||
|
// 内容处理
|
||||||
|
if content_parts.is_empty() {
|
||||||
|
msg["content"] = Value::Null;
|
||||||
|
} else if content_parts.len() == 1 {
|
||||||
|
if let Some(text) = content_parts[0].get("text") {
|
||||||
|
msg["content"] = text.clone();
|
||||||
|
} else {
|
||||||
|
msg["content"] = json!(content_parts);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
msg["content"] = json!(content_parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具调用
|
||||||
|
if !tool_calls.is_empty() {
|
||||||
|
msg["tool_calls"] = json!(tool_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他情况直接透传
|
||||||
|
result.push(json!({"role": role, "content": content}));
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理 JSON schema(移除不支持的 format)
|
||||||
|
fn clean_schema(mut schema: Value) -> Value {
|
||||||
|
if let Some(obj) = schema.as_object_mut() {
|
||||||
|
// 移除 "format": "uri"
|
||||||
|
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
|
||||||
|
obj.remove("format");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归清理嵌套 schema
|
||||||
|
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
|
||||||
|
for (_, value) in properties.iter_mut() {
|
||||||
|
*value = clean_schema(value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(items) = obj.get_mut("items") {
|
||||||
|
*items = clean_schema(items.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schema
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI 响应 → Anthropic 响应
|
||||||
|
pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||||
|
let choices = body
|
||||||
|
.get("choices")
|
||||||
|
.and_then(|c| c.as_array())
|
||||||
|
.ok_or_else(|| ProxyError::TransformError("No choices in response".to_string()))?;
|
||||||
|
|
||||||
|
let choice = choices
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| ProxyError::TransformError("Empty choices array".to_string()))?;
|
||||||
|
|
||||||
|
let message = choice
|
||||||
|
.get("message")
|
||||||
|
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
|
||||||
|
|
||||||
|
let mut content = Vec::new();
|
||||||
|
|
||||||
|
// 文本内容
|
||||||
|
if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
|
||||||
|
if !text.is_empty() {
|
||||||
|
content.push(json!({"type": "text", "text": text}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具调用
|
||||||
|
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
|
||||||
|
for tc in tool_calls {
|
||||||
|
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||||
|
let empty_obj = json!({});
|
||||||
|
let func = tc.get("function").unwrap_or(&empty_obj);
|
||||||
|
let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||||
|
let args_str = func
|
||||||
|
.get("arguments")
|
||||||
|
.and_then(|a| a.as_str())
|
||||||
|
.unwrap_or("{}");
|
||||||
|
let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
|
||||||
|
|
||||||
|
content.push(json!({
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"input": input
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 映射 finish_reason → stop_reason
|
||||||
|
let stop_reason = choice
|
||||||
|
.get("finish_reason")
|
||||||
|
.and_then(|r| r.as_str())
|
||||||
|
.map(|r| match r {
|
||||||
|
"stop" => "end_turn",
|
||||||
|
"length" => "max_tokens",
|
||||||
|
"tool_calls" => "tool_use",
|
||||||
|
other => other,
|
||||||
|
});
|
||||||
|
|
||||||
|
// usage
|
||||||
|
let usage = body.get("usage").cloned().unwrap_or(json!({}));
|
||||||
|
let input_tokens = usage
|
||||||
|
.get("prompt_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32;
|
||||||
|
let output_tokens = usage
|
||||||
|
.get("completion_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32;
|
||||||
|
|
||||||
|
let result = json!({
|
||||||
|
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": content,
|
||||||
|
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
|
||||||
|
"stop_reason": stop_reason,
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": input_tokens,
|
||||||
|
"output_tokens": output_tokens
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn create_provider(env_config: Value) -> Provider {
|
||||||
|
Provider {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: "Test Provider".to_string(),
|
||||||
|
settings_config: json!({"env": env_config}),
|
||||||
|
website_url: None,
|
||||||
|
category: None,
|
||||||
|
created_at: None,
|
||||||
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
|
meta: None,
|
||||||
|
icon: None,
|
||||||
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_openrouter_provider() -> Provider {
|
||||||
|
create_provider(json!({
|
||||||
|
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||||||
|
"ANTHROPIC_MODEL": "anthropic/claude-sonnet-4.5",
|
||||||
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4.5",
|
||||||
|
"ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-4.5",
|
||||||
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-4.5"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_openai_simple() {
|
||||||
|
let provider = create_openrouter_provider();
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"messages": [{"role": "user", "content": "Hello"}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||||
|
// opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||||
|
assert_eq!(result["model"], "anthropic/claude-opus-4.5");
|
||||||
|
assert_eq!(result["max_tokens"], 1024);
|
||||||
|
assert_eq!(result["messages"][0]["role"], "user");
|
||||||
|
assert_eq!(result["messages"][0]["content"], "Hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_openai_with_system() {
|
||||||
|
let provider = create_openrouter_provider();
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-3-sonnet",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"system": "You are a helpful assistant.",
|
||||||
|
"messages": [{"role": "user", "content": "Hello"}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||||
|
assert_eq!(result["messages"][0]["role"], "system");
|
||||||
|
assert_eq!(
|
||||||
|
result["messages"][0]["content"],
|
||||||
|
"You are a helpful assistant."
|
||||||
|
);
|
||||||
|
assert_eq!(result["messages"][1]["role"], "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_openai_with_tools() {
|
||||||
|
let provider = create_openrouter_provider();
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"messages": [{"role": "user", "content": "What's the weather?"}],
|
||||||
|
"tools": [{
|
||||||
|
"name": "get_weather",
|
||||||
|
"description": "Get weather info",
|
||||||
|
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||||
|
assert_eq!(result["tools"][0]["type"], "function");
|
||||||
|
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_openai_tool_use() {
|
||||||
|
let provider = create_openrouter_provider();
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Let me check"},
|
||||||
|
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||||
|
let msg = &result["messages"][0];
|
||||||
|
assert_eq!(msg["role"], "assistant");
|
||||||
|
assert!(msg.get("tool_calls").is_some());
|
||||||
|
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_openai_tool_result() {
|
||||||
|
let provider = create_openrouter_provider();
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "tool_result", "tool_use_id": "call_123", "content": "Sunny, 25°C"}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||||
|
let msg = &result["messages"][0];
|
||||||
|
assert_eq!(msg["role"], "tool");
|
||||||
|
assert_eq!(msg["tool_call_id"], "call_123");
|
||||||
|
assert_eq!(msg["content"], "Sunny, 25°C");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_openai_to_anthropic_simple() {
|
||||||
|
let input = json!({
|
||||||
|
"id": "chatcmpl-123",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1234567890,
|
||||||
|
"model": "gpt-4",
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": "Hello!"},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = openai_to_anthropic(input).unwrap();
|
||||||
|
assert_eq!(result["id"], "chatcmpl-123");
|
||||||
|
assert_eq!(result["type"], "message");
|
||||||
|
assert_eq!(result["content"][0]["type"], "text");
|
||||||
|
assert_eq!(result["content"][0]["text"], "Hello!");
|
||||||
|
assert_eq!(result["stop_reason"], "end_turn");
|
||||||
|
assert_eq!(result["usage"]["input_tokens"], 10);
|
||||||
|
assert_eq!(result["usage"]["output_tokens"], 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_openai_to_anthropic_with_tool_calls() {
|
||||||
|
let input = json!({
|
||||||
|
"id": "chatcmpl-123",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1234567890,
|
||||||
|
"model": "gpt-4",
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": null,
|
||||||
|
"tool_calls": [{
|
||||||
|
"id": "call_123",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "get_weather", "arguments": "{\"location\": \"Tokyo\"}"}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls"
|
||||||
|
}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = openai_to_anthropic(input).unwrap();
|
||||||
|
assert_eq!(result["content"][0]["type"], "tool_use");
|
||||||
|
assert_eq!(result["content"][0]["id"], "call_123");
|
||||||
|
assert_eq!(result["content"][0]["name"], "get_weather");
|
||||||
|
assert_eq!(result["content"][0]["input"]["location"], "Tokyo");
|
||||||
|
assert_eq!(result["stop_reason"], "tool_use");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_model_mapping_from_provider() {
|
||||||
|
let provider = create_openrouter_provider();
|
||||||
|
let body = json!({"model": "test"});
|
||||||
|
|
||||||
|
// sonnet 模型
|
||||||
|
assert_eq!(
|
||||||
|
get_model_from_provider("claude-sonnet-4-5-20250929", &provider, &body),
|
||||||
|
"anthropic/claude-sonnet-4.5"
|
||||||
|
);
|
||||||
|
|
||||||
|
// haiku 模型
|
||||||
|
assert_eq!(
|
||||||
|
get_model_from_provider("claude-haiku-4-5-20250929", &provider, &body),
|
||||||
|
"anthropic/claude-haiku-4.5"
|
||||||
|
);
|
||||||
|
|
||||||
|
// opus 模型
|
||||||
|
assert_eq!(
|
||||||
|
get_model_from_provider("claude-opus-4-5", &provider, &body),
|
||||||
|
"anthropic/claude-opus-4.5"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_openai_model_mapping() {
|
||||||
|
let provider = create_openrouter_provider();
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-sonnet-4-5-20250929",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"messages": [{"role": "user", "content": "Hello"}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||||
|
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_thinking_parameter_detection() {
|
||||||
|
let mut provider = create_openrouter_provider();
|
||||||
|
// 添加推理模型配置
|
||||||
|
if let Some(env) = provider.settings_config.get_mut("env") {
|
||||||
|
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
|
||||||
|
}
|
||||||
|
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"thinking": {"type": "enabled"},
|
||||||
|
"messages": [{"role": "user", "content": "Solve this problem"}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||||
|
// 应该使用推理模型
|
||||||
|
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5:extended");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_thinking_parameter_disabled() {
|
||||||
|
let mut provider = create_openrouter_provider();
|
||||||
|
if let Some(env) = provider.settings_config.get_mut("env") {
|
||||||
|
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
|
||||||
|
}
|
||||||
|
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"thinking": {"type": "disabled"},
|
||||||
|
"messages": [{"role": "user", "content": "Hello"}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||||
|
// 应该使用普通模型
|
||||||
|
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
//! Response Handler - 统一响应处理
|
||||||
|
//!
|
||||||
|
//! 提供流式和非流式响应的统一处理接口
|
||||||
|
|
||||||
|
use super::session::ProxySession;
|
||||||
|
use super::usage::parser::TokenUsage;
|
||||||
|
use super::ProxyError;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures::stream::{Stream, StreamExt};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
/// 响应类型
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum ResponseType {
|
||||||
|
/// 流式响应 (SSE)
|
||||||
|
Stream,
|
||||||
|
/// 非流式响应
|
||||||
|
NonStream,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseType {
|
||||||
|
/// 从 Content-Type 检测响应类型
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn from_content_type(content_type: &str) -> Self {
|
||||||
|
if content_type.contains("text/event-stream") {
|
||||||
|
ResponseType::Stream
|
||||||
|
} else {
|
||||||
|
ResponseType::NonStream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式响应处理器
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct StreamHandler {
|
||||||
|
/// 空闲超时时间
|
||||||
|
idle_timeout: Duration,
|
||||||
|
/// 收集的事件
|
||||||
|
events: Arc<Mutex<Vec<Value>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl StreamHandler {
|
||||||
|
/// 创建新的流式处理器
|
||||||
|
pub fn new(idle_timeout_secs: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
idle_timeout: Duration::from_secs(idle_timeout_secs),
|
||||||
|
events: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理流式响应,返回分流后的客户端流
|
||||||
|
///
|
||||||
|
/// 客户端流立即返回,内部流在后台收集事件
|
||||||
|
pub fn handle_stream<S>(
|
||||||
|
&self,
|
||||||
|
stream: S,
|
||||||
|
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send
|
||||||
|
where
|
||||||
|
S: Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
|
||||||
|
{
|
||||||
|
let events = self.events.clone();
|
||||||
|
let idle_timeout = self.idle_timeout;
|
||||||
|
|
||||||
|
async_stream::stream! {
|
||||||
|
let mut _last_activity = Instant::now();
|
||||||
|
let mut buffer = String::new();
|
||||||
|
|
||||||
|
tokio::pin!(stream);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let chunk_result = timeout(idle_timeout, stream.next()).await;
|
||||||
|
|
||||||
|
match chunk_result {
|
||||||
|
Ok(Some(Ok(bytes))) => {
|
||||||
|
_last_activity = Instant::now();
|
||||||
|
|
||||||
|
// 解析 SSE 事件
|
||||||
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
|
buffer.push_str(&text);
|
||||||
|
|
||||||
|
// 提取完整事件
|
||||||
|
while let Some(pos) = buffer.find("\n\n") {
|
||||||
|
let event_text = buffer[..pos].to_string();
|
||||||
|
buffer = buffer[pos + 2..].to_string();
|
||||||
|
|
||||||
|
for line in event_text.lines() {
|
||||||
|
if let Some(data) = line.strip_prefix("data: ") {
|
||||||
|
if data.trim() != "[DONE]" {
|
||||||
|
if let Ok(json) = serde_json::from_str::<Value>(data) {
|
||||||
|
let mut guard = events.lock().await;
|
||||||
|
guard.push(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield Ok(bytes);
|
||||||
|
}
|
||||||
|
Ok(Some(Err(e))) => {
|
||||||
|
log::error!("流错误: {e}");
|
||||||
|
yield Err(std::io::Error::other(e.to_string()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
// 流结束
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// 空闲超时
|
||||||
|
log::warn!("流式响应空闲超时: {idle_timeout:?} 无数据");
|
||||||
|
yield Err(std::io::Error::other("Stream idle timeout"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取收集的事件
|
||||||
|
pub async fn get_events(&self) -> Vec<Value> {
|
||||||
|
let guard = self.events.lock().await;
|
||||||
|
guard.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从收集的事件中提取 Token 使用量
|
||||||
|
pub async fn extract_usage(&self, session: &ProxySession) -> Option<TokenUsage> {
|
||||||
|
let events = self.get_events().await;
|
||||||
|
|
||||||
|
match session.client_format {
|
||||||
|
super::session::ClientFormat::Claude => TokenUsage::from_claude_stream_events(&events),
|
||||||
|
super::session::ClientFormat::Codex => TokenUsage::from_codex_stream_events(&events),
|
||||||
|
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
|
||||||
|
TokenUsage::from_gemini_stream_chunks(&events)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非流式响应处理器
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct NonStreamHandler;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl NonStreamHandler {
|
||||||
|
/// 处理非流式响应
|
||||||
|
///
|
||||||
|
/// 克隆响应体用于后台解析,原始响应立即返回
|
||||||
|
pub async fn handle_response(
|
||||||
|
body: &[u8],
|
||||||
|
session: &ProxySession,
|
||||||
|
) -> Result<Option<TokenUsage>, ProxyError> {
|
||||||
|
let json: Value = serde_json::from_slice(body)
|
||||||
|
.map_err(|e| ProxyError::TransformError(format!("Failed to parse response: {e}")))?;
|
||||||
|
|
||||||
|
let usage = match session.client_format {
|
||||||
|
super::session::ClientFormat::Claude => TokenUsage::from_claude_response(&json),
|
||||||
|
super::session::ClientFormat::Codex => TokenUsage::from_codex_response_adjusted(&json),
|
||||||
|
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
|
||||||
|
TokenUsage::from_gemini_response(&json)
|
||||||
|
}
|
||||||
|
super::session::ClientFormat::OpenAI => TokenUsage::from_openrouter_response(&json),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统一响应分发器
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct ResponseDispatcher;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl ResponseDispatcher {
|
||||||
|
/// 判断响应类型
|
||||||
|
pub fn detect_type(content_type: &str) -> ResponseType {
|
||||||
|
ResponseType::from_content_type(content_type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_response_type_detection() {
|
||||||
|
assert_eq!(
|
||||||
|
ResponseType::from_content_type("text/event-stream"),
|
||||||
|
ResponseType::Stream
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ResponseType::from_content_type("text/event-stream; charset=utf-8"),
|
||||||
|
ResponseType::Stream
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ResponseType::from_content_type("application/json"),
|
||||||
|
ResponseType::NonStream
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stream_handler_creation() {
|
||||||
|
let handler = StreamHandler::new(30);
|
||||||
|
assert_eq!(handler.idle_timeout, Duration::from_secs(30));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
//! 响应处理器模块
|
||||||
|
//!
|
||||||
|
//! 统一处理流式和非流式 API 响应
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
handler_config::UsageParserConfig,
|
||||||
|
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||||
|
server::ProxyState,
|
||||||
|
usage::parser::TokenUsage,
|
||||||
|
ProxyError,
|
||||||
|
};
|
||||||
|
use axum::response::Response;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures::stream::{Stream, StreamExt};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::{
|
||||||
|
str::FromStr,
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 公共接口
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 检测响应是否为 SSE 流式响应
|
||||||
|
#[inline]
|
||||||
|
pub fn is_sse_response(response: &reqwest::Response) -> bool {
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get("content-type")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|ct| ct.contains("text/event-stream"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理流式响应
|
||||||
|
pub async fn handle_streaming(
|
||||||
|
response: reqwest::Response,
|
||||||
|
ctx: &RequestContext,
|
||||||
|
state: &ProxyState,
|
||||||
|
parser_config: &UsageParserConfig,
|
||||||
|
) -> Response {
|
||||||
|
log::info!("[{}] 流式透传响应 (SSE)", ctx.tag);
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let mut builder = axum::response::Response::builder().status(status);
|
||||||
|
|
||||||
|
// 复制响应头
|
||||||
|
for (key, value) in response.headers() {
|
||||||
|
builder = builder.header(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建字节流
|
||||||
|
let stream = response
|
||||||
|
.bytes_stream()
|
||||||
|
.map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string())));
|
||||||
|
|
||||||
|
// 创建使用量收集器
|
||||||
|
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
|
||||||
|
|
||||||
|
// 获取流式超时配置
|
||||||
|
let timeout_config = ctx.streaming_timeout_config();
|
||||||
|
|
||||||
|
// 创建带日志和超时的透传流
|
||||||
|
let logged_stream =
|
||||||
|
create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector), timeout_config);
|
||||||
|
|
||||||
|
let body = axum::body::Body::from_stream(logged_stream);
|
||||||
|
builder.body(body).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理非流式响应
|
||||||
|
pub async fn handle_non_streaming(
|
||||||
|
response: reqwest::Response,
|
||||||
|
ctx: &RequestContext,
|
||||||
|
state: &ProxyState,
|
||||||
|
parser_config: &UsageParserConfig,
|
||||||
|
) -> Result<Response, ProxyError> {
|
||||||
|
let response_headers = response.headers().clone();
|
||||||
|
let status = response.status();
|
||||||
|
|
||||||
|
// 读取响应体
|
||||||
|
let body_bytes = response.bytes().await.map_err(|e| {
|
||||||
|
log::error!("[{}] 读取响应失败: {e}", ctx.tag);
|
||||||
|
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 解析并记录使用量
|
||||||
|
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
|
||||||
|
log::info!(
|
||||||
|
"[{}] <<< 响应 JSON:\n{}",
|
||||||
|
ctx.tag,
|
||||||
|
serde_json::to_string_pretty(&json_value).unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 解析使用量
|
||||||
|
if let Some(usage) = (parser_config.response_parser)(&json_value) {
|
||||||
|
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
|
||||||
|
let model = if let Some(ref m) = usage.model {
|
||||||
|
m.clone()
|
||||||
|
} else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) {
|
||||||
|
m.to_string()
|
||||||
|
} else {
|
||||||
|
ctx.request_model.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false);
|
||||||
|
} else {
|
||||||
|
log::debug!(
|
||||||
|
"[{}] 未能解析 usage 信息,跳过记录",
|
||||||
|
parser_config.app_type_str
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"[{}] <<< 响应 (非 JSON): {} bytes",
|
||||||
|
ctx.tag,
|
||||||
|
body_bytes.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[{}] ====== 请求结束 ======", ctx.tag);
|
||||||
|
|
||||||
|
// 构建响应
|
||||||
|
let mut builder = axum::response::Response::builder().status(status);
|
||||||
|
for (key, value) in response_headers.iter() {
|
||||||
|
builder = builder.header(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = axum::body::Body::from(body_bytes);
|
||||||
|
Ok(builder.body(body).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通用响应处理入口
|
||||||
|
///
|
||||||
|
/// 根据响应类型自动选择流式或非流式处理
|
||||||
|
pub async fn process_response(
|
||||||
|
response: reqwest::Response,
|
||||||
|
ctx: &RequestContext,
|
||||||
|
state: &ProxyState,
|
||||||
|
parser_config: &UsageParserConfig,
|
||||||
|
) -> Result<Response, ProxyError> {
|
||||||
|
if is_sse_response(&response) {
|
||||||
|
Ok(handle_streaming(response, ctx, state, parser_config).await)
|
||||||
|
} else {
|
||||||
|
handle_non_streaming(response, ctx, state, parser_config).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSE 使用量收集器
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type UsageCallbackWithTiming = Arc<dyn Fn(Vec<Value>, Option<u64>) + Send + Sync + 'static>;
|
||||||
|
|
||||||
|
/// SSE 使用量收集器
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SseUsageCollector {
|
||||||
|
inner: Arc<SseUsageCollectorInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SseUsageCollectorInner {
|
||||||
|
events: Mutex<Vec<Value>>,
|
||||||
|
first_event_time: Mutex<Option<std::time::Instant>>,
|
||||||
|
start_time: std::time::Instant,
|
||||||
|
on_complete: UsageCallbackWithTiming,
|
||||||
|
finished: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SseUsageCollector {
|
||||||
|
/// 创建新的使用量收集器
|
||||||
|
pub fn new(
|
||||||
|
start_time: std::time::Instant,
|
||||||
|
callback: impl Fn(Vec<Value>, Option<u64>) + Send + Sync + 'static,
|
||||||
|
) -> Self {
|
||||||
|
let on_complete: UsageCallbackWithTiming = Arc::new(callback);
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(SseUsageCollectorInner {
|
||||||
|
events: Mutex::new(Vec::new()),
|
||||||
|
first_event_time: Mutex::new(None),
|
||||||
|
start_time,
|
||||||
|
on_complete,
|
||||||
|
finished: AtomicBool::new(false),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 推送 SSE 事件
|
||||||
|
pub async fn push(&self, event: Value) {
|
||||||
|
// 记录首个事件时间
|
||||||
|
{
|
||||||
|
let mut first_time = self.inner.first_event_time.lock().await;
|
||||||
|
if first_time.is_none() {
|
||||||
|
*first_time = Some(std::time::Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut events = self.inner.events.lock().await;
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 完成收集并触发回调
|
||||||
|
pub async fn finish(&self) {
|
||||||
|
if self.inner.finished.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let events = {
|
||||||
|
let mut guard = self.inner.events.lock().await;
|
||||||
|
std::mem::take(&mut *guard)
|
||||||
|
};
|
||||||
|
|
||||||
|
let first_token_ms = {
|
||||||
|
let first_time = self.inner.first_event_time.lock().await;
|
||||||
|
first_time.map(|t| (t - self.inner.start_time).as_millis() as u64)
|
||||||
|
};
|
||||||
|
|
||||||
|
(self.inner.on_complete)(events, first_token_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 内部辅助函数
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 创建使用量收集器
|
||||||
|
fn create_usage_collector(
|
||||||
|
ctx: &RequestContext,
|
||||||
|
state: &ProxyState,
|
||||||
|
status_code: u16,
|
||||||
|
parser_config: &UsageParserConfig,
|
||||||
|
) -> SseUsageCollector {
|
||||||
|
let state = state.clone();
|
||||||
|
let provider_id = ctx.provider.id.clone();
|
||||||
|
let request_model = ctx.request_model.clone();
|
||||||
|
let app_type_str = parser_config.app_type_str;
|
||||||
|
let tag = ctx.tag;
|
||||||
|
let start_time = ctx.start_time;
|
||||||
|
let stream_parser = parser_config.stream_parser;
|
||||||
|
let model_extractor = parser_config.model_extractor;
|
||||||
|
|
||||||
|
SseUsageCollector::new(start_time, move |events, first_token_ms| {
|
||||||
|
if let Some(usage) = stream_parser(&events) {
|
||||||
|
let model = model_extractor(&events, &request_model);
|
||||||
|
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
let state = state.clone();
|
||||||
|
let provider_id = provider_id.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
log_usage_internal(
|
||||||
|
&state,
|
||||||
|
&provider_id,
|
||||||
|
app_type_str,
|
||||||
|
&model,
|
||||||
|
usage,
|
||||||
|
latency_ms,
|
||||||
|
first_token_ms,
|
||||||
|
true, // is_streaming
|
||||||
|
status_code,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log::debug!("[{tag}] 流式响应缺少 usage 统计,跳过消费记录");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 异步记录使用量
|
||||||
|
fn spawn_log_usage(
|
||||||
|
state: &ProxyState,
|
||||||
|
ctx: &RequestContext,
|
||||||
|
usage: TokenUsage,
|
||||||
|
model: &str,
|
||||||
|
status_code: u16,
|
||||||
|
is_streaming: bool,
|
||||||
|
) {
|
||||||
|
let state = state.clone();
|
||||||
|
let provider_id = ctx.provider.id.clone();
|
||||||
|
let app_type_str = ctx.app_type_str.to_string();
|
||||||
|
let model = model.to_string();
|
||||||
|
let latency_ms = ctx.latency_ms();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
log_usage_internal(
|
||||||
|
&state,
|
||||||
|
&provider_id,
|
||||||
|
&app_type_str,
|
||||||
|
&model,
|
||||||
|
usage,
|
||||||
|
latency_ms,
|
||||||
|
None,
|
||||||
|
is_streaming,
|
||||||
|
status_code,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内部使用量记录函数
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn log_usage_internal(
|
||||||
|
state: &ProxyState,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
model: &str,
|
||||||
|
usage: TokenUsage,
|
||||||
|
latency_ms: u64,
|
||||||
|
first_token_ms: Option<u64>,
|
||||||
|
is_streaming: bool,
|
||||||
|
status_code: u16,
|
||||||
|
) {
|
||||||
|
use super::usage::logger::UsageLogger;
|
||||||
|
|
||||||
|
let logger = UsageLogger::new(&state.db);
|
||||||
|
|
||||||
|
// 获取 provider 的 cost_multiplier
|
||||||
|
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
|
||||||
|
Ok(Some(p)) => {
|
||||||
|
if let Some(meta) = p.meta {
|
||||||
|
if let Some(cm) = meta.cost_multiplier {
|
||||||
|
Decimal::from_str(&cm).unwrap_or(Decimal::from(1))
|
||||||
|
} else {
|
||||||
|
Decimal::from(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Decimal::from(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Decimal::from(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
let request_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
if let Err(e) = logger.log_with_calculation(
|
||||||
|
request_id,
|
||||||
|
provider_id.to_string(),
|
||||||
|
app_type.to_string(),
|
||||||
|
model.to_string(),
|
||||||
|
usage,
|
||||||
|
multiplier,
|
||||||
|
latency_ms,
|
||||||
|
first_token_ms,
|
||||||
|
status_code,
|
||||||
|
None,
|
||||||
|
None, // provider_type
|
||||||
|
is_streaming,
|
||||||
|
) {
|
||||||
|
log::warn!("记录使用量失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建带日志记录和超时控制的透传流
|
||||||
|
pub fn create_logged_passthrough_stream(
|
||||||
|
stream: impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
|
||||||
|
tag: &'static str,
|
||||||
|
usage_collector: Option<SseUsageCollector>,
|
||||||
|
timeout_config: StreamingTimeoutConfig,
|
||||||
|
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||||
|
async_stream::stream! {
|
||||||
|
let mut buffer = String::new();
|
||||||
|
let mut collector = usage_collector;
|
||||||
|
let mut is_first_chunk = true;
|
||||||
|
|
||||||
|
// 超时配置
|
||||||
|
let first_byte_timeout = if timeout_config.first_byte_timeout > 0 {
|
||||||
|
Some(Duration::from_secs(timeout_config.first_byte_timeout))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let idle_timeout = if timeout_config.idle_timeout > 0 {
|
||||||
|
Some(Duration::from_secs(timeout_config.idle_timeout))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::pin!(stream);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// 选择超时时间:首字节超时或静默期超时
|
||||||
|
let timeout_duration = if is_first_chunk {
|
||||||
|
first_byte_timeout
|
||||||
|
} else {
|
||||||
|
idle_timeout
|
||||||
|
};
|
||||||
|
|
||||||
|
let chunk_result = match timeout_duration {
|
||||||
|
Some(duration) => {
|
||||||
|
match tokio::time::timeout(duration, stream.next()).await {
|
||||||
|
Ok(Some(chunk)) => Some(chunk),
|
||||||
|
Ok(None) => None, // 流结束
|
||||||
|
Err(_) => {
|
||||||
|
// 超时
|
||||||
|
let timeout_type = if is_first_chunk { "首字节" } else { "静默期" };
|
||||||
|
log::error!("[{tag}] 流式响应{}超时 ({}秒)", timeout_type, duration.as_secs());
|
||||||
|
yield Err(std::io::Error::other(format!("流式响应{timeout_type}超时")));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => stream.next().await, // 无超时限制
|
||||||
|
};
|
||||||
|
|
||||||
|
match chunk_result {
|
||||||
|
Some(Ok(bytes)) => {
|
||||||
|
is_first_chunk = false;
|
||||||
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
|
buffer.push_str(&text);
|
||||||
|
|
||||||
|
// 尝试解析并记录完整的 SSE 事件
|
||||||
|
while let Some(pos) = buffer.find("\n\n") {
|
||||||
|
let event_text = buffer[..pos].to_string();
|
||||||
|
buffer = buffer[pos + 2..].to_string();
|
||||||
|
|
||||||
|
if !event_text.trim().is_empty() {
|
||||||
|
// 提取 data 部分并尝试解析为 JSON
|
||||||
|
for line in event_text.lines() {
|
||||||
|
if let Some(data) = line.strip_prefix("data: ") {
|
||||||
|
if data.trim() != "[DONE]" {
|
||||||
|
if let Ok(json_value) = serde_json::from_str::<Value>(data) {
|
||||||
|
if let Some(c) = &collector {
|
||||||
|
c.push(json_value.clone()).await;
|
||||||
|
}
|
||||||
|
log::info!(
|
||||||
|
"[{}] <<< SSE 事件:\n{}",
|
||||||
|
tag,
|
||||||
|
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::info!("[{tag}] <<< SSE 数据: {data}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::info!("[{tag}] <<< SSE: [DONE]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield Ok(bytes);
|
||||||
|
}
|
||||||
|
Some(Err(e)) => {
|
||||||
|
log::error!("[{tag}] 流错误: {e}");
|
||||||
|
yield Err(std::io::Error::other(e.to_string()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// 流正常结束
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[{}] ====== 流结束 ======", tag);
|
||||||
|
|
||||||
|
if let Some(c) = collector.take() {
|
||||||
|
c.finish().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
//! HTTP代理服务器
|
||||||
|
//!
|
||||||
|
//! 基于Axum的HTTP服务器,处理代理请求
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
failover_switch::FailoverSwitchManager, handlers, provider_router::ProviderRouter, types::*,
|
||||||
|
ProxyError,
|
||||||
|
};
|
||||||
|
use crate::database::Database;
|
||||||
|
use axum::{
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{oneshot, RwLock};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
|
/// 代理服务器状态(共享)
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ProxyState {
|
||||||
|
pub db: Arc<Database>,
|
||||||
|
pub config: Arc<RwLock<ProxyConfig>>,
|
||||||
|
pub status: Arc<RwLock<ProxyStatus>>,
|
||||||
|
pub start_time: Arc<RwLock<Option<std::time::Instant>>>,
|
||||||
|
/// 每个应用类型当前使用的 provider (app_type -> (provider_id, provider_name))
|
||||||
|
pub current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||||
|
/// 共享的 ProviderRouter(持有熔断器状态,跨请求保持)
|
||||||
|
pub provider_router: Arc<ProviderRouter>,
|
||||||
|
/// AppHandle,用于发射事件和更新托盘菜单
|
||||||
|
pub app_handle: Option<tauri::AppHandle>,
|
||||||
|
/// 故障转移切换管理器
|
||||||
|
pub failover_manager: Arc<FailoverSwitchManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 代理HTTP服务器
|
||||||
|
pub struct ProxyServer {
|
||||||
|
config: ProxyConfig,
|
||||||
|
state: ProxyState,
|
||||||
|
shutdown_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
|
||||||
|
/// 服务器任务句柄,用于等待服务器实际关闭
|
||||||
|
server_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyServer {
|
||||||
|
pub fn new(
|
||||||
|
config: ProxyConfig,
|
||||||
|
db: Arc<Database>,
|
||||||
|
app_handle: Option<tauri::AppHandle>,
|
||||||
|
) -> Self {
|
||||||
|
// 创建共享的 ProviderRouter(熔断器状态将跨所有请求保持)
|
||||||
|
let provider_router = Arc::new(ProviderRouter::new(db.clone()));
|
||||||
|
// 创建故障转移切换管理器
|
||||||
|
let failover_manager = Arc::new(FailoverSwitchManager::new(db.clone()));
|
||||||
|
|
||||||
|
let state = ProxyState {
|
||||||
|
db,
|
||||||
|
config: Arc::new(RwLock::new(config.clone())),
|
||||||
|
status: Arc::new(RwLock::new(ProxyStatus::default())),
|
||||||
|
start_time: Arc::new(RwLock::new(None)),
|
||||||
|
current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||||
|
provider_router,
|
||||||
|
app_handle,
|
||||||
|
failover_manager,
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
state,
|
||||||
|
shutdown_tx: Arc::new(RwLock::new(None)),
|
||||||
|
server_handle: Arc::new(RwLock::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(&self) -> Result<ProxyServerInfo, ProxyError> {
|
||||||
|
// 检查是否已在运行
|
||||||
|
if self.shutdown_tx.read().await.is_some() {
|
||||||
|
return Err(ProxyError::AlreadyRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
let addr: SocketAddr =
|
||||||
|
format!("{}:{}", self.config.listen_address, self.config.listen_port)
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| ProxyError::BindFailed(format!("无效的地址: {e}")))?;
|
||||||
|
|
||||||
|
// 创建关闭通道
|
||||||
|
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||||
|
|
||||||
|
// 构建路由
|
||||||
|
let app = self.build_router();
|
||||||
|
|
||||||
|
// 绑定监听器
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ProxyError::BindFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
log::info!("代理服务器启动于 {addr}");
|
||||||
|
|
||||||
|
// 保存关闭句柄
|
||||||
|
*self.shutdown_tx.write().await = Some(shutdown_tx);
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
let mut status = self.state.status.write().await;
|
||||||
|
status.running = true;
|
||||||
|
status.address = self.config.listen_address.clone();
|
||||||
|
status.port = self.config.listen_port;
|
||||||
|
drop(status);
|
||||||
|
|
||||||
|
// 记录启动时间
|
||||||
|
*self.state.start_time.write().await = Some(std::time::Instant::now());
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
let state = self.state.clone();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(async {
|
||||||
|
shutdown_rx.await.ok();
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
// 服务器停止后更新状态
|
||||||
|
state.status.write().await.running = false;
|
||||||
|
*state.start_time.write().await = None;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存服务器任务句柄
|
||||||
|
*self.server_handle.write().await = Some(handle);
|
||||||
|
|
||||||
|
Ok(ProxyServerInfo {
|
||||||
|
address: self.config.listen_address.clone(),
|
||||||
|
port: self.config.listen_port,
|
||||||
|
started_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(&self) -> Result<(), ProxyError> {
|
||||||
|
// 1. 发送关闭信号
|
||||||
|
if let Some(tx) = self.shutdown_tx.write().await.take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
} else {
|
||||||
|
return Err(ProxyError::NotRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 等待服务器任务结束(带 5 秒超时保护)
|
||||||
|
if let Some(handle) = self.server_handle.write().await.take() {
|
||||||
|
match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await {
|
||||||
|
Ok(Ok(())) => log::info!("代理服务器已完全停止"),
|
||||||
|
Ok(Err(e)) => log::warn!("代理服务器任务异常终止: {e}"),
|
||||||
|
Err(_) => log::warn!("代理服务器停止超时(5秒),强制继续"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_status(&self) -> ProxyStatus {
|
||||||
|
let mut status = self.state.status.read().await.clone();
|
||||||
|
|
||||||
|
// 计算运行时间
|
||||||
|
if let Some(start) = *self.state.start_time.read().await {
|
||||||
|
status.uptime_seconds = start.elapsed().as_secs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 current_providers HashMap 获取每个应用类型当前正在使用的 provider
|
||||||
|
let current_providers = self.state.current_providers.read().await;
|
||||||
|
status.active_targets = current_providers
|
||||||
|
.iter()
|
||||||
|
.map(|(app_type, (provider_id, provider_name))| ActiveTarget {
|
||||||
|
app_type: app_type.clone(),
|
||||||
|
provider_id: provider_id.clone(),
|
||||||
|
provider_name: provider_name.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_router(&self) -> Router {
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any);
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
// 健康检查
|
||||||
|
.route("/health", get(handlers::health_check))
|
||||||
|
.route("/status", get(handlers::get_status))
|
||||||
|
// Claude API (支持带前缀和不带前缀两种格式)
|
||||||
|
.route("/v1/messages", post(handlers::handle_messages))
|
||||||
|
.route("/claude/v1/messages", post(handlers::handle_messages))
|
||||||
|
// OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀)
|
||||||
|
.route("/chat/completions", post(handlers::handle_chat_completions))
|
||||||
|
.route(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
post(handlers::handle_chat_completions),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/v1/v1/chat/completions",
|
||||||
|
post(handlers::handle_chat_completions),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/codex/v1/chat/completions",
|
||||||
|
post(handlers::handle_chat_completions),
|
||||||
|
)
|
||||||
|
// OpenAI Responses API (Codex CLI,支持带前缀和不带前缀)
|
||||||
|
.route("/responses", post(handlers::handle_responses))
|
||||||
|
.route("/v1/responses", post(handlers::handle_responses))
|
||||||
|
.route("/v1/v1/responses", post(handlers::handle_responses))
|
||||||
|
.route("/codex/v1/responses", post(handlers::handle_responses))
|
||||||
|
// Gemini API (支持带前缀和不带前缀)
|
||||||
|
.route("/v1beta/*path", post(handlers::handle_gemini))
|
||||||
|
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
|
||||||
|
.layer(cors)
|
||||||
|
.with_state(self.state.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在不重启服务的情况下更新运行时配置
|
||||||
|
pub async fn apply_runtime_config(&self, config: &ProxyConfig) {
|
||||||
|
*self.state.config.write().await = config.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 热更新熔断器配置
|
||||||
|
///
|
||||||
|
/// 将新配置应用到所有已创建的熔断器实例
|
||||||
|
pub async fn update_circuit_breaker_configs(
|
||||||
|
&self,
|
||||||
|
config: super::circuit_breaker::CircuitBreakerConfig,
|
||||||
|
) {
|
||||||
|
self.state.provider_router.update_all_configs(config).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置指定 Provider 的熔断器
|
||||||
|
pub async fn reset_provider_circuit_breaker(&self, provider_id: &str, app_type: &str) {
|
||||||
|
self.state
|
||||||
|
.provider_router
|
||||||
|
.reset_provider_breaker(provider_id, app_type)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
//! Proxy Session - 请求会话管理
|
||||||
|
//!
|
||||||
|
//! 为每个代理请求创建会话上下文,在整个请求生命周期中跟踪状态和元数据。
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// 客户端请求格式
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum ClientFormat {
|
||||||
|
/// Claude Messages API (/v1/messages)
|
||||||
|
Claude,
|
||||||
|
/// Codex Response API (/v1/responses)
|
||||||
|
Codex,
|
||||||
|
/// OpenAI Chat Completions API (/v1/chat/completions)
|
||||||
|
OpenAI,
|
||||||
|
/// Gemini API (/v1beta/models/*/generateContent)
|
||||||
|
Gemini,
|
||||||
|
/// Gemini CLI API (/v1internal/models/*/generateContent)
|
||||||
|
GeminiCli,
|
||||||
|
/// 未知格式
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl ClientFormat {
|
||||||
|
/// 从请求路径检测格式
|
||||||
|
pub fn from_path(path: &str) -> Self {
|
||||||
|
if path.contains("/v1/messages") {
|
||||||
|
ClientFormat::Claude
|
||||||
|
} else if path.contains("/v1/responses") {
|
||||||
|
ClientFormat::Codex
|
||||||
|
} else if path.contains("/v1/chat/completions") {
|
||||||
|
ClientFormat::OpenAI
|
||||||
|
} else if path.contains("/v1internal/") && path.contains("generateContent") {
|
||||||
|
// Gemini CLI 使用 /v1internal/ 路径
|
||||||
|
ClientFormat::GeminiCli
|
||||||
|
} else if (path.contains("/v1beta/") || path.contains("/v1/"))
|
||||||
|
&& path.contains("generateContent")
|
||||||
|
{
|
||||||
|
// Gemini API 使用 /v1beta/ 或 /v1/ 路径
|
||||||
|
ClientFormat::Gemini
|
||||||
|
} else if path.contains("generateContent") {
|
||||||
|
// 通用 Gemini 端点
|
||||||
|
ClientFormat::Gemini
|
||||||
|
} else {
|
||||||
|
ClientFormat::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从请求体内容检测格式(回退方案)
|
||||||
|
pub fn from_body(body: &serde_json::Value) -> Self {
|
||||||
|
// Claude 格式特征: messages 数组 + model 字段 + 无 response_format
|
||||||
|
if body.get("messages").is_some()
|
||||||
|
&& body.get("model").is_some()
|
||||||
|
&& body.get("response_format").is_none()
|
||||||
|
&& body.get("contents").is_none()
|
||||||
|
{
|
||||||
|
// 区分 Claude 和 OpenAI
|
||||||
|
if body.get("max_tokens").is_some() {
|
||||||
|
return ClientFormat::Claude;
|
||||||
|
}
|
||||||
|
return ClientFormat::OpenAI;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codex 格式特征: input 字段
|
||||||
|
if body.get("input").is_some() {
|
||||||
|
return ClientFormat::Codex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini 格式特征: contents 数组
|
||||||
|
if body.get("contents").is_some() {
|
||||||
|
return ClientFormat::Gemini;
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientFormat::Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转换为字符串
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ClientFormat::Claude => "claude",
|
||||||
|
ClientFormat::Codex => "codex",
|
||||||
|
ClientFormat::OpenAI => "openai",
|
||||||
|
ClientFormat::Gemini => "gemini",
|
||||||
|
ClientFormat::GeminiCli => "gemini_cli",
|
||||||
|
ClientFormat::Unknown => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ClientFormat {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 代理会话
|
||||||
|
///
|
||||||
|
/// 包含请求全生命周期的上下文数据
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct ProxySession {
|
||||||
|
/// 唯一会话 ID
|
||||||
|
pub session_id: String,
|
||||||
|
/// 请求开始时间
|
||||||
|
pub start_time: Instant,
|
||||||
|
/// HTTP 方法
|
||||||
|
pub method: String,
|
||||||
|
/// 请求 URL
|
||||||
|
pub request_url: String,
|
||||||
|
/// User-Agent
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
/// 客户端请求格式
|
||||||
|
pub client_format: ClientFormat,
|
||||||
|
/// 选定的供应商 ID
|
||||||
|
pub provider_id: Option<String>,
|
||||||
|
/// 模型名称
|
||||||
|
pub model: Option<String>,
|
||||||
|
/// 是否为流式请求
|
||||||
|
pub is_streaming: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl ProxySession {
|
||||||
|
/// 从请求创建会话
|
||||||
|
pub fn from_request(
|
||||||
|
method: &str,
|
||||||
|
request_url: &str,
|
||||||
|
user_agent: Option<&str>,
|
||||||
|
body: Option<&serde_json::Value>,
|
||||||
|
) -> Self {
|
||||||
|
// 检测客户端格式
|
||||||
|
let mut client_format = ClientFormat::from_path(request_url);
|
||||||
|
if client_format == ClientFormat::Unknown {
|
||||||
|
if let Some(body) = body {
|
||||||
|
client_format = ClientFormat::from_body(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否为流式请求
|
||||||
|
let is_streaming = body
|
||||||
|
.and_then(|b| b.get("stream"))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// 提取模型名称
|
||||||
|
let model = body
|
||||||
|
.and_then(|b| b.get("model"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
session_id: Uuid::new_v4().to_string(),
|
||||||
|
start_time: Instant::now(),
|
||||||
|
method: method.to_string(),
|
||||||
|
request_url: request_url.to_string(),
|
||||||
|
user_agent: user_agent.map(|s| s.to_string()),
|
||||||
|
client_format,
|
||||||
|
provider_id: None,
|
||||||
|
model,
|
||||||
|
is_streaming,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置供应商 ID
|
||||||
|
pub fn with_provider(mut self, provider_id: &str) -> Self {
|
||||||
|
self.provider_id = Some(provider_id.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取请求延迟(毫秒)
|
||||||
|
pub fn latency_ms(&self) -> u64 {
|
||||||
|
self.start_time.elapsed().as_millis() as u64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_format_from_path_claude() {
|
||||||
|
assert_eq!(
|
||||||
|
ClientFormat::from_path("/v1/messages"),
|
||||||
|
ClientFormat::Claude
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ClientFormat::from_path("/api/v1/messages"),
|
||||||
|
ClientFormat::Claude
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_format_from_path_codex() {
|
||||||
|
assert_eq!(
|
||||||
|
ClientFormat::from_path("/v1/responses"),
|
||||||
|
ClientFormat::Codex
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_format_from_path_openai() {
|
||||||
|
assert_eq!(
|
||||||
|
ClientFormat::from_path("/v1/chat/completions"),
|
||||||
|
ClientFormat::OpenAI
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_format_from_path_gemini() {
|
||||||
|
assert_eq!(
|
||||||
|
ClientFormat::from_path("/v1beta/models/gemini-pro:generateContent"),
|
||||||
|
ClientFormat::Gemini
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_format_from_path_gemini_cli() {
|
||||||
|
assert_eq!(
|
||||||
|
ClientFormat::from_path("/v1internal/models/gemini-pro:generateContent"),
|
||||||
|
ClientFormat::GeminiCli
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_format_from_body_claude() {
|
||||||
|
let body = json!({
|
||||||
|
"model": "claude-3-5-sonnet",
|
||||||
|
"messages": [{"role": "user", "content": "Hello"}],
|
||||||
|
"max_tokens": 1024
|
||||||
|
});
|
||||||
|
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Claude);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_format_from_body_codex() {
|
||||||
|
let body = json!({
|
||||||
|
"input": "Write a function"
|
||||||
|
});
|
||||||
|
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Codex);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_format_from_body_gemini() {
|
||||||
|
let body = json!({
|
||||||
|
"contents": [{"parts": [{"text": "Hello"}]}]
|
||||||
|
});
|
||||||
|
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Gemini);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_id_uniqueness() {
|
||||||
|
let session1 = ProxySession::from_request("POST", "/v1/messages", None, None);
|
||||||
|
let session2 = ProxySession::from_request("POST", "/v1/messages", None, None);
|
||||||
|
assert_ne!(session1.session_id, session2.session_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_from_request() {
|
||||||
|
let body = json!({
|
||||||
|
"model": "claude-3-5-sonnet",
|
||||||
|
"messages": [{"role": "user", "content": "Hello"}],
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"stream": true
|
||||||
|
});
|
||||||
|
|
||||||
|
let session =
|
||||||
|
ProxySession::from_request("POST", "/v1/messages", Some("Mozilla/5.0"), Some(&body));
|
||||||
|
|
||||||
|
assert_eq!(session.method, "POST");
|
||||||
|
assert_eq!(session.request_url, "/v1/messages");
|
||||||
|
assert_eq!(session.user_agent, Some("Mozilla/5.0".to_string()));
|
||||||
|
assert_eq!(session.client_format, ClientFormat::Claude);
|
||||||
|
assert_eq!(session.model, Some("claude-3-5-sonnet".to_string()));
|
||||||
|
assert!(session.is_streaming);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_with_provider() {
|
||||||
|
let session = ProxySession::from_request("POST", "/v1/messages", None, None)
|
||||||
|
.with_provider("provider-123");
|
||||||
|
|
||||||
|
assert_eq!(session.provider_id, Some("provider-123".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_format_as_str() {
|
||||||
|
assert_eq!(ClientFormat::Claude.as_str(), "claude");
|
||||||
|
assert_eq!(ClientFormat::Codex.as_str(), "codex");
|
||||||
|
assert_eq!(ClientFormat::OpenAI.as_str(), "openai");
|
||||||
|
assert_eq!(ClientFormat::Gemini.as_str(), "gemini");
|
||||||
|
assert_eq!(ClientFormat::GeminiCli.as_str(), "gemini_cli");
|
||||||
|
assert_eq!(ClientFormat::Unknown.as_str(), "unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 代理服务器配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProxyConfig {
|
||||||
|
/// 监听地址
|
||||||
|
pub listen_address: String,
|
||||||
|
/// 监听端口
|
||||||
|
pub listen_port: u16,
|
||||||
|
/// 最大重试次数
|
||||||
|
pub max_retries: u8,
|
||||||
|
/// 请求超时时间(秒)- 已废弃,保留兼容
|
||||||
|
pub request_timeout: u64,
|
||||||
|
/// 是否启用日志
|
||||||
|
pub enable_logging: bool,
|
||||||
|
/// 是否正在接管 Live 配置
|
||||||
|
#[serde(default)]
|
||||||
|
pub live_takeover_active: bool,
|
||||||
|
/// 流式首字超时(秒)- 等待首个数据块的最大时间
|
||||||
|
#[serde(default = "default_streaming_first_byte_timeout")]
|
||||||
|
pub streaming_first_byte_timeout: u64,
|
||||||
|
/// 流式静默超时(秒)- 两个数据块之间的最大间隔
|
||||||
|
#[serde(default = "default_streaming_idle_timeout")]
|
||||||
|
pub streaming_idle_timeout: u64,
|
||||||
|
/// 非流式总超时(秒)- 非流式请求的总超时时间
|
||||||
|
#[serde(default = "default_non_streaming_timeout")]
|
||||||
|
pub non_streaming_timeout: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_streaming_first_byte_timeout() -> u64 {
|
||||||
|
30
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_streaming_idle_timeout() -> u64 {
|
||||||
|
60
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_non_streaming_timeout() -> u64 {
|
||||||
|
600
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProxyConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
listen_address: "127.0.0.1".to_string(),
|
||||||
|
listen_port: 15721, // 使用较少占用的高位端口
|
||||||
|
max_retries: 3,
|
||||||
|
request_timeout: 300,
|
||||||
|
enable_logging: true,
|
||||||
|
live_takeover_active: false,
|
||||||
|
streaming_first_byte_timeout: 30,
|
||||||
|
streaming_idle_timeout: 60,
|
||||||
|
non_streaming_timeout: 600,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 代理服务器状态
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ProxyStatus {
|
||||||
|
/// 是否运行中
|
||||||
|
pub running: bool,
|
||||||
|
/// 监听地址
|
||||||
|
pub address: String,
|
||||||
|
/// 监听端口
|
||||||
|
pub port: u16,
|
||||||
|
/// 活跃连接数
|
||||||
|
pub active_connections: usize,
|
||||||
|
/// 总请求数
|
||||||
|
pub total_requests: u64,
|
||||||
|
/// 成功请求数
|
||||||
|
pub success_requests: u64,
|
||||||
|
/// 失败请求数
|
||||||
|
pub failed_requests: u64,
|
||||||
|
/// 成功率 (0-100)
|
||||||
|
pub success_rate: f32,
|
||||||
|
/// 运行时间(秒)
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
/// 当前使用的Provider名称
|
||||||
|
pub current_provider: Option<String>,
|
||||||
|
/// 当前Provider的ID
|
||||||
|
pub current_provider_id: Option<String>,
|
||||||
|
/// 最后一次请求时间
|
||||||
|
pub last_request_at: Option<String>,
|
||||||
|
/// 最后一次错误信息
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
/// Provider故障转移次数
|
||||||
|
pub failover_count: u64,
|
||||||
|
/// 当前活跃的代理目标列表
|
||||||
|
#[serde(default)]
|
||||||
|
pub active_targets: Vec<ActiveTarget>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 活跃的代理目标信息
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ActiveTarget {
|
||||||
|
pub app_type: String, // "Claude" | "Codex" | "Gemini"
|
||||||
|
pub provider_name: String,
|
||||||
|
pub provider_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 代理服务器信息
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProxyServerInfo {
|
||||||
|
pub address: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub started_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ProxyTakeoverStatus {
|
||||||
|
pub claude: bool,
|
||||||
|
pub codex: bool,
|
||||||
|
pub gemini: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API 格式类型(预留,当前不需要格式转换)
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum ApiFormat {
|
||||||
|
Claude,
|
||||||
|
OpenAI,
|
||||||
|
Gemini,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider健康状态
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProviderHealth {
|
||||||
|
pub provider_id: String,
|
||||||
|
pub app_type: String,
|
||||||
|
pub is_healthy: bool,
|
||||||
|
pub consecutive_failures: u32,
|
||||||
|
pub last_success_at: Option<String>,
|
||||||
|
pub last_failure_at: Option<String>,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live 配置备份记录
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LiveBackup {
|
||||||
|
/// 应用类型 (claude/codex/gemini)
|
||||||
|
pub app_type: String,
|
||||||
|
/// 原始配置 JSON
|
||||||
|
pub original_config: String,
|
||||||
|
/// 备份时间
|
||||||
|
pub backed_up_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全局代理配置(统一字段,三行镜像)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GlobalProxyConfig {
|
||||||
|
/// 代理总开关
|
||||||
|
pub proxy_enabled: bool,
|
||||||
|
/// 监听地址
|
||||||
|
pub listen_address: String,
|
||||||
|
/// 监听端口
|
||||||
|
pub listen_port: u16,
|
||||||
|
/// 是否启用日志
|
||||||
|
pub enable_logging: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用级代理配置(每个 app 独立)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AppProxyConfig {
|
||||||
|
/// 应用类型 (claude/codex/gemini)
|
||||||
|
pub app_type: String,
|
||||||
|
/// 该 app 代理启用开关
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 该 app 自动故障转移开关
|
||||||
|
pub auto_failover_enabled: bool,
|
||||||
|
/// 最大重试次数
|
||||||
|
pub max_retries: u32,
|
||||||
|
/// 流式首字超时(秒)
|
||||||
|
pub streaming_first_byte_timeout: u32,
|
||||||
|
/// 流式静默超时(秒)
|
||||||
|
pub streaming_idle_timeout: u32,
|
||||||
|
/// 非流式总超时(秒)
|
||||||
|
pub non_streaming_timeout: u32,
|
||||||
|
/// 熔断失败阈值
|
||||||
|
pub circuit_failure_threshold: u32,
|
||||||
|
/// 熔断恢复阈值
|
||||||
|
pub circuit_success_threshold: u32,
|
||||||
|
/// 熔断恢复等待时间(秒)
|
||||||
|
pub circuit_timeout_seconds: u32,
|
||||||
|
/// 错误率阈值
|
||||||
|
pub circuit_error_rate_threshold: f64,
|
||||||
|
/// 计算错误率的最小请求数
|
||||||
|
pub circuit_min_requests: u32,
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
//! Cost Calculator - 计算 API 请求成本
|
||||||
|
//!
|
||||||
|
//! 使用高精度 Decimal 类型避免浮点数精度问题
|
||||||
|
|
||||||
|
use super::parser::TokenUsage;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// 成本明细
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CostBreakdown {
|
||||||
|
pub input_cost: Decimal,
|
||||||
|
pub output_cost: Decimal,
|
||||||
|
pub cache_read_cost: Decimal,
|
||||||
|
pub cache_creation_cost: Decimal,
|
||||||
|
pub total_cost: Decimal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模型定价信息
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ModelPricing {
|
||||||
|
pub input_cost_per_million: Decimal,
|
||||||
|
pub output_cost_per_million: Decimal,
|
||||||
|
pub cache_read_cost_per_million: Decimal,
|
||||||
|
pub cache_creation_cost_per_million: Decimal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 成本计算器
|
||||||
|
pub struct CostCalculator;
|
||||||
|
|
||||||
|
impl CostCalculator {
|
||||||
|
/// 计算请求成本
|
||||||
|
///
|
||||||
|
/// # 参数
|
||||||
|
/// - `usage`: Token 使用量
|
||||||
|
/// - `pricing`: 模型定价
|
||||||
|
/// - `cost_multiplier`: 成本倍数 (provider 自定义)
|
||||||
|
pub fn calculate(
|
||||||
|
usage: &TokenUsage,
|
||||||
|
pricing: &ModelPricing,
|
||||||
|
cost_multiplier: Decimal,
|
||||||
|
) -> CostBreakdown {
|
||||||
|
let million = Decimal::from(1_000_000);
|
||||||
|
|
||||||
|
let input_cost = Decimal::from(usage.input_tokens) * pricing.input_cost_per_million
|
||||||
|
/ million
|
||||||
|
* cost_multiplier;
|
||||||
|
let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million
|
||||||
|
/ million
|
||||||
|
* cost_multiplier;
|
||||||
|
let cache_read_cost =
|
||||||
|
Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million
|
||||||
|
* cost_multiplier;
|
||||||
|
let cache_creation_cost = Decimal::from(usage.cache_creation_tokens)
|
||||||
|
* pricing.cache_creation_cost_per_million
|
||||||
|
/ million
|
||||||
|
* cost_multiplier;
|
||||||
|
|
||||||
|
let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost;
|
||||||
|
|
||||||
|
CostBreakdown {
|
||||||
|
input_cost,
|
||||||
|
output_cost,
|
||||||
|
cache_read_cost,
|
||||||
|
cache_creation_cost,
|
||||||
|
total_cost,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 尝试计算成本,如果模型未知则返回 None
|
||||||
|
pub fn try_calculate(
|
||||||
|
usage: &TokenUsage,
|
||||||
|
pricing: Option<&ModelPricing>,
|
||||||
|
cost_multiplier: Decimal,
|
||||||
|
) -> Option<CostBreakdown> {
|
||||||
|
pricing.map(|p| Self::calculate(usage, p, cost_multiplier))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelPricing {
|
||||||
|
/// 从字符串创建定价信息
|
||||||
|
pub fn from_strings(
|
||||||
|
input: &str,
|
||||||
|
output: &str,
|
||||||
|
cache_read: &str,
|
||||||
|
cache_creation: &str,
|
||||||
|
) -> Result<Self, rust_decimal::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
input_cost_per_million: Decimal::from_str(input)?,
|
||||||
|
output_cost_per_million: Decimal::from_str(output)?,
|
||||||
|
cache_read_cost_per_million: Decimal::from_str(cache_read)?,
|
||||||
|
cache_creation_cost_per_million: Decimal::from_str(cache_creation)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cost_calculation() {
|
||||||
|
let usage = TokenUsage {
|
||||||
|
input_tokens: 1000,
|
||||||
|
output_tokens: 500,
|
||||||
|
cache_read_tokens: 200,
|
||||||
|
cache_creation_tokens: 100,
|
||||||
|
model: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pricing = ModelPricing::from_strings("3.0", "15.0", "0.3", "3.75").unwrap();
|
||||||
|
let multiplier = Decimal::from_str("1.0").unwrap();
|
||||||
|
|
||||||
|
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
||||||
|
|
||||||
|
// input: 1000 * 3.0 / 1M = 0.003
|
||||||
|
assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap());
|
||||||
|
// output: 500 * 15.0 / 1M = 0.0075
|
||||||
|
assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap());
|
||||||
|
// cache_read: 200 * 0.3 / 1M = 0.00006
|
||||||
|
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.00006").unwrap());
|
||||||
|
// cache_creation: 100 * 3.75 / 1M = 0.000375
|
||||||
|
assert_eq!(
|
||||||
|
cost.cache_creation_cost,
|
||||||
|
Decimal::from_str("0.000375").unwrap()
|
||||||
|
);
|
||||||
|
// total: 0.003 + 0.0075 + 0.00006 + 0.000375 = 0.010935
|
||||||
|
assert_eq!(cost.total_cost, Decimal::from_str("0.010935").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cost_multiplier() {
|
||||||
|
let usage = TokenUsage {
|
||||||
|
input_tokens: 1000,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
model: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pricing = ModelPricing::from_strings("3.0", "15.0", "0", "0").unwrap();
|
||||||
|
let multiplier = Decimal::from_str("1.5").unwrap();
|
||||||
|
|
||||||
|
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
||||||
|
|
||||||
|
// input: 1000 * 3.0 / 1M * 1.5 = 0.0045
|
||||||
|
assert_eq!(cost.input_cost, Decimal::from_str("0.0045").unwrap());
|
||||||
|
assert_eq!(cost.total_cost, Decimal::from_str("0.0045").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unknown_model_handling() {
|
||||||
|
let usage = TokenUsage {
|
||||||
|
input_tokens: 1000,
|
||||||
|
output_tokens: 500,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
model: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let multiplier = Decimal::from_str("1.0").unwrap();
|
||||||
|
let cost = CostCalculator::try_calculate(&usage, None, multiplier);
|
||||||
|
|
||||||
|
assert!(cost.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decimal_precision() {
|
||||||
|
let usage = TokenUsage {
|
||||||
|
input_tokens: 1,
|
||||||
|
output_tokens: 1,
|
||||||
|
cache_read_tokens: 1,
|
||||||
|
cache_creation_tokens: 1,
|
||||||
|
model: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pricing = ModelPricing::from_strings("0.075", "0.3", "0.01875", "0.075").unwrap();
|
||||||
|
let multiplier = Decimal::from_str("1.0").unwrap();
|
||||||
|
|
||||||
|
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
||||||
|
|
||||||
|
// 验证高精度计算
|
||||||
|
assert!(cost.total_cost > Decimal::ZERO);
|
||||||
|
assert!(cost.total_cost.to_string().len() > 2); // 确保保留了小数位
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
//! Usage Logger - 记录 API 请求使用情况
|
||||||
|
|
||||||
|
use super::calculator::{CostBreakdown, CostCalculator, ModelPricing};
|
||||||
|
use super::parser::TokenUsage;
|
||||||
|
use crate::database::Database;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::usage_stats::find_model_pricing_row;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
/// 请求日志
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RequestLog {
|
||||||
|
pub request_id: String,
|
||||||
|
pub provider_id: String,
|
||||||
|
pub app_type: String,
|
||||||
|
pub model: String,
|
||||||
|
pub usage: TokenUsage,
|
||||||
|
pub cost: Option<CostBreakdown>,
|
||||||
|
pub latency_ms: u64,
|
||||||
|
pub first_token_ms: Option<u64>,
|
||||||
|
pub status_code: u16,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
/// 供应商类型 (claude, claude_auth, codex, gemini, gemini_cli, openrouter)
|
||||||
|
pub provider_type: Option<String>,
|
||||||
|
/// 是否为流式请求
|
||||||
|
pub is_streaming: bool,
|
||||||
|
/// 成本倍数
|
||||||
|
pub cost_multiplier: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用量记录器
|
||||||
|
pub struct UsageLogger<'a> {
|
||||||
|
db: &'a Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> UsageLogger<'a> {
|
||||||
|
pub fn new(db: &'a Database) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录成功的请求
|
||||||
|
pub fn log_request(&self, log: &RequestLog) -> Result<(), AppError> {
|
||||||
|
let conn = crate::database::lock_conn!(self.db.conn);
|
||||||
|
|
||||||
|
let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) =
|
||||||
|
if let Some(cost) = &log.cost {
|
||||||
|
(
|
||||||
|
cost.input_cost.to_string(),
|
||||||
|
cost.output_cost.to_string(),
|
||||||
|
cost.cache_read_cost.to_string(),
|
||||||
|
cost.cache_creation_cost.to_string(),
|
||||||
|
cost.total_cost.to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"0".to_string(),
|
||||||
|
"0".to_string(),
|
||||||
|
"0".to_string(),
|
||||||
|
"0".to_string(),
|
||||||
|
"0".to_string(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let created_at = SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO proxy_request_logs (
|
||||||
|
request_id, provider_id, app_type, model,
|
||||||
|
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||||
|
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||||
|
latency_ms, first_token_ms, status_code, error_message, session_id,
|
||||||
|
provider_type, is_streaming, cost_multiplier, created_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)",
|
||||||
|
rusqlite::params![
|
||||||
|
log.request_id,
|
||||||
|
log.provider_id,
|
||||||
|
log.app_type,
|
||||||
|
log.model,
|
||||||
|
log.usage.input_tokens,
|
||||||
|
log.usage.output_tokens,
|
||||||
|
log.usage.cache_read_tokens,
|
||||||
|
log.usage.cache_creation_tokens,
|
||||||
|
input_cost,
|
||||||
|
output_cost,
|
||||||
|
cache_read_cost,
|
||||||
|
cache_creation_cost,
|
||||||
|
total_cost,
|
||||||
|
log.latency_ms as i64,
|
||||||
|
log.first_token_ms.map(|v| v as i64),
|
||||||
|
log.status_code as i64,
|
||||||
|
log.error_message,
|
||||||
|
log.session_id,
|
||||||
|
log.provider_type,
|
||||||
|
log.is_streaming as i64,
|
||||||
|
log.cost_multiplier,
|
||||||
|
created_at,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("记录请求日志失败: {e}")))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录失败的请求
|
||||||
|
///
|
||||||
|
/// 用于记录无法从上游获取 usage 信息的失败请求
|
||||||
|
#[allow(dead_code, clippy::too_many_arguments)]
|
||||||
|
pub fn log_error(
|
||||||
|
&self,
|
||||||
|
request_id: String,
|
||||||
|
provider_id: String,
|
||||||
|
app_type: String,
|
||||||
|
model: String,
|
||||||
|
status_code: u16,
|
||||||
|
error_message: String,
|
||||||
|
latency_ms: u64,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let log = RequestLog {
|
||||||
|
request_id,
|
||||||
|
provider_id,
|
||||||
|
app_type,
|
||||||
|
model,
|
||||||
|
usage: TokenUsage::default(),
|
||||||
|
cost: None,
|
||||||
|
latency_ms,
|
||||||
|
first_token_ms: None,
|
||||||
|
status_code,
|
||||||
|
error_message: Some(error_message),
|
||||||
|
session_id: None,
|
||||||
|
provider_type: None,
|
||||||
|
is_streaming: false,
|
||||||
|
cost_multiplier: "1.0".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.log_request(&log)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录失败的请求(带更多上下文信息)
|
||||||
|
///
|
||||||
|
/// 相比 log_error,这个方法接受更多参数以提供完整的请求上下文
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn log_error_with_context(
|
||||||
|
&self,
|
||||||
|
request_id: String,
|
||||||
|
provider_id: String,
|
||||||
|
app_type: String,
|
||||||
|
model: String,
|
||||||
|
status_code: u16,
|
||||||
|
error_message: String,
|
||||||
|
latency_ms: u64,
|
||||||
|
is_streaming: bool,
|
||||||
|
session_id: Option<String>,
|
||||||
|
provider_type: Option<String>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let log = RequestLog {
|
||||||
|
request_id,
|
||||||
|
provider_id,
|
||||||
|
app_type,
|
||||||
|
model,
|
||||||
|
usage: TokenUsage::default(),
|
||||||
|
cost: None,
|
||||||
|
latency_ms,
|
||||||
|
first_token_ms: None,
|
||||||
|
status_code,
|
||||||
|
error_message: Some(error_message),
|
||||||
|
session_id,
|
||||||
|
provider_type,
|
||||||
|
is_streaming,
|
||||||
|
cost_multiplier: "1.0".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.log_request(&log)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取模型定价
|
||||||
|
pub fn get_model_pricing(&self, model_id: &str) -> Result<Option<ModelPricing>, AppError> {
|
||||||
|
let conn = crate::database::lock_conn!(self.db.conn);
|
||||||
|
let row = find_model_pricing_row(&conn, model_id)?;
|
||||||
|
match row {
|
||||||
|
Some((input, output, cache_read, cache_creation)) => {
|
||||||
|
ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|e| AppError::Database(format!("解析定价数据失败: {e}")))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算并记录请求
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn log_with_calculation(
|
||||||
|
&self,
|
||||||
|
request_id: String,
|
||||||
|
provider_id: String,
|
||||||
|
app_type: String,
|
||||||
|
model: String,
|
||||||
|
usage: TokenUsage,
|
||||||
|
cost_multiplier: Decimal,
|
||||||
|
latency_ms: u64,
|
||||||
|
first_token_ms: Option<u64>,
|
||||||
|
status_code: u16,
|
||||||
|
session_id: Option<String>,
|
||||||
|
provider_type: Option<String>,
|
||||||
|
is_streaming: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let pricing = self.get_model_pricing(&model)?;
|
||||||
|
|
||||||
|
if pricing.is_none() {
|
||||||
|
log::warn!("模型 {model} 的定价信息未找到,成本将记录为 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
|
||||||
|
|
||||||
|
let log = RequestLog {
|
||||||
|
request_id,
|
||||||
|
provider_id,
|
||||||
|
app_type,
|
||||||
|
model,
|
||||||
|
usage,
|
||||||
|
cost,
|
||||||
|
latency_ms,
|
||||||
|
first_token_ms,
|
||||||
|
status_code,
|
||||||
|
error_message: None,
|
||||||
|
session_id,
|
||||||
|
provider_type,
|
||||||
|
is_streaming,
|
||||||
|
cost_multiplier: cost_multiplier.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.log_request(&log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_log_request() -> Result<(), AppError> {
|
||||||
|
let db = Database::memory()?;
|
||||||
|
|
||||||
|
// 插入测试定价
|
||||||
|
{
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||||
|
VALUES ('test-model', 'Test Model', '3.0', '15.0')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let logger = UsageLogger::new(&db);
|
||||||
|
|
||||||
|
let usage = TokenUsage {
|
||||||
|
input_tokens: 1000,
|
||||||
|
output_tokens: 500,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
model: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.log_with_calculation(
|
||||||
|
"req-123".to_string(),
|
||||||
|
"provider-1".to_string(),
|
||||||
|
"claude".to_string(),
|
||||||
|
"test-model".to_string(),
|
||||||
|
usage,
|
||||||
|
Decimal::from(1),
|
||||||
|
100,
|
||||||
|
None,
|
||||||
|
200,
|
||||||
|
None,
|
||||||
|
Some("claude".to_string()),
|
||||||
|
false,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 验证记录已插入
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = 'req-123'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_log_error() -> Result<(), AppError> {
|
||||||
|
let db = Database::memory()?;
|
||||||
|
let logger = UsageLogger::new(&db);
|
||||||
|
|
||||||
|
logger.log_error(
|
||||||
|
"req-error".to_string(),
|
||||||
|
"provider-1".to_string(),
|
||||||
|
"claude".to_string(),
|
||||||
|
"unknown-model".to_string(),
|
||||||
|
500,
|
||||||
|
"Internal Server Error".to_string(),
|
||||||
|
50,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 验证错误记录已插入
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
let (status, error): (i64, Option<String>) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT status_code, error_message FROM proxy_request_logs WHERE request_id = 'req-error'",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status, 500);
|
||||||
|
assert_eq!(error, Some("Internal Server Error".to_string()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! Proxy Usage Tracking Module
|
||||||
|
//!
|
||||||
|
//! 提供 API 请求的使用量跟踪、成本计算和日志记录功能
|
||||||
|
|
||||||
|
pub mod calculator;
|
||||||
|
pub mod logger;
|
||||||
|
pub mod parser;
|
||||||
|
|
||||||
|
// 仅导出内部使用的类型,避免未使用警告
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use calculator::{CostBreakdown, CostCalculator, ModelPricing};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use logger::{RequestLog, UsageLogger};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use parser::{ApiType, TokenUsage};
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
//! Response Parser - 从 API 响应中提取 token 使用量
|
||||||
|
//!
|
||||||
|
//! 支持多种 API 格式:
|
||||||
|
//! - Claude API (非流式和流式)
|
||||||
|
//! - OpenRouter (OpenAI 格式)
|
||||||
|
//! - Codex API (非流式和流式)
|
||||||
|
//! - Gemini API (非流式和流式)
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// Token 使用量统计
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct TokenUsage {
|
||||||
|
pub input_tokens: u32,
|
||||||
|
pub output_tokens: u32,
|
||||||
|
pub cache_read_tokens: u32,
|
||||||
|
pub cache_creation_tokens: u32,
|
||||||
|
/// 从响应中提取的实际模型名称(如果可用)
|
||||||
|
pub model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API 类型
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum ApiType {
|
||||||
|
Claude,
|
||||||
|
OpenRouter,
|
||||||
|
Codex,
|
||||||
|
Gemini,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenUsage {
|
||||||
|
/// 从 Claude API 非流式响应解析
|
||||||
|
pub fn from_claude_response(body: &Value) -> Option<Self> {
|
||||||
|
let usage = body.get("usage")?;
|
||||||
|
// 提取响应中的模型名称
|
||||||
|
let model = body
|
||||||
|
.get("model")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
input_tokens: usage.get("input_tokens")?.as_u64()? as u32,
|
||||||
|
output_tokens: usage.get("output_tokens")?.as_u64()? as u32,
|
||||||
|
cache_read_tokens: usage
|
||||||
|
.get("cache_read_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32,
|
||||||
|
cache_creation_tokens: usage
|
||||||
|
.get("cache_creation_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32,
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Claude API 流式响应解析
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> {
|
||||||
|
let mut usage = Self::default();
|
||||||
|
let mut model: Option<String> = None;
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
|
||||||
|
match event_type {
|
||||||
|
"message_start" => {
|
||||||
|
// 从 message_start 提取模型名称
|
||||||
|
if model.is_none() {
|
||||||
|
if let Some(message) = event.get("message") {
|
||||||
|
if let Some(m) = message.get("model").and_then(|v| v.as_str()) {
|
||||||
|
model = Some(m.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) {
|
||||||
|
// 从 message_start 获取 input_tokens(原生 Claude API)
|
||||||
|
if let Some(input) =
|
||||||
|
msg_usage.get("input_tokens").and_then(|v| v.as_u64())
|
||||||
|
{
|
||||||
|
usage.input_tokens = input as u32;
|
||||||
|
}
|
||||||
|
usage.cache_read_tokens = msg_usage
|
||||||
|
.get("cache_read_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0)
|
||||||
|
as u32;
|
||||||
|
usage.cache_creation_tokens = msg_usage
|
||||||
|
.get("cache_creation_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0)
|
||||||
|
as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"message_delta" => {
|
||||||
|
if let Some(delta_usage) = event.get("usage") {
|
||||||
|
// 从 message_delta 获取 output_tokens
|
||||||
|
if let Some(output) =
|
||||||
|
delta_usage.get("output_tokens").and_then(|v| v.as_u64())
|
||||||
|
{
|
||||||
|
usage.output_tokens = output as u32;
|
||||||
|
}
|
||||||
|
// OpenRouter 转换后的流式响应:input_tokens 也在 message_delta 中
|
||||||
|
// 如果 message_start 中没有 input_tokens,则从 message_delta 获取
|
||||||
|
if usage.input_tokens == 0 {
|
||||||
|
if let Some(input) =
|
||||||
|
delta_usage.get("input_tokens").and_then(|v| v.as_u64())
|
||||||
|
{
|
||||||
|
usage.input_tokens = input as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage.input_tokens > 0 || usage.output_tokens > 0 {
|
||||||
|
usage.model = model;
|
||||||
|
Some(usage)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 OpenRouter 响应解析 (OpenAI 格式)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn from_openrouter_response(body: &Value) -> Option<Self> {
|
||||||
|
let usage = body.get("usage")?;
|
||||||
|
Some(Self {
|
||||||
|
input_tokens: usage.get("prompt_tokens")?.as_u64()? as u32,
|
||||||
|
output_tokens: usage.get("completion_tokens")?.as_u64()? as u32,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
model: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Codex API 非流式响应解析
|
||||||
|
pub fn from_codex_response(body: &Value) -> Option<Self> {
|
||||||
|
let usage = body.get("usage");
|
||||||
|
if usage.is_none() {
|
||||||
|
log::debug!(
|
||||||
|
"[Codex] 响应中没有 usage 字段,body keys: {:?}",
|
||||||
|
body.as_object().map(|o| o.keys().collect::<Vec<_>>())
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let usage = usage?;
|
||||||
|
|
||||||
|
let input_tokens = usage.get("input_tokens").and_then(|v| v.as_u64());
|
||||||
|
let output_tokens = usage.get("output_tokens").and_then(|v| v.as_u64());
|
||||||
|
|
||||||
|
if input_tokens.is_none() || output_tokens.is_none() {
|
||||||
|
log::debug!("[Codex] usage 字段缺少 input_tokens 或 output_tokens,usage: {usage:?}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取响应中的模型名称
|
||||||
|
let model = body
|
||||||
|
.get("model")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
input_tokens: input_tokens? as u32,
|
||||||
|
output_tokens: output_tokens? as u32,
|
||||||
|
cache_read_tokens: usage
|
||||||
|
.get("cache_read_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32,
|
||||||
|
cache_creation_tokens: usage
|
||||||
|
.get("cache_creation_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32,
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Codex API 响应解析并调整 input_tokens
|
||||||
|
///
|
||||||
|
/// Codex 的 input_tokens 需要减去 cached_tokens 以获得实际计费的 token 数
|
||||||
|
/// 公式: adjusted_input = max(input_tokens - cached_tokens, 0)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn from_codex_response_adjusted(body: &Value) -> Option<Self> {
|
||||||
|
let usage = body.get("usage")?;
|
||||||
|
let input_tokens = usage.get("input_tokens")?.as_u64()? as u32;
|
||||||
|
let output_tokens = usage.get("output_tokens")?.as_u64()? as u32;
|
||||||
|
|
||||||
|
// 获取 cached_tokens (可能在 input_tokens_details 中)
|
||||||
|
let cached_tokens = usage
|
||||||
|
.get("input_tokens_details")
|
||||||
|
.and_then(|d| d.get("cached_tokens"))
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32;
|
||||||
|
|
||||||
|
// 调整 input_tokens: 减去 cached_tokens
|
||||||
|
let adjusted_input = input_tokens.saturating_sub(cached_tokens);
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
input_tokens: adjusted_input,
|
||||||
|
output_tokens,
|
||||||
|
cache_read_tokens: cached_tokens,
|
||||||
|
cache_creation_tokens: usage
|
||||||
|
.get("cache_creation_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32,
|
||||||
|
model: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Codex API 流式响应解析
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn from_codex_stream_events(events: &[Value]) -> Option<Self> {
|
||||||
|
log::debug!("[Codex] 解析流式事件,共 {} 个事件", events.len());
|
||||||
|
for event in events {
|
||||||
|
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
|
||||||
|
log::debug!("[Codex] 事件类型: {event_type}");
|
||||||
|
if event_type == "response.completed" {
|
||||||
|
if let Some(response) = event.get("response") {
|
||||||
|
log::debug!("[Codex] 找到 response.completed 事件,解析 usage");
|
||||||
|
return Self::from_codex_response(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log::debug!("[Codex] 未找到 response.completed 事件");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 OpenAI Chat Completions API 响应解析 (prompt_tokens, completion_tokens)
|
||||||
|
pub fn from_openai_response(body: &Value) -> Option<Self> {
|
||||||
|
let usage = body.get("usage")?;
|
||||||
|
|
||||||
|
// OpenAI 使用 prompt_tokens 和 completion_tokens
|
||||||
|
let prompt_tokens = usage.get("prompt_tokens").and_then(|v| v.as_u64())?;
|
||||||
|
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64())?;
|
||||||
|
|
||||||
|
// 获取 cached_tokens (可能在 prompt_tokens_details 中)
|
||||||
|
let cached_tokens = usage
|
||||||
|
.get("prompt_tokens_details")
|
||||||
|
.and_then(|d| d.get("cached_tokens"))
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32;
|
||||||
|
|
||||||
|
// 提取响应中的模型名称
|
||||||
|
let model = body
|
||||||
|
.get("model")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
input_tokens: prompt_tokens as u32,
|
||||||
|
output_tokens: completion_tokens as u32,
|
||||||
|
cache_read_tokens: cached_tokens,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 OpenAI Chat Completions API 流式响应解析
|
||||||
|
pub fn from_openai_stream_events(events: &[Value]) -> Option<Self> {
|
||||||
|
log::debug!("[Codex] 解析 OpenAI 流式事件,共 {} 个事件", events.len());
|
||||||
|
// OpenAI 流式响应在最后一个 chunk 中包含 usage
|
||||||
|
for event in events.iter().rev() {
|
||||||
|
if let Some(usage) = event.get("usage") {
|
||||||
|
if !usage.is_null() {
|
||||||
|
log::debug!("[Codex] 找到 usage: {usage:?}");
|
||||||
|
return Self::from_openai_response(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log::debug!("[Codex] 未找到 usage 信息");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Gemini API 非流式响应解析
|
||||||
|
pub fn from_gemini_response(body: &Value) -> Option<Self> {
|
||||||
|
let usage = body.get("usageMetadata")?;
|
||||||
|
// 提取实际使用的模型名称(modelVersion 字段)
|
||||||
|
let model = body
|
||||||
|
.get("modelVersion")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
input_tokens: usage.get("promptTokenCount")?.as_u64()? as u32,
|
||||||
|
output_tokens: usage.get("candidatesTokenCount")?.as_u64()? as u32,
|
||||||
|
cache_read_tokens: usage
|
||||||
|
.get("cachedContentTokenCount")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Gemini API 流式响应解析
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn from_gemini_stream_chunks(chunks: &[Value]) -> Option<Self> {
|
||||||
|
let mut total_input = 0u32;
|
||||||
|
let mut total_output = 0u32;
|
||||||
|
let mut total_cache_read = 0u32;
|
||||||
|
let mut model: Option<String> = None;
|
||||||
|
|
||||||
|
for chunk in chunks {
|
||||||
|
if let Some(usage) = chunk.get("usageMetadata") {
|
||||||
|
total_input = usage
|
||||||
|
.get("promptTokenCount")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32;
|
||||||
|
total_output += usage
|
||||||
|
.get("candidatesTokenCount")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32;
|
||||||
|
total_cache_read = usage
|
||||||
|
.get("cachedContentTokenCount")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取实际使用的模型名称(modelVersion 字段)
|
||||||
|
if model.is_none() {
|
||||||
|
if let Some(model_version) = chunk.get("modelVersion").and_then(|v| v.as_str()) {
|
||||||
|
model = Some(model_version.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if total_input > 0 || total_output > 0 {
|
||||||
|
Some(Self {
|
||||||
|
input_tokens: total_input,
|
||||||
|
output_tokens: total_output,
|
||||||
|
cache_read_tokens: total_cache_read,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_claude_response_parsing() {
|
||||||
|
let response = json!({
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 100,
|
||||||
|
"output_tokens": 50,
|
||||||
|
"cache_read_input_tokens": 20,
|
||||||
|
"cache_creation_input_tokens": 10
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_claude_response(&response).unwrap();
|
||||||
|
assert_eq!(usage.input_tokens, 100);
|
||||||
|
assert_eq!(usage.output_tokens, 50);
|
||||||
|
assert_eq!(usage.cache_read_tokens, 20);
|
||||||
|
assert_eq!(usage.cache_creation_tokens, 10);
|
||||||
|
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_claude_response_parsing_no_model() {
|
||||||
|
let response = json!({
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 100,
|
||||||
|
"output_tokens": 50,
|
||||||
|
"cache_read_input_tokens": 20,
|
||||||
|
"cache_creation_input_tokens": 10
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_claude_response(&response).unwrap();
|
||||||
|
assert_eq!(usage.input_tokens, 100);
|
||||||
|
assert_eq!(usage.output_tokens, 50);
|
||||||
|
assert_eq!(usage.cache_read_tokens, 20);
|
||||||
|
assert_eq!(usage.cache_creation_tokens, 10);
|
||||||
|
assert_eq!(usage.model, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_claude_stream_parsing() {
|
||||||
|
let events = vec![
|
||||||
|
json!({
|
||||||
|
"type": "message_start",
|
||||||
|
"message": {
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 100,
|
||||||
|
"cache_read_input_tokens": 20,
|
||||||
|
"cache_creation_input_tokens": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"type": "message_delta",
|
||||||
|
"usage": {
|
||||||
|
"output_tokens": 50
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
|
||||||
|
assert_eq!(usage.input_tokens, 100);
|
||||||
|
assert_eq!(usage.output_tokens, 50);
|
||||||
|
assert_eq!(usage.cache_read_tokens, 20);
|
||||||
|
assert_eq!(usage.cache_creation_tokens, 10);
|
||||||
|
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_claude_stream_parsing_no_model() {
|
||||||
|
let events = vec![
|
||||||
|
json!({
|
||||||
|
"type": "message_start",
|
||||||
|
"message": {
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 100,
|
||||||
|
"cache_read_input_tokens": 20,
|
||||||
|
"cache_creation_input_tokens": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"type": "message_delta",
|
||||||
|
"usage": {
|
||||||
|
"output_tokens": 50
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
|
||||||
|
assert_eq!(usage.input_tokens, 100);
|
||||||
|
assert_eq!(usage.output_tokens, 50);
|
||||||
|
assert_eq!(usage.cache_read_tokens, 20);
|
||||||
|
assert_eq!(usage.cache_creation_tokens, 10);
|
||||||
|
assert_eq!(usage.model, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_openrouter_response_parsing() {
|
||||||
|
let response = json!({
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 100,
|
||||||
|
"completion_tokens": 50
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_openrouter_response(&response).unwrap();
|
||||||
|
assert_eq!(usage.input_tokens, 100);
|
||||||
|
assert_eq!(usage.output_tokens, 50);
|
||||||
|
assert_eq!(usage.cache_read_tokens, 0);
|
||||||
|
assert_eq!(usage.cache_creation_tokens, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gemini_response_parsing() {
|
||||||
|
let response = json!({
|
||||||
|
"modelVersion": "gemini-3-pro-high",
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 100,
|
||||||
|
"candidatesTokenCount": 50,
|
||||||
|
"cachedContentTokenCount": 20
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_gemini_response(&response).unwrap();
|
||||||
|
assert_eq!(usage.input_tokens, 100);
|
||||||
|
assert_eq!(usage.output_tokens, 50);
|
||||||
|
assert_eq!(usage.cache_read_tokens, 20);
|
||||||
|
assert_eq!(usage.cache_creation_tokens, 0);
|
||||||
|
assert_eq!(usage.model, Some("gemini-3-pro-high".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gemini_response_parsing_no_model() {
|
||||||
|
// 测试没有 modelVersion 字段的情况
|
||||||
|
let response = json!({
|
||||||
|
"usageMetadata": {
|
||||||
|
"promptTokenCount": 100,
|
||||||
|
"candidatesTokenCount": 50,
|
||||||
|
"cachedContentTokenCount": 20
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_gemini_response(&response).unwrap();
|
||||||
|
assert_eq!(usage.input_tokens, 100);
|
||||||
|
assert_eq!(usage.output_tokens, 50);
|
||||||
|
assert_eq!(usage.cache_read_tokens, 20);
|
||||||
|
assert_eq!(usage.cache_creation_tokens, 0);
|
||||||
|
assert_eq!(usage.model, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_codex_response_adjusted() {
|
||||||
|
let response = json!({
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 1000,
|
||||||
|
"output_tokens": 500,
|
||||||
|
"input_tokens_details": {
|
||||||
|
"cached_tokens": 300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
||||||
|
// input_tokens 应该被调整: 1000 - 300 = 700
|
||||||
|
assert_eq!(usage.input_tokens, 700);
|
||||||
|
assert_eq!(usage.output_tokens, 500);
|
||||||
|
assert_eq!(usage.cache_read_tokens, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_codex_response_adjusted_no_cache() {
|
||||||
|
let response = json!({
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 1000,
|
||||||
|
"output_tokens": 500
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
||||||
|
// 没有 cached_tokens,input_tokens 保持不变
|
||||||
|
assert_eq!(usage.input_tokens, 1000);
|
||||||
|
assert_eq!(usage.output_tokens, 500);
|
||||||
|
assert_eq!(usage.cache_read_tokens, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_codex_response_adjusted_saturating_sub() {
|
||||||
|
// 测试 cached_tokens > input_tokens 的边界情况
|
||||||
|
let response = json!({
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 100,
|
||||||
|
"output_tokens": 50,
|
||||||
|
"input_tokens_details": {
|
||||||
|
"cached_tokens": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
||||||
|
// saturating_sub 确保不会下溢
|
||||||
|
assert_eq!(usage.input_tokens, 0);
|
||||||
|
assert_eq!(usage.cache_read_tokens, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_openrouter_stream_parsing() {
|
||||||
|
// 测试 OpenRouter 转换后的流式响应解析
|
||||||
|
// OpenRouter 流式响应经过转换后,input_tokens 在 message_delta 中
|
||||||
|
let events = vec![
|
||||||
|
json!({
|
||||||
|
"type": "message_start",
|
||||||
|
"message": {
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": {
|
||||||
|
"stop_reason": "end_turn"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 150,
|
||||||
|
"output_tokens": 75
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
|
||||||
|
assert_eq!(usage.input_tokens, 150);
|
||||||
|
assert_eq!(usage.output_tokens, 75);
|
||||||
|
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_native_claude_stream_parsing() {
|
||||||
|
// 测试原生 Claude API 流式响应解析
|
||||||
|
// 原生 Claude API 的 input_tokens 在 message_start 中
|
||||||
|
let events = vec![
|
||||||
|
json!({
|
||||||
|
"type": "message_start",
|
||||||
|
"message": {
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 200,
|
||||||
|
"cache_read_input_tokens": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"type": "message_delta",
|
||||||
|
"usage": {
|
||||||
|
"output_tokens": 100
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
|
||||||
|
assert_eq!(usage.input_tokens, 200);
|
||||||
|
assert_eq!(usage.output_tokens, 100);
|
||||||
|
assert_eq!(usage.cache_read_tokens, 50);
|
||||||
|
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,27 @@ impl McpService {
|
|||||||
|
|
||||||
/// 添加或更新 MCP 服务器
|
/// 添加或更新 MCP 服务器
|
||||||
pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> {
|
pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> {
|
||||||
|
// 读取旧状态:用于处理“编辑时取消勾选某个应用”的场景(需要从对应 live 配置中移除)
|
||||||
|
let prev_apps = state
|
||||||
|
.db
|
||||||
|
.get_all_mcp_servers()?
|
||||||
|
.get(&server.id)
|
||||||
|
.map(|s| s.apps.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
state.db.save_mcp_server(&server)?;
|
state.db.save_mcp_server(&server)?;
|
||||||
|
|
||||||
|
// 处理禁用:若旧版本启用但新版本取消,则需要从该应用的 live 配置移除
|
||||||
|
if prev_apps.claude && !server.apps.claude {
|
||||||
|
Self::remove_server_from_app(state, &server.id, &AppType::Claude)?;
|
||||||
|
}
|
||||||
|
if prev_apps.codex && !server.apps.codex {
|
||||||
|
Self::remove_server_from_app(state, &server.id, &AppType::Codex)?;
|
||||||
|
}
|
||||||
|
if prev_apps.gemini && !server.apps.gemini {
|
||||||
|
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
|
||||||
|
}
|
||||||
|
|
||||||
// 同步到各个启用的应用
|
// 同步到各个启用的应用
|
||||||
Self::sync_server_to_apps(state, &server)?;
|
Self::sync_server_to_apps(state, &server)?;
|
||||||
|
|
||||||
@@ -190,10 +209,22 @@ impl McpService {
|
|||||||
// 如果有导入的服务器,保存到数据库
|
// 如果有导入的服务器,保存到数据库
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
if let Some(servers) = &temp_config.mcp.servers {
|
if let Some(servers) = &temp_config.mcp.servers {
|
||||||
|
let mut existing = state.db.get_all_mcp_servers()?;
|
||||||
for server in servers.values() {
|
for server in servers.values() {
|
||||||
state.db.save_mcp_server(server)?;
|
// 已存在:仅启用 Claude,不覆盖其他字段(与导入模块语义保持一致)
|
||||||
// 同步到 Claude live 配置
|
let to_save = if let Some(existing_server) = existing.get(&server.id) {
|
||||||
Self::sync_server_to_apps(state, server)?;
|
let mut merged = existing_server.clone();
|
||||||
|
merged.apps.claude = true;
|
||||||
|
merged
|
||||||
|
} else {
|
||||||
|
server.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
state.db.save_mcp_server(&to_save)?;
|
||||||
|
existing.insert(to_save.id.clone(), to_save.clone());
|
||||||
|
|
||||||
|
// 同步到对应应用 live 配置
|
||||||
|
Self::sync_server_to_apps(state, &to_save)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,10 +243,22 @@ impl McpService {
|
|||||||
// 如果有导入的服务器,保存到数据库
|
// 如果有导入的服务器,保存到数据库
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
if let Some(servers) = &temp_config.mcp.servers {
|
if let Some(servers) = &temp_config.mcp.servers {
|
||||||
|
let mut existing = state.db.get_all_mcp_servers()?;
|
||||||
for server in servers.values() {
|
for server in servers.values() {
|
||||||
state.db.save_mcp_server(server)?;
|
// 已存在:仅启用 Codex,不覆盖其他字段(与导入模块语义保持一致)
|
||||||
// 同步到 Codex live 配置
|
let to_save = if let Some(existing_server) = existing.get(&server.id) {
|
||||||
Self::sync_server_to_apps(state, server)?;
|
let mut merged = existing_server.clone();
|
||||||
|
merged.apps.codex = true;
|
||||||
|
merged
|
||||||
|
} else {
|
||||||
|
server.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
state.db.save_mcp_server(&to_save)?;
|
||||||
|
existing.insert(to_save.id.clone(), to_save.clone());
|
||||||
|
|
||||||
|
// 同步到对应应用 live 配置
|
||||||
|
Self::sync_server_to_apps(state, &to_save)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,10 +277,22 @@ impl McpService {
|
|||||||
// 如果有导入的服务器,保存到数据库
|
// 如果有导入的服务器,保存到数据库
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
if let Some(servers) = &temp_config.mcp.servers {
|
if let Some(servers) = &temp_config.mcp.servers {
|
||||||
|
let mut existing = state.db.get_all_mcp_servers()?;
|
||||||
for server in servers.values() {
|
for server in servers.values() {
|
||||||
state.db.save_mcp_server(server)?;
|
// 已存在:仅启用 Gemini,不覆盖其他字段(与导入模块语义保持一致)
|
||||||
// 同步到 Gemini live 配置
|
let to_save = if let Some(existing_server) = existing.get(&server.id) {
|
||||||
Self::sync_server_to_apps(state, server)?;
|
let mut merged = existing_server.clone();
|
||||||
|
merged.apps.gemini = true;
|
||||||
|
merged
|
||||||
|
} else {
|
||||||
|
server.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
state.db.save_mcp_server(&to_save)?;
|
||||||
|
existing.insert(to_save.id.clone(), to_save.clone());
|
||||||
|
|
||||||
|
// 同步到对应应用 live 配置
|
||||||
|
Self::sync_server_to_apps(state, &to_save)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,21 @@ pub mod env_manager;
|
|||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
|
pub mod proxy;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
pub mod speedtest;
|
pub mod speedtest;
|
||||||
|
pub mod stream_check;
|
||||||
|
pub mod usage_stats;
|
||||||
|
|
||||||
pub use config::ConfigService;
|
pub use config::ConfigService;
|
||||||
pub use mcp::McpService;
|
pub use mcp::McpService;
|
||||||
pub use prompt::PromptService;
|
pub use prompt::PromptService;
|
||||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||||
|
pub use proxy::ProxyService;
|
||||||
pub use skill::{Skill, SkillRepo, SkillService};
|
pub use skill::{Skill, SkillRepo, SkillService};
|
||||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use usage_stats::{
|
||||||
|
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
|
||||||
|
RequestLogDetail, UsageSummary,
|
||||||
|
};
|
||||||
|
|||||||
@@ -144,9 +144,29 @@ impl ProviderService {
|
|||||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||||
|
|
||||||
if is_current {
|
if is_current {
|
||||||
write_live_snapshot(&app_type, &provider)?;
|
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
|
||||||
// Sync MCP
|
// - 不写 Live 配置(否则会破坏接管)
|
||||||
McpService::sync_all_enabled(state)?;
|
// - 仅更新 Live 备份(保证关闭代理时能恢复到最新配置)
|
||||||
|
let is_app_taken_over =
|
||||||
|
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
|
||||||
|
let should_skip_live_write = is_app_taken_over && is_proxy_running;
|
||||||
|
|
||||||
|
if should_skip_live_write {
|
||||||
|
futures::executor::block_on(
|
||||||
|
state
|
||||||
|
.proxy_service
|
||||||
|
.update_live_backup_from_provider(app_type.as_str(), &provider),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||||
|
} else {
|
||||||
|
write_live_snapshot(&app_type, &provider)?;
|
||||||
|
// Sync MCP
|
||||||
|
McpService::sync_all_enabled(state)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -173,14 +193,77 @@ impl ProviderService {
|
|||||||
///
|
///
|
||||||
/// Switch flow:
|
/// Switch flow:
|
||||||
/// 1. Validate target provider exists
|
/// 1. Validate target provider exists
|
||||||
/// 2. **Backfill mechanism**: Backfill current live config to current provider, protect user manual modifications
|
/// 2. Check if proxy takeover mode is active AND proxy server is running
|
||||||
/// 3. Update local settings current_provider_xxx (device-level)
|
/// 3. If takeover mode active: hot-switch proxy target only (no Live config write)
|
||||||
/// 4. Update database is_current (as default for new devices)
|
/// 4. If normal mode:
|
||||||
/// 5. Write target provider config to live files
|
/// a. **Backfill mechanism**: Backfill current live config to current provider
|
||||||
/// 6. Sync MCP configuration
|
/// b. Update local settings current_provider_xxx (device-level)
|
||||||
|
/// c. Update database is_current (as default for new devices)
|
||||||
|
/// d. Write target provider config to live files
|
||||||
|
/// e. Sync MCP configuration
|
||||||
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||||
// Check if provider exists
|
// Check if provider exists
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
|
let _provider = providers
|
||||||
|
.get(id)
|
||||||
|
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||||||
|
|
||||||
|
// Check if proxy takeover mode is active AND proxy server is actually running
|
||||||
|
// Both conditions must be true to use hot-switch mode
|
||||||
|
// Use blocking wait since this is a sync function
|
||||||
|
let is_app_taken_over =
|
||||||
|
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
|
||||||
|
|
||||||
|
// Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
|
||||||
|
let should_hot_switch = is_app_taken_over && is_proxy_running;
|
||||||
|
|
||||||
|
if should_hot_switch {
|
||||||
|
// Proxy takeover mode: hot-switch only, don't write Live config
|
||||||
|
log::info!(
|
||||||
|
"代理接管模式:热切换 {} 的目标供应商为 {}",
|
||||||
|
app_type.as_str(),
|
||||||
|
id
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取新供应商的完整配置(用于更新备份)
|
||||||
|
let provider = providers
|
||||||
|
.get(id)
|
||||||
|
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||||||
|
|
||||||
|
// Update database is_current
|
||||||
|
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||||
|
|
||||||
|
// Update local settings for consistency
|
||||||
|
crate::settings::set_current_provider(&app_type, Some(id))?;
|
||||||
|
|
||||||
|
// 更新 Live 备份(确保代理关闭时恢复正确的供应商配置)
|
||||||
|
futures::executor::block_on(
|
||||||
|
state
|
||||||
|
.proxy_service
|
||||||
|
.update_live_backup_from_provider(app_type.as_str(), provider),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||||
|
|
||||||
|
// Note: No Live config write, no MCP sync
|
||||||
|
// The proxy server will route requests to the new provider via is_current
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal mode: full switch with Live config write
|
||||||
|
Self::switch_normal(state, app_type, id, &providers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normal switch flow (non-proxy mode)
|
||||||
|
fn switch_normal(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: AppType,
|
||||||
|
id: &str,
|
||||||
|
providers: &indexmap::IndexMap<String, Provider>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
let provider = providers
|
let provider = providers
|
||||||
.get(id)
|
.get(id)
|
||||||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||||||
|
|||||||
@@ -79,6 +79,34 @@ pub(crate) async fn execute_and_format_usage_result(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract API key from provider configuration
|
||||||
|
fn extract_api_key_from_provider(provider: &crate::provider::Provider) -> Option<String> {
|
||||||
|
if let Some(env) = provider.settings_config.get("env") {
|
||||||
|
// Try multiple possible API key fields
|
||||||
|
env.get("ANTHROPIC_AUTH_TOKEN")
|
||||||
|
.or_else(|| env.get("ANTHROPIC_API_KEY"))
|
||||||
|
.or_else(|| env.get("OPENROUTER_API_KEY"))
|
||||||
|
.or_else(|| env.get("GOOGLE_API_KEY"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract base URL from provider configuration
|
||||||
|
fn extract_base_url_from_provider(provider: &crate::provider::Provider) -> Option<String> {
|
||||||
|
if let Some(env) = provider.settings_config.get("env") {
|
||||||
|
// Try multiple possible base URL fields
|
||||||
|
env.get("ANTHROPIC_BASE_URL")
|
||||||
|
.or_else(|| env.get("GOOGLE_GEMINI_BASE_URL"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.trim_end_matches('/').to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Query provider usage (using saved script configuration)
|
/// Query provider usage (using saved script configuration)
|
||||||
pub async fn query_usage(
|
pub async fn query_usage(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -114,12 +142,26 @@ pub async fn query_usage(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get credentials directly from UsageScript, no longer extract from provider config
|
// Get credentials: prioritize UsageScript values, fallback to provider config
|
||||||
|
let api_key = usage_script
|
||||||
|
.api_key
|
||||||
|
.clone()
|
||||||
|
.filter(|k| !k.is_empty())
|
||||||
|
.or_else(|| extract_api_key_from_provider(provider))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let base_url = usage_script
|
||||||
|
.base_url
|
||||||
|
.clone()
|
||||||
|
.filter(|u| !u.is_empty())
|
||||||
|
.or_else(|| extract_base_url_from_provider(provider))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
(
|
(
|
||||||
usage_script.code.clone(),
|
usage_script.code.clone(),
|
||||||
usage_script.timeout.unwrap_or(10),
|
usage_script.timeout.unwrap_or(10),
|
||||||
usage_script.api_key.clone().unwrap_or_default(),
|
api_key,
|
||||||
usage_script.base_url.clone().unwrap_or_default(),
|
base_url,
|
||||||
usage_script.access_token.clone(),
|
usage_script.access_token.clone(),
|
||||||
usage_script.user_id.clone(),
|
usage_script.user_id.clone(),
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ use std::fs;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
use crate::error::format_skill_error;
|
use crate::error::format_skill_error;
|
||||||
|
|
||||||
/// 技能对象
|
/// 技能对象
|
||||||
@@ -106,11 +107,16 @@ pub struct SkillMetadata {
|
|||||||
pub struct SkillService {
|
pub struct SkillService {
|
||||||
http_client: Client,
|
http_client: Client,
|
||||||
install_dir: PathBuf,
|
install_dir: PathBuf,
|
||||||
|
app_type: AppType,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SkillService {
|
impl SkillService {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
let install_dir = Self::get_install_dir()?;
|
Self::new_for_app(AppType::Claude)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_for_app(app_type: AppType) -> Result<Self> {
|
||||||
|
let install_dir = Self::get_install_dir_for_app(&app_type)?;
|
||||||
|
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
fs::create_dir_all(&install_dir)?;
|
fs::create_dir_all(&install_dir)?;
|
||||||
@@ -122,16 +128,38 @@ impl SkillService {
|
|||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.build()?,
|
.build()?,
|
||||||
install_dir,
|
install_dir,
|
||||||
|
app_type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_install_dir() -> Result<PathBuf> {
|
fn get_install_dir_for_app(app_type: &AppType) -> Result<PathBuf> {
|
||||||
let home = dirs::home_dir().context(format_skill_error(
|
let home = dirs::home_dir().context(format_skill_error(
|
||||||
"GET_HOME_DIR_FAILED",
|
"GET_HOME_DIR_FAILED",
|
||||||
&[],
|
&[],
|
||||||
Some("checkPermission"),
|
Some("checkPermission"),
|
||||||
))?;
|
))?;
|
||||||
Ok(home.join(".claude").join("skills"))
|
|
||||||
|
let dir = match app_type {
|
||||||
|
AppType::Claude => home.join(".claude").join("skills"),
|
||||||
|
AppType::Codex => {
|
||||||
|
// 检查是否有自定义 Codex 配置目录
|
||||||
|
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||||||
|
custom.join("skills")
|
||||||
|
} else {
|
||||||
|
home.join(".codex").join("skills")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
// 为 Gemini 预留,暂时使用默认路径
|
||||||
|
home.join(".gemini").join("skills")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app_type(&self) -> &AppType {
|
||||||
|
&self.app_type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,40 +31,51 @@ impl SpeedtestService {
|
|||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut results: Vec<Option<EndpointLatency>> = vec![None; urls.len()];
|
||||||
|
let mut valid_targets = Vec::new();
|
||||||
|
|
||||||
|
for (idx, raw_url) in urls.into_iter().enumerate() {
|
||||||
|
let trimmed = raw_url.trim().to_string();
|
||||||
|
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
results[idx] = Some(EndpointLatency {
|
||||||
|
url: raw_url,
|
||||||
|
latency: None,
|
||||||
|
status: None,
|
||||||
|
error: Some("URL 不能为空".to_string()),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match Url::parse(&trimmed) {
|
||||||
|
Ok(parsed_url) => valid_targets.push((idx, trimmed, parsed_url)),
|
||||||
|
Err(err) => {
|
||||||
|
results[idx] = Some(EndpointLatency {
|
||||||
|
url: trimmed,
|
||||||
|
latency: None,
|
||||||
|
status: None,
|
||||||
|
error: Some(format!("URL 无效: {err}")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if valid_targets.is_empty() {
|
||||||
|
return Ok(results.into_iter().flatten().collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
|
||||||
let timeout = Self::sanitize_timeout(timeout_secs);
|
let timeout = Self::sanitize_timeout(timeout_secs);
|
||||||
let client = Self::build_client(timeout)?;
|
let client = Self::build_client(timeout)?;
|
||||||
|
|
||||||
let tasks = urls.into_iter().map(|raw_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 trimmed = raw_url.trim().to_string();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return EndpointLatency {
|
|
||||||
url: raw_url,
|
|
||||||
latency: None,
|
|
||||||
status: None,
|
|
||||||
error: Some("URL 不能为空".to_string()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed_url = match Url::parse(&trimmed) {
|
|
||||||
Ok(url) => url,
|
|
||||||
Err(err) => {
|
|
||||||
return EndpointLatency {
|
|
||||||
url: trimmed,
|
|
||||||
latency: None,
|
|
||||||
status: None,
|
|
||||||
error: Some(format!("URL 无效: {err}")),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
|
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
|
||||||
let _ = client.get(parsed_url.clone()).send().await;
|
let _ = client.get(parsed_url.clone()).send().await;
|
||||||
|
|
||||||
// 第二次请求开始计时,并将其作为结果返回。
|
// 第二次请求开始计时,并将其作为结果返回。
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
match client.get(parsed_url).send().await {
|
let latency = match client.get(parsed_url).send().await {
|
||||||
Ok(resp) => EndpointLatency {
|
Ok(resp) => EndpointLatency {
|
||||||
url: trimmed,
|
url: trimmed,
|
||||||
latency: Some(start.elapsed().as_millis()),
|
latency: Some(start.elapsed().as_millis()),
|
||||||
@@ -88,11 +99,17 @@ impl SpeedtestService {
|
|||||||
error: Some(error_message),
|
error: Some(error_message),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
(idx, latency)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(join_all(tasks).await)
|
for (idx, latency) in join_all(tasks).await {
|
||||||
|
results[idx] = Some(latency);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results.into_iter().flatten().collect::<Vec<_>>())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
|
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
|
||||||
|
|||||||
@@ -0,0 +1,481 @@
|
|||||||
|
//! 流式健康检查服务
|
||||||
|
//!
|
||||||
|
//! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。
|
||||||
|
|
||||||
|
use futures::StreamExt;
|
||||||
|
use regex::Regex;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::proxy::providers::{get_adapter, AuthInfo};
|
||||||
|
|
||||||
|
/// 健康状态枚举
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum HealthStatus {
|
||||||
|
Operational,
|
||||||
|
Degraded,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式检查配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StreamCheckConfig {
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
pub max_retries: u32,
|
||||||
|
pub degraded_threshold_ms: u64,
|
||||||
|
/// Claude 测试模型
|
||||||
|
pub claude_model: String,
|
||||||
|
/// Codex 测试模型
|
||||||
|
pub codex_model: String,
|
||||||
|
/// Gemini 测试模型
|
||||||
|
pub gemini_model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StreamCheckConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
timeout_secs: 45,
|
||||||
|
max_retries: 2,
|
||||||
|
degraded_threshold_ms: 6000,
|
||||||
|
claude_model: "claude-haiku-4-5-20251001".to_string(),
|
||||||
|
codex_model: "gpt-5.1-codex@low".to_string(),
|
||||||
|
gemini_model: "gemini-3-pro-preview".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式检查结果
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StreamCheckResult {
|
||||||
|
pub status: HealthStatus,
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub response_time_ms: Option<u64>,
|
||||||
|
pub http_status: Option<u16>,
|
||||||
|
pub model_used: String,
|
||||||
|
pub tested_at: i64,
|
||||||
|
pub retry_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式健康检查服务
|
||||||
|
pub struct StreamCheckService;
|
||||||
|
|
||||||
|
impl StreamCheckService {
|
||||||
|
/// 执行流式健康检查(带重试)
|
||||||
|
pub async fn check_with_retry(
|
||||||
|
app_type: &AppType,
|
||||||
|
provider: &Provider,
|
||||||
|
config: &StreamCheckConfig,
|
||||||
|
) -> Result<StreamCheckResult, AppError> {
|
||||||
|
let mut last_result = None;
|
||||||
|
|
||||||
|
for attempt in 0..=config.max_retries {
|
||||||
|
let result = Self::check_once(app_type, provider, config).await;
|
||||||
|
|
||||||
|
match &result {
|
||||||
|
Ok(r) if r.success => {
|
||||||
|
return Ok(StreamCheckResult {
|
||||||
|
retry_count: attempt,
|
||||||
|
..r.clone()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(r) => {
|
||||||
|
// 失败但非异常,判断是否重试
|
||||||
|
if Self::should_retry(&r.message) && attempt < config.max_retries {
|
||||||
|
last_result = Some(r.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Ok(StreamCheckResult {
|
||||||
|
retry_count: attempt,
|
||||||
|
..r.clone()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if Self::should_retry(&e.to_string()) && attempt < config.max_retries {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Err(AppError::Message(e.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(last_result.unwrap_or_else(|| StreamCheckResult {
|
||||||
|
status: HealthStatus::Failed,
|
||||||
|
success: false,
|
||||||
|
message: "检查失败".to_string(),
|
||||||
|
response_time_ms: None,
|
||||||
|
http_status: None,
|
||||||
|
model_used: String::new(),
|
||||||
|
tested_at: chrono::Utc::now().timestamp(),
|
||||||
|
retry_count: config.max_retries,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单次流式检查
|
||||||
|
async fn check_once(
|
||||||
|
app_type: &AppType,
|
||||||
|
provider: &Provider,
|
||||||
|
config: &StreamCheckConfig,
|
||||||
|
) -> Result<StreamCheckResult, AppError> {
|
||||||
|
let start = Instant::now();
|
||||||
|
let adapter = get_adapter(app_type);
|
||||||
|
|
||||||
|
let base_url = adapter
|
||||||
|
.extract_base_url(provider)
|
||||||
|
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
|
||||||
|
|
||||||
|
let auth = adapter
|
||||||
|
.extract_auth(provider)
|
||||||
|
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
|
||||||
|
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(config.timeout_secs))
|
||||||
|
.user_agent("cc-switch/1.0")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
|
||||||
|
|
||||||
|
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
||||||
|
|
||||||
|
let result = match app_type {
|
||||||
|
AppType::Claude => {
|
||||||
|
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
|
||||||
|
}
|
||||||
|
AppType::Codex => {
|
||||||
|
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
|
||||||
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let response_time = start.elapsed().as_millis() as u64;
|
||||||
|
let tested_at = chrono::Utc::now().timestamp();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((status_code, model)) => {
|
||||||
|
let health_status =
|
||||||
|
Self::determine_status(response_time, config.degraded_threshold_ms);
|
||||||
|
Ok(StreamCheckResult {
|
||||||
|
status: health_status,
|
||||||
|
success: true,
|
||||||
|
message: "检查成功".to_string(),
|
||||||
|
response_time_ms: Some(response_time),
|
||||||
|
http_status: Some(status_code),
|
||||||
|
model_used: model,
|
||||||
|
tested_at,
|
||||||
|
retry_count: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => Ok(StreamCheckResult {
|
||||||
|
status: HealthStatus::Failed,
|
||||||
|
success: false,
|
||||||
|
message: e.to_string(),
|
||||||
|
response_time_ms: Some(response_time),
|
||||||
|
http_status: None,
|
||||||
|
model_used: String::new(),
|
||||||
|
tested_at,
|
||||||
|
retry_count: 0,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude 流式检查
|
||||||
|
async fn check_claude_stream(
|
||||||
|
client: &Client,
|
||||||
|
base_url: &str,
|
||||||
|
auth: &AuthInfo,
|
||||||
|
model: &str,
|
||||||
|
) -> Result<(u16, String), AppError> {
|
||||||
|
let base = base_url.trim_end_matches('/');
|
||||||
|
let url = if base.ends_with("/v1") {
|
||||||
|
format!("{base}/messages")
|
||||||
|
} else {
|
||||||
|
format!("{base}/v1/messages")
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = json!({
|
||||||
|
"model": model,
|
||||||
|
"max_tokens": 1,
|
||||||
|
"messages": [{ "role": "user", "content": "hi" }],
|
||||||
|
"stream": true
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.header("x-api-key", &auth.api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_request_error)?;
|
||||||
|
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流式读取:只需首个 chunk
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
if let Some(chunk) = stream.next().await {
|
||||||
|
match chunk {
|
||||||
|
Ok(_) => Ok((status, model.to_string())),
|
||||||
|
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(AppError::Message("未收到响应数据".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Codex 流式检查
|
||||||
|
async fn check_codex_stream(
|
||||||
|
client: &Client,
|
||||||
|
base_url: &str,
|
||||||
|
auth: &AuthInfo,
|
||||||
|
model: &str,
|
||||||
|
) -> Result<(u16, String), AppError> {
|
||||||
|
let base = base_url.trim_end_matches('/');
|
||||||
|
let url = if base.ends_with("/v1") {
|
||||||
|
format!("{base}/chat/completions")
|
||||||
|
} else {
|
||||||
|
format!("{base}/v1/chat/completions")
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||||
|
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
|
||||||
|
|
||||||
|
let mut body = json!({
|
||||||
|
"model": actual_model,
|
||||||
|
"messages": [
|
||||||
|
{ "role": "system", "content": "" },
|
||||||
|
{ "role": "assistant", "content": "" },
|
||||||
|
{ "role": "user", "content": "hi" }
|
||||||
|
],
|
||||||
|
"max_tokens": 1,
|
||||||
|
"temperature": 0,
|
||||||
|
"stream": true
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果是推理模型,添加 reasoning_effort
|
||||||
|
if let Some(effort) = reasoning_effort {
|
||||||
|
body["reasoning_effort"] = json!(effort);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_request_error)?;
|
||||||
|
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
if let Some(chunk) = stream.next().await {
|
||||||
|
match chunk {
|
||||||
|
Ok(_) => Ok((status, model.to_string())),
|
||||||
|
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(AppError::Message("未收到响应数据".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gemini 流式检查
|
||||||
|
async fn check_gemini_stream(
|
||||||
|
client: &Client,
|
||||||
|
base_url: &str,
|
||||||
|
auth: &AuthInfo,
|
||||||
|
model: &str,
|
||||||
|
) -> Result<(u16, String), AppError> {
|
||||||
|
let base = base_url.trim_end_matches('/');
|
||||||
|
let url = format!("{base}/v1/chat/completions");
|
||||||
|
|
||||||
|
let body = json!({
|
||||||
|
"model": model,
|
||||||
|
"messages": [{ "role": "user", "content": "hi" }],
|
||||||
|
"max_tokens": 1,
|
||||||
|
"temperature": 0,
|
||||||
|
"stream": true
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_request_error)?;
|
||||||
|
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
if let Some(chunk) = stream.next().await {
|
||||||
|
match chunk {
|
||||||
|
Ok(_) => Ok((status, model.to_string())),
|
||||||
|
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(AppError::Message("未收到响应数据".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn determine_status(latency_ms: u64, threshold: u64) -> HealthStatus {
|
||||||
|
if latency_ms <= threshold {
|
||||||
|
HealthStatus::Operational
|
||||||
|
} else {
|
||||||
|
HealthStatus::Degraded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||||
|
/// 返回 (实际模型名, Option<推理等级>)
|
||||||
|
fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
|
||||||
|
// 查找 @ 或 # 分隔符
|
||||||
|
if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
|
||||||
|
let actual_model = model[..pos].to_string();
|
||||||
|
let effort = model[pos + 1..].to_string();
|
||||||
|
if !effort.is_empty() {
|
||||||
|
return (actual_model, Some(effort));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(model.to_string(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_retry(msg: &str) -> bool {
|
||||||
|
let lower = msg.to_lowercase();
|
||||||
|
lower.contains("timeout")
|
||||||
|
|| lower.contains("abort")
|
||||||
|
|| lower.contains("中断")
|
||||||
|
|| lower.contains("超时")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_request_error(e: reqwest::Error) -> AppError {
|
||||||
|
if e.is_timeout() {
|
||||||
|
AppError::Message("请求超时".to_string())
|
||||||
|
} else if e.is_connect() {
|
||||||
|
AppError::Message(format!("连接失败: {e}"))
|
||||||
|
} else {
|
||||||
|
AppError::Message(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_test_model(
|
||||||
|
app_type: &AppType,
|
||||||
|
provider: &Provider,
|
||||||
|
config: &StreamCheckConfig,
|
||||||
|
) -> String {
|
||||||
|
match app_type {
|
||||||
|
AppType::Claude => Self::extract_env_model(provider, "ANTHROPIC_MODEL")
|
||||||
|
.unwrap_or_else(|| config.claude_model.clone()),
|
||||||
|
AppType::Codex => {
|
||||||
|
Self::extract_codex_model(provider).unwrap_or_else(|| config.codex_model.clone())
|
||||||
|
}
|
||||||
|
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
|
||||||
|
.unwrap_or_else(|| config.gemini_model.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
|
||||||
|
provider
|
||||||
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|env| env.get(key))
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_codex_model(provider: &Provider) -> Option<String> {
|
||||||
|
let config_text = provider
|
||||||
|
.settings_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(|value| value.as_str())?;
|
||||||
|
if config_text.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let re = Regex::new(r#"^model\s*=\s*["']([^"']+)["']"#).ok()?;
|
||||||
|
re.captures(config_text)
|
||||||
|
.and_then(|caps| caps.get(1))
|
||||||
|
.map(|m| m.as_str().trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_determine_status() {
|
||||||
|
assert_eq!(
|
||||||
|
StreamCheckService::determine_status(3000, 6000),
|
||||||
|
HealthStatus::Operational
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
StreamCheckService::determine_status(6000, 6000),
|
||||||
|
HealthStatus::Operational
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
StreamCheckService::determine_status(6001, 6000),
|
||||||
|
HealthStatus::Degraded
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_retry() {
|
||||||
|
assert!(StreamCheckService::should_retry("请求超时"));
|
||||||
|
assert!(StreamCheckService::should_retry("request timeout"));
|
||||||
|
assert!(!StreamCheckService::should_retry("API Key 无效"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_config() {
|
||||||
|
let config = StreamCheckConfig::default();
|
||||||
|
assert_eq!(config.timeout_secs, 45);
|
||||||
|
assert_eq!(config.max_retries, 2);
|
||||||
|
assert_eq!(config.degraded_threshold_ms, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_model_with_effort() {
|
||||||
|
// 带 @ 分隔符
|
||||||
|
let (model, effort) = StreamCheckService::parse_model_with_effort("gpt-5.1-codex@low");
|
||||||
|
assert_eq!(model, "gpt-5.1-codex");
|
||||||
|
assert_eq!(effort, Some("low".to_string()));
|
||||||
|
|
||||||
|
// 带 # 分隔符
|
||||||
|
let (model, effort) = StreamCheckService::parse_model_with_effort("o1-preview#high");
|
||||||
|
assert_eq!(model, "o1-preview");
|
||||||
|
assert_eq!(effort, Some("high".to_string()));
|
||||||
|
|
||||||
|
// 无分隔符
|
||||||
|
let (model, effort) = StreamCheckService::parse_model_with_effort("gpt-4o-mini");
|
||||||
|
assert_eq!(model, "gpt-4o-mini");
|
||||||
|
assert_eq!(effort, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,18 @@
|
|||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
|
use crate::services::ProxyService;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// 全局应用状态
|
/// 全局应用状态
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Arc<Database>,
|
pub db: Arc<Database>,
|
||||||
|
pub proxy_service: ProxyService,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
/// 创建新的应用状态
|
/// 创建新的应用状态
|
||||||
pub fn new(db: Arc<Database>) -> Self {
|
pub fn new(db: Arc<Database>) -> Self {
|
||||||
Self { db }
|
let proxy_service = ProxyService::new(db.clone());
|
||||||
|
|
||||||
|
Self { db, proxy_service }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+492
-18
@@ -3,6 +3,7 @@ use rquickjs::{Context, Function, Runtime};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use url::{Host, Url};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
@@ -15,20 +16,14 @@ pub async fn execute_usage_script(
|
|||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
) -> Result<Value, AppError> {
|
) -> Result<Value, AppError> {
|
||||||
// 1. 替换变量
|
// 1. 替换模板变量,避免泄露敏感信息
|
||||||
let mut replaced = script_code
|
let script_with_vars =
|
||||||
.replace("{{apiKey}}", api_key)
|
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
|
||||||
.replace("{{baseUrl}}", base_url);
|
|
||||||
|
|
||||||
// 替换 accessToken 和 userId
|
// 2. 验证 base_url 的安全性
|
||||||
if let Some(token) = access_token {
|
validate_base_url(base_url)?;
|
||||||
replaced = replaced.replace("{{accessToken}}", token);
|
|
||||||
}
|
|
||||||
if let Some(uid) = user_id {
|
|
||||||
replaced = replaced.replace("{{userId}}", uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
||||||
let request_config = {
|
let request_config = {
|
||||||
let runtime = Runtime::new().map_err(|e| {
|
let runtime = Runtime::new().map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
@@ -47,7 +42,7 @@ pub async fn execute_usage_script(
|
|||||||
|
|
||||||
context.with(|ctx| {
|
context.with(|ctx| {
|
||||||
// 执行用户代码,获取配置对象
|
// 执行用户代码,获取配置对象
|
||||||
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
let config: rquickjs::Object = ctx.eval(script_with_vars.clone()).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.config_parse_failed",
|
"usage_script.config_parse_failed",
|
||||||
format!("解析配置失败: {e}"),
|
format!("解析配置失败: {e}"),
|
||||||
@@ -94,7 +89,7 @@ pub async fn execute_usage_script(
|
|||||||
})?
|
})?
|
||||||
}; // Runtime 和 Context 在这里被 drop
|
}; // Runtime 和 Context 在这里被 drop
|
||||||
|
|
||||||
// 3. 解析 request 配置
|
// 4. 解析 request 配置
|
||||||
let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {
|
let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.request_format_invalid",
|
"usage_script.request_format_invalid",
|
||||||
@@ -103,10 +98,13 @@ pub async fn execute_usage_script(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 4. 发送 HTTP 请求
|
// 5. 验证请求 URL 是否安全(防止 SSRF)
|
||||||
|
validate_request_url(&request.url, base_url)?;
|
||||||
|
|
||||||
|
// 6. 发送 HTTP 请求
|
||||||
let response_data = send_http_request(&request, timeout_secs).await?;
|
let response_data = send_http_request(&request, timeout_secs).await?;
|
||||||
|
|
||||||
// 5. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放)
|
// 7. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放)
|
||||||
let result: Value = {
|
let result: Value = {
|
||||||
let runtime = Runtime::new().map_err(|e| {
|
let runtime = Runtime::new().map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
@@ -125,7 +123,7 @@ pub async fn execute_usage_script(
|
|||||||
|
|
||||||
context.with(|ctx| {
|
context.with(|ctx| {
|
||||||
// 重新 eval 获取配置对象
|
// 重新 eval 获取配置对象
|
||||||
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
let config: rquickjs::Object = ctx.eval(script_with_vars.clone()).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.config_reparse_failed",
|
"usage_script.config_reparse_failed",
|
||||||
format!("重新解析配置失败: {e}"),
|
format!("重新解析配置失败: {e}"),
|
||||||
@@ -198,7 +196,7 @@ pub async fn execute_usage_script(
|
|||||||
})?
|
})?
|
||||||
}; // Runtime 和 Context 在这里被 drop
|
}; // Runtime 和 Context 在这里被 drop
|
||||||
|
|
||||||
// 6. 验证返回值格式
|
// 8. 验证返回值格式
|
||||||
validate_result(&result)?;
|
validate_result(&result)?;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -394,3 +392,479 @@ fn validate_single_usage(result: &Value) -> Result<(), AppError> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 构建替换变量后的脚本,保持与旧版脚本的兼容性
|
||||||
|
fn build_script_with_vars(
|
||||||
|
script_code: &str,
|
||||||
|
api_key: &str,
|
||||||
|
base_url: &str,
|
||||||
|
access_token: Option<&str>,
|
||||||
|
user_id: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let mut replaced = script_code
|
||||||
|
.replace("{{apiKey}}", api_key)
|
||||||
|
.replace("{{baseUrl}}", base_url);
|
||||||
|
|
||||||
|
if let Some(token) = access_token {
|
||||||
|
replaced = replaced.replace("{{accessToken}}", token);
|
||||||
|
}
|
||||||
|
if let Some(uid) = user_id {
|
||||||
|
replaced = replaced.replace("{{userId}}", uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
replaced
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证 base_url 的基本安全性
|
||||||
|
fn validate_base_url(base_url: &str) -> Result<(), AppError> {
|
||||||
|
if base_url.is_empty() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.base_url_empty",
|
||||||
|
"base_url 不能为空",
|
||||||
|
"base_url cannot be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 URL
|
||||||
|
let parsed_url = Url::parse(base_url).map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.base_url_invalid",
|
||||||
|
format!("无效的 base_url: {e}"),
|
||||||
|
format!("Invalid base_url: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let is_loopback = is_loopback_host(&parsed_url);
|
||||||
|
|
||||||
|
// 必须是 HTTPS(允许 localhost 用于开发)
|
||||||
|
if parsed_url.scheme() != "https" && !is_loopback {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.base_url_https_required",
|
||||||
|
"base_url 必须使用 HTTPS 协议(localhost 除外)",
|
||||||
|
"base_url must use HTTPS (localhost allowed)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查主机名格式有效性
|
||||||
|
let hostname = parsed_url.host_str().ok_or_else(|| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.base_url_hostname_missing",
|
||||||
|
"base_url 必须包含有效的主机名",
|
||||||
|
"base_url must include a valid hostname",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 基本的主机名格式检查
|
||||||
|
if hostname.is_empty() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.base_url_hostname_empty",
|
||||||
|
"base_url 主机名不能为空",
|
||||||
|
"base_url hostname cannot be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为明显的私有IP(但在 base_url 阶段不过于严格,主要在 request_url 阶段检查)
|
||||||
|
if is_suspicious_hostname(hostname) {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.base_url_suspicious",
|
||||||
|
"base_url 包含可疑的主机名",
|
||||||
|
"base_url contains a suspicious hostname",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证请求 URL 是否安全(防止 SSRF)
|
||||||
|
fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppError> {
|
||||||
|
// 解析请求 URL
|
||||||
|
let parsed_request = Url::parse(request_url).map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.request_url_invalid",
|
||||||
|
format!("无效的请求 URL: {e}"),
|
||||||
|
format!("Invalid request URL: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 解析 base URL
|
||||||
|
let parsed_base = Url::parse(base_url).map_err(|e| {
|
||||||
|
AppError::localized(
|
||||||
|
"usage_script.base_url_invalid",
|
||||||
|
format!("无效的 base_url: {e}"),
|
||||||
|
format!("Invalid base_url: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let is_request_loopback = is_loopback_host(&parsed_request);
|
||||||
|
|
||||||
|
// 必须使用 HTTPS(允许 localhost 用于开发)
|
||||||
|
if parsed_request.scheme() != "https" && !is_request_loopback {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.request_https_required",
|
||||||
|
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
|
||||||
|
"Request URL must use HTTPS (localhost allowed)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
|
||||||
|
if parsed_request.host_str() != parsed_base.host_str() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.request_host_mismatch",
|
||||||
|
format!(
|
||||||
|
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
|
||||||
|
parsed_request.host_str().unwrap_or("unknown"),
|
||||||
|
parsed_base.host_str().unwrap_or("unknown")
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"Request host {} must match base_url host {} (same-origin required)",
|
||||||
|
parsed_request.host_str().unwrap_or("unknown"),
|
||||||
|
parsed_base.host_str().unwrap_or("unknown")
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查端口是否匹配(考虑默认端口)
|
||||||
|
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443)
|
||||||
|
match (
|
||||||
|
parsed_request.port_or_known_default(),
|
||||||
|
parsed_base.port_or_known_default(),
|
||||||
|
) {
|
||||||
|
(Some(request_port), Some(base_port)) if request_port == base_port => {
|
||||||
|
// 端口匹配,继续执行
|
||||||
|
}
|
||||||
|
(Some(request_port), Some(base_port)) => {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.request_port_mismatch",
|
||||||
|
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
|
||||||
|
format!("Request port {request_port} must match base_url port {base_port}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.request_port_unknown",
|
||||||
|
"无法确定端口号",
|
||||||
|
"Unable to determine port number",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
|
||||||
|
if let Some(host) = parsed_request.host_str() {
|
||||||
|
let base_host = parsed_base.host_str().unwrap_or("");
|
||||||
|
|
||||||
|
// 如果 base_url 不是私有地址,则禁止访问私有IP
|
||||||
|
if !is_private_ip(base_host) && is_private_ip(host) {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.private_ip_blocked",
|
||||||
|
"禁止访问私有 IP 地址",
|
||||||
|
"Access to private IP addresses is blocked",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否为私有 IP 地址
|
||||||
|
fn is_private_ip(host: &str) -> bool {
|
||||||
|
// localhost 检查
|
||||||
|
if host.eq_ignore_ascii_case("localhost") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试解析为IP地址
|
||||||
|
if let Ok(ip_addr) = host.parse::<std::net::IpAddr>() {
|
||||||
|
return is_private_ip_addr(ip_addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是IP地址,不是私有IP
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用标准库API检查IP地址是否为私有地址
|
||||||
|
fn is_private_ip_addr(ip: std::net::IpAddr) -> bool {
|
||||||
|
match ip {
|
||||||
|
std::net::IpAddr::V4(ipv4) => {
|
||||||
|
let octets = ipv4.octets();
|
||||||
|
|
||||||
|
// 0.0.0.0/8 (包括未指定地址)
|
||||||
|
if octets[0] == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC1918 私有地址范围
|
||||||
|
// 10.0.0.0/8
|
||||||
|
if octets[0] == 10 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
|
||||||
|
if octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 192.168.0.0/16
|
||||||
|
if octets[0] == 192 && octets[1] == 168 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他特殊地址
|
||||||
|
// 169.254.0.0/16 (链路本地地址)
|
||||||
|
if octets[0] == 169 && octets[1] == 254 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 127.0.0.0/8 (环回地址)
|
||||||
|
if octets[0] == 127 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
std::net::IpAddr::V6(ipv6) => {
|
||||||
|
// IPv6 私有地址检查 - 使用标准库方法
|
||||||
|
|
||||||
|
// ::1 (环回地址)
|
||||||
|
if ipv6.is_loopback() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 唯一本地地址 (fc00::/7)
|
||||||
|
// Rust 1.70+ 可以使用 ipv6.is_unique_local()
|
||||||
|
// 但为了兼容性,我们手动检查
|
||||||
|
let first_segment = ipv6.segments()[0];
|
||||||
|
if (first_segment & 0xfe00) == 0xfc00 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 链路本地地址 (fe80::/10)
|
||||||
|
if (first_segment & 0xffc0) == 0xfe80 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未指定地址 ::
|
||||||
|
if ipv6.is_unspecified() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否为可疑的主机名(只检查明显不安全的模式)
|
||||||
|
fn is_suspicious_hostname(hostname: &str) -> bool {
|
||||||
|
// 空主机名
|
||||||
|
if hostname.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查明显的主机名格式问题
|
||||||
|
if hostname.contains("..") || hostname.starts_with(".") || hostname.ends_with(".") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为纯IP地址但没有合理格式(过于宽松的检查在这里可能不够,但主要依赖后续的同源检查)
|
||||||
|
if hostname.parse::<std::net::IpAddr>().is_ok() {
|
||||||
|
// IP地址格式的,在这里不直接拒绝,让同源检查来处理
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否包含明显不当的字符
|
||||||
|
let suspicious_chars = ['<', '>', '"', '\'', '\n', '\r', '\t', '\0'];
|
||||||
|
if hostname.chars().any(|c| suspicious_chars.contains(&c)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断 URL 是否指向本机(localhost / loopback)
|
||||||
|
fn is_loopback_host(url: &Url) -> bool {
|
||||||
|
match url.host() {
|
||||||
|
Some(Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
|
||||||
|
Some(Host::Ipv4(ip)) => ip.is_loopback(),
|
||||||
|
Some(Host::Ipv6(ip)) => ip.is_loopback(),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_private_ip_validation() {
|
||||||
|
// 测试IPv4私网地址
|
||||||
|
|
||||||
|
// RFC1918私网地址 - 应该返回true
|
||||||
|
assert!(is_private_ip("10.0.0.1"));
|
||||||
|
assert!(is_private_ip("10.255.255.254"));
|
||||||
|
assert!(is_private_ip("172.16.0.1"));
|
||||||
|
assert!(is_private_ip("172.31.255.255"));
|
||||||
|
assert!(is_private_ip("192.168.0.1"));
|
||||||
|
assert!(is_private_ip("192.168.255.255"));
|
||||||
|
|
||||||
|
// 链路本地地址 - 应该返回true
|
||||||
|
assert!(is_private_ip("169.254.0.1"));
|
||||||
|
assert!(is_private_ip("169.254.255.255"));
|
||||||
|
|
||||||
|
// 环回地址 - 应该返回true
|
||||||
|
assert!(is_private_ip("127.0.0.1"));
|
||||||
|
assert!(is_private_ip("localhost"));
|
||||||
|
|
||||||
|
// 公网172.x.x.x地址 - 应该返回false(这是修复的重点)
|
||||||
|
assert!(!is_private_ip("172.0.0.1"));
|
||||||
|
assert!(!is_private_ip("172.15.255.255"));
|
||||||
|
assert!(!is_private_ip("172.32.0.1"));
|
||||||
|
assert!(!is_private_ip("172.64.0.1"));
|
||||||
|
assert!(!is_private_ip("172.67.0.1")); // Cloudflare CDN
|
||||||
|
assert!(!is_private_ip("172.68.0.1"));
|
||||||
|
assert!(!is_private_ip("172.100.50.25"));
|
||||||
|
assert!(!is_private_ip("172.255.255.255"));
|
||||||
|
|
||||||
|
// 其他公网地址 - 应该返回false
|
||||||
|
assert!(!is_private_ip("8.8.8.8")); // Google DNS
|
||||||
|
assert!(!is_private_ip("1.1.1.1")); // Cloudflare DNS
|
||||||
|
assert!(!is_private_ip("208.67.222.222")); // OpenDNS
|
||||||
|
assert!(!is_private_ip("180.76.76.76")); // Baidu DNS
|
||||||
|
|
||||||
|
// 域名 - 应该返回false
|
||||||
|
assert!(!is_private_ip("api.example.com"));
|
||||||
|
assert!(!is_private_ip("www.google.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ipv6_private_validation() {
|
||||||
|
// IPv6私网地址
|
||||||
|
assert!(is_private_ip("::1")); // 环回地址
|
||||||
|
assert!(is_private_ip("fc00::1")); // 唯一本地地址
|
||||||
|
assert!(is_private_ip("fd00::1")); // 唯一本地地址
|
||||||
|
assert!(is_private_ip("fe80::1")); // 链路本地地址
|
||||||
|
assert!(is_private_ip("::")); // 未指定地址
|
||||||
|
|
||||||
|
// IPv6公网地址 - 应该返回false(修复的重点)
|
||||||
|
assert!(!is_private_ip("2001:4860:4860::8888")); // Google DNS IPv6
|
||||||
|
assert!(!is_private_ip("2606:4700:4700::1111")); // Cloudflare DNS IPv6
|
||||||
|
assert!(!is_private_ip("2404:6800:4001:c01::67")); // Google DNS IPv6 (其他格式)
|
||||||
|
assert!(!is_private_ip("2001:db8::1")); // 文档地址(非私网)
|
||||||
|
|
||||||
|
// 测试包含 ::1 子串但不是环回地址的公网地址
|
||||||
|
assert!(!is_private_ip("2001:db8::1abc")); // 包含 ::1abc 但不是环回
|
||||||
|
assert!(!is_private_ip("2606:4700::1")); // 包含 ::1 但不是环回
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hostname_bypass_prevention() {
|
||||||
|
// 看起来像本地,但实际是域名
|
||||||
|
assert!(!is_private_ip("127.0.0.1.evil.com"));
|
||||||
|
assert!(!is_private_ip("localhost.evil.com"));
|
||||||
|
|
||||||
|
// 0.0.0.0 应该被视为本地/阻断
|
||||||
|
assert!(is_private_ip("0.0.0.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_https_bypass_prevention() {
|
||||||
|
// 非本地域名的 HTTP 应该被拒绝
|
||||||
|
let result = validate_base_url("http://127.0.0.1.evil.com/api");
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"Should reject HTTP for non-localhost domains"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edge_cases() {
|
||||||
|
// 边界情况测试
|
||||||
|
assert!(is_private_ip("172.16.0.0")); // RFC1918起始
|
||||||
|
assert!(is_private_ip("172.31.255.255")); // RFC1918结束
|
||||||
|
assert!(is_private_ip("10.0.0.0")); // 10.0.0.0/8起始
|
||||||
|
assert!(is_private_ip("10.255.255.255")); // 10.0.0.0/8结束
|
||||||
|
assert!(is_private_ip("192.168.0.0")); // 192.168.0.0/16起始
|
||||||
|
assert!(is_private_ip("192.168.255.255")); // 192.168.0.0/16结束
|
||||||
|
|
||||||
|
// 紧邻RFC1918的公网地址 - 应该返回false
|
||||||
|
assert!(!is_private_ip("172.15.255.255")); // 172.16.0.0的前一个
|
||||||
|
assert!(!is_private_ip("172.32.0.0")); // 172.31.255.255的后一个
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ip_addr_parsing() {
|
||||||
|
// 测试IP地址解析功能
|
||||||
|
let ipv4_private = "10.0.0.1".parse::<std::net::IpAddr>().unwrap();
|
||||||
|
assert!(is_private_ip_addr(ipv4_private));
|
||||||
|
|
||||||
|
let ipv4_public = "172.67.0.1".parse::<std::net::IpAddr>().unwrap();
|
||||||
|
assert!(!is_private_ip_addr(ipv4_public));
|
||||||
|
|
||||||
|
let ipv6_private = "fc00::1".parse::<std::net::IpAddr>().unwrap();
|
||||||
|
assert!(is_private_ip_addr(ipv6_private));
|
||||||
|
|
||||||
|
let ipv6_public = "2001:4860:4860::8888".parse::<std::net::IpAddr>().unwrap();
|
||||||
|
assert!(!is_private_ip_addr(ipv6_public));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_port_comparison() {
|
||||||
|
// 测试端口比较逻辑是否正确处理默认端口和显式端口
|
||||||
|
|
||||||
|
// 测试用例:(base_url, request_url, should_match)
|
||||||
|
let test_cases = vec![
|
||||||
|
// HTTPS默认端口测试
|
||||||
|
(
|
||||||
|
"https://api.example.com",
|
||||||
|
"https://api.example.com/v1/test",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://api.example.com",
|
||||||
|
"https://api.example.com:443/v1/test",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://api.example.com:443",
|
||||||
|
"https://api.example.com/v1/test",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://api.example.com:443",
|
||||||
|
"https://api.example.com:443/v1/test",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
// 端口不匹配测试
|
||||||
|
(
|
||||||
|
"https://api.example.com",
|
||||||
|
"https://api.example.com:8443/v1/test",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://api.example.com:443",
|
||||||
|
"https://api.example.com:8443/v1/test",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (base_url, request_url, should_match) in test_cases {
|
||||||
|
let result = validate_request_url(request_url, base_url);
|
||||||
|
|
||||||
|
if should_match {
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"应该匹配的URL被拒绝: base_url={}, request_url={}, error={}",
|
||||||
|
base_url,
|
||||||
|
request_url,
|
||||||
|
result.unwrap_err()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"应该不匹配的URL被允许: base_url={}, request_url={}",
|
||||||
|
base_url,
|
||||||
|
request_url
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,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.8.3",
|
"version": "3.9.0-2",
|
||||||
"identifier": "com.ccswitch.desktop",
|
"identifier": "com.ccswitch.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"titleBarStyle": "Visible",
|
||||||
|
"minWidth": 900,
|
||||||
|
"minHeight": 600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use cc_switch_lib::{import_provider_from_deeplink, parse_deeplink_url, AppState, Database};
|
use cc_switch_lib::{
|
||||||
|
import_provider_from_deeplink, parse_deeplink_url, AppState, Database, ProxyService,
|
||||||
|
};
|
||||||
|
|
||||||
#[path = "support.rs"]
|
#[path = "support.rs"]
|
||||||
mod support;
|
mod support;
|
||||||
@@ -16,8 +18,11 @@ fn deeplink_import_claude_provider_persists_to_db() {
|
|||||||
let request = parse_deeplink_url(url).expect("parse deeplink url");
|
let request = parse_deeplink_url(url).expect("parse deeplink url");
|
||||||
|
|
||||||
let db = Arc::new(Database::memory().expect("create memory db"));
|
let db = Arc::new(Database::memory().expect("create memory db"));
|
||||||
|
let proxy_service = ProxyService::new(db.clone());
|
||||||
let state = AppState { db: db.clone() };
|
let state = AppState {
|
||||||
|
db: db.clone(),
|
||||||
|
proxy_service,
|
||||||
|
};
|
||||||
|
|
||||||
let provider_id = import_provider_from_deeplink(&state, request.clone())
|
let provider_id = import_provider_from_deeplink(&state, request.clone())
|
||||||
.expect("import provider from deeplink");
|
.expect("import provider from deeplink");
|
||||||
@@ -53,8 +58,11 @@ fn deeplink_import_codex_provider_builds_auth_and_config() {
|
|||||||
let request = parse_deeplink_url(url).expect("parse deeplink url");
|
let request = parse_deeplink_url(url).expect("parse deeplink url");
|
||||||
|
|
||||||
let db = Arc::new(Database::memory().expect("create memory db"));
|
let db = Arc::new(Database::memory().expect("create memory db"));
|
||||||
|
let proxy_service = ProxyService::new(db.clone());
|
||||||
let state = AppState { db: db.clone() };
|
let state = AppState {
|
||||||
|
db: db.clone(),
|
||||||
|
proxy_service,
|
||||||
|
};
|
||||||
|
|
||||||
let provider_id = import_provider_from_deeplink(&state, request.clone())
|
let provider_id = import_provider_from_deeplink(&state, request.clone())
|
||||||
.expect("import provider from deeplink");
|
.expect("import provider from deeplink");
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
use cc_switch_lib::{update_settings, AppSettings, AppState, Database, MultiAppConfig};
|
use cc_switch_lib::{
|
||||||
|
update_settings, AppSettings, AppState, Database, MultiAppConfig, ProxyService,
|
||||||
|
};
|
||||||
|
|
||||||
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
|
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
|
||||||
pub fn ensure_test_home() -> &'static Path {
|
pub fn ensure_test_home() -> &'static Path {
|
||||||
@@ -48,15 +50,17 @@ pub fn test_mutex() -> &'static Mutex<()> {
|
|||||||
|
|
||||||
/// 创建测试用的 AppState,包含一个空的数据库
|
/// 创建测试用的 AppState,包含一个空的数据库
|
||||||
pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
|
pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
|
||||||
let db = Database::init()?;
|
let db = Arc::new(Database::init()?);
|
||||||
Ok(AppState { db: Arc::new(db) })
|
let proxy_service = ProxyService::new(db.clone());
|
||||||
|
Ok(AppState { db, proxy_service })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据
|
/// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据
|
||||||
pub fn create_test_state_with_config(
|
pub fn create_test_state_with_config(
|
||||||
config: &MultiAppConfig,
|
config: &MultiAppConfig,
|
||||||
) -> Result<AppState, Box<dyn std::error::Error>> {
|
) -> Result<AppState, Box<dyn std::error::Error>> {
|
||||||
let db = Database::init()?;
|
let db = Arc::new(Database::init()?);
|
||||||
db.migrate_from_json(config)?;
|
db.migrate_from_json(config)?;
|
||||||
Ok(AppState { db: Arc::new(db) })
|
let proxy_service = ProxyService::new(db.clone());
|
||||||
|
Ok(AppState { db, proxy_service })
|
||||||
}
|
}
|
||||||
|
|||||||
+182
-97
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState, useRef } from "react";
|
import { useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
||||||
import { useProviderActions } from "@/hooks/useProviderActions";
|
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||||
|
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AppSwitcher } from "@/components/AppSwitcher";
|
import { AppSwitcher } from "@/components/AppSwitcher";
|
||||||
@@ -34,6 +36,7 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|||||||
import { SettingsPage } from "@/components/settings/SettingsPage";
|
import { SettingsPage } from "@/components/settings/SettingsPage";
|
||||||
import { UpdateBadge } from "@/components/UpdateBadge";
|
import { UpdateBadge } from "@/components/UpdateBadge";
|
||||||
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
|
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
|
||||||
|
import { ProxyToggle } from "@/components/proxy/ProxyToggle";
|
||||||
import UsageScriptModal from "@/components/UsageScriptModal";
|
import UsageScriptModal from "@/components/UsageScriptModal";
|
||||||
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
||||||
import PromptPanel from "@/components/prompts/PromptPanel";
|
import PromptPanel from "@/components/prompts/PromptPanel";
|
||||||
@@ -44,7 +47,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
|
|
||||||
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
|
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
|
||||||
|
|
||||||
// 顶部拖拽区域和 header 的高度常量
|
|
||||||
const DRAG_BAR_HEIGHT = 28; // px
|
const DRAG_BAR_HEIGHT = 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;
|
||||||
@@ -63,16 +65,52 @@ function App() {
|
|||||||
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
||||||
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
||||||
|
|
||||||
|
// 保存最后一个有效的 provider,用于动画退出期间显示内容
|
||||||
|
const lastUsageProviderRef = useRef<Provider | null>(null);
|
||||||
|
const lastEditingProviderRef = useRef<Provider | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (usageProvider) {
|
||||||
|
lastUsageProviderRef.current = usageProvider;
|
||||||
|
}
|
||||||
|
}, [usageProvider]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingProvider) {
|
||||||
|
lastEditingProviderRef.current = editingProvider;
|
||||||
|
}
|
||||||
|
}, [editingProvider]);
|
||||||
|
|
||||||
const promptPanelRef = useRef<any>(null);
|
const promptPanelRef = useRef<any>(null);
|
||||||
const mcpPanelRef = useRef<any>(null);
|
const mcpPanelRef = useRef<any>(null);
|
||||||
const skillsPageRef = useRef<any>(null);
|
const skillsPageRef = useRef<any>(null);
|
||||||
const addActionButtonClass =
|
const addActionButtonClass =
|
||||||
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
|
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
|
// 获取代理服务状态
|
||||||
|
const {
|
||||||
|
isRunning: isProxyRunning,
|
||||||
|
takeoverStatus,
|
||||||
|
status: proxyStatus,
|
||||||
|
} = useProxyStatus();
|
||||||
|
// 当前应用的代理是否开启
|
||||||
|
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
|
||||||
|
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取)
|
||||||
|
const activeProviderId = useMemo(() => {
|
||||||
|
const target = proxyStatus?.active_targets?.find(
|
||||||
|
(t) => t.app_type === activeApp,
|
||||||
|
);
|
||||||
|
return target?.provider_id;
|
||||||
|
}, [proxyStatus?.active_targets, activeApp]);
|
||||||
|
|
||||||
|
// 获取供应商列表,当代理服务运行时自动刷新
|
||||||
|
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
|
||||||
|
isProxyRunning,
|
||||||
|
});
|
||||||
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||||
const currentProviderId = data?.currentProviderId ?? "";
|
const currentProviderId = data?.currentProviderId ?? "";
|
||||||
const isClaudeApp = activeApp === "claude";
|
// Skills 功能仅支持 Claude 和 Codex
|
||||||
|
const hasSkillsSupport = activeApp === "claude" || activeApp === "codex";
|
||||||
|
|
||||||
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
|
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
|
||||||
const {
|
const {
|
||||||
@@ -140,6 +178,7 @@ function App() {
|
|||||||
if (migrated) {
|
if (migrated) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("migration.success", { defaultValue: "配置迁移成功" }),
|
t("migration.success", { defaultValue: "配置迁移成功" }),
|
||||||
|
{ closeButton: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -287,76 +326,109 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
switch (currentView) {
|
const content = (() => {
|
||||||
case "settings":
|
switch (currentView) {
|
||||||
return (
|
case "settings":
|
||||||
<SettingsPage
|
return (
|
||||||
open={true}
|
<SettingsPage
|
||||||
onOpenChange={() => setCurrentView("providers")}
|
open={true}
|
||||||
onImportSuccess={handleImportSuccess}
|
onOpenChange={() => setCurrentView("providers")}
|
||||||
/>
|
onImportSuccess={handleImportSuccess}
|
||||||
);
|
/>
|
||||||
case "prompts":
|
);
|
||||||
return (
|
case "prompts":
|
||||||
<PromptPanel
|
return (
|
||||||
ref={promptPanelRef}
|
<PromptPanel
|
||||||
open={true}
|
ref={promptPanelRef}
|
||||||
onOpenChange={() => setCurrentView("providers")}
|
open={true}
|
||||||
appId={activeApp}
|
onOpenChange={() => setCurrentView("providers")}
|
||||||
/>
|
appId={activeApp}
|
||||||
);
|
/>
|
||||||
case "skills":
|
);
|
||||||
return (
|
case "skills":
|
||||||
<SkillsPage
|
return (
|
||||||
ref={skillsPageRef}
|
<SkillsPage
|
||||||
onClose={() => setCurrentView("providers")}
|
ref={skillsPageRef}
|
||||||
/>
|
onClose={() => setCurrentView("providers")}
|
||||||
);
|
initialApp={activeApp}
|
||||||
case "mcp":
|
/>
|
||||||
return (
|
);
|
||||||
<UnifiedMcpPanel
|
case "mcp":
|
||||||
ref={mcpPanelRef}
|
return (
|
||||||
onOpenChange={() => setCurrentView("providers")}
|
<UnifiedMcpPanel
|
||||||
/>
|
ref={mcpPanelRef}
|
||||||
);
|
onOpenChange={() => setCurrentView("providers")}
|
||||||
case "agents":
|
/>
|
||||||
return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
|
);
|
||||||
default:
|
case "agents":
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
<AgentsPanel onOpenChange={() => setCurrentView("providers")} />
|
||||||
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
|
);
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
default:
|
||||||
<div className="space-y-4">
|
return (
|
||||||
<ProviderList
|
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||||
providers={providers}
|
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
|
||||||
currentProviderId={currentProviderId}
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
||||||
appId={activeApp}
|
<AnimatePresence mode="wait">
|
||||||
isLoading={isLoading}
|
<motion.div
|
||||||
onSwitch={switchProvider}
|
key={activeApp}
|
||||||
onEdit={setEditingProvider}
|
initial={{ opacity: 0 }}
|
||||||
onDelete={setConfirmDelete}
|
animate={{ opacity: 1 }}
|
||||||
onDuplicate={handleDuplicateProvider}
|
exit={{ opacity: 0 }}
|
||||||
onConfigureUsage={setUsageProvider}
|
transition={{ duration: 0.15 }}
|
||||||
onOpenWebsite={handleOpenWebsite}
|
className="space-y-4"
|
||||||
onCreate={() => setIsAddOpen(true)}
|
>
|
||||||
/>
|
<ProviderList
|
||||||
|
providers={providers}
|
||||||
|
currentProviderId={currentProviderId}
|
||||||
|
appId={activeApp}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isProxyRunning={isProxyRunning}
|
||||||
|
isProxyTakeover={
|
||||||
|
isProxyRunning && isCurrentAppTakeoverActive
|
||||||
|
}
|
||||||
|
activeProviderId={activeProviderId}
|
||||||
|
onSwitch={switchProvider}
|
||||||
|
onEdit={setEditingProvider}
|
||||||
|
onDelete={setConfirmDelete}
|
||||||
|
onDuplicate={handleDuplicateProvider}
|
||||||
|
onConfigureUsage={setUsageProvider}
|
||||||
|
onOpenWebsite={handleOpenWebsite}
|
||||||
|
onCreate={() => setIsAddOpen(true)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
}
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={currentView}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex min-h-screen flex-col bg-background text-foreground selection:bg-primary/30"
|
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30"
|
||||||
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
|
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
|
||||||
>
|
>
|
||||||
{/* 全局拖拽区域(顶部 4px),避免上边框无法拖动 */}
|
{/* 全局拖拽区域(顶部 28px),避免上边框无法拖动 */}
|
||||||
<div
|
<div
|
||||||
className="fixed top-0 left-0 right-0 h-4 z-[60]"
|
className="fixed top-0 left-0 right-0 z-[60]"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
style={{ WebkitAppRegion: "drag" } as any}
|
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
|
||||||
/>
|
/>
|
||||||
{/* 环境变量警告横幅 */}
|
{/* 环境变量警告横幅 */}
|
||||||
{showEnvBanner && envConflicts.length > 0 && (
|
{showEnvBanner && envConflicts.length > 0 && (
|
||||||
@@ -386,13 +458,18 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<header
|
<header
|
||||||
className="glass-header fixed top-0 z-50 w-full py-3 transition-all duration-300"
|
className="fixed z-50 w-full transition-all duration-300 bg-background/80 backdrop-blur-md"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
style={{ WebkitAppRegion: "drag" } as any}
|
style={
|
||||||
|
{
|
||||||
|
WebkitAppRegion: "drag",
|
||||||
|
top: DRAG_BAR_HEIGHT,
|
||||||
|
height: HEADER_HEIGHT,
|
||||||
|
} as any
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="h-4 w-full" aria-hidden data-tauri-drag-region />
|
|
||||||
<div
|
<div
|
||||||
className="mx-auto max-w-[56rem] px-6 flex flex-wrap items-center justify-between gap-2"
|
className="mx-auto flex h-full max-w-[56rem] flex-wrap items-center justify-between gap-2 px-6"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
style={{ WebkitAppRegion: "drag" } as any}
|
style={{ WebkitAppRegion: "drag" } as any}
|
||||||
>
|
>
|
||||||
@@ -408,7 +485,7 @@ function App() {
|
|||||||
onClick={() => setCurrentView("providers")}
|
onClick={() => setCurrentView("providers")}
|
||||||
className="mr-2 rounded-lg"
|
className="mr-2 rounded-lg"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-lg font-semibold">
|
<h1 className="text-lg font-semibold">
|
||||||
{currentView === "settings" && t("settings.title")}
|
{currentView === "settings" && t("settings.title")}
|
||||||
@@ -426,7 +503,12 @@ function App() {
|
|||||||
href="https://github.com/farion1231/cc-switch"
|
href="https://github.com/farion1231/cc-switch"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
className={cn(
|
||||||
|
"text-xl font-semibold transition-colors",
|
||||||
|
isProxyRunning && isCurrentAppTakeoverActive
|
||||||
|
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
|
||||||
|
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
CC Switch
|
CC Switch
|
||||||
</a>
|
</a>
|
||||||
@@ -437,7 +519,7 @@ function App() {
|
|||||||
title={t("common.settings")}
|
title={t("common.settings")}
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<UpdateBadge onClick={() => setCurrentView("settings")} />
|
<UpdateBadge onClick={() => setCurrentView("settings")} />
|
||||||
@@ -456,7 +538,7 @@ function App() {
|
|||||||
className={`ml-auto ${addActionButtonClass}`}
|
className={`ml-auto ${addActionButtonClass}`}
|
||||||
title={t("prompts.add")}
|
title={t("prompts.add")}
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{currentView === "mcp" && (
|
{currentView === "mcp" && (
|
||||||
@@ -466,7 +548,7 @@ function App() {
|
|||||||
className={`ml-auto ${addActionButtonClass}`}
|
className={`ml-auto ${addActionButtonClass}`}
|
||||||
title={t("mcp.unifiedPanel.addServer")}
|
title={t("mcp.unifiedPanel.addServer")}
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{currentView === "skills" && (
|
{currentView === "skills" && (
|
||||||
@@ -477,7 +559,7 @@ function App() {
|
|||||||
onClick={() => skillsPageRef.current?.refresh()}
|
onClick={() => skillsPageRef.current?.refresh()}
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
{t("skills.refresh")}
|
{t("skills.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -486,16 +568,18 @@ function App() {
|
|||||||
onClick={() => skillsPageRef.current?.openRepoManager()}
|
onClick={() => skillsPageRef.current?.openRepoManager()}
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
{t("skills.repoManager")}
|
{t("skills.repoManager")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{currentView === "providers" && (
|
{currentView === "providers" && (
|
||||||
<>
|
<>
|
||||||
|
<ProxyToggle activeApp={activeApp} />
|
||||||
|
|
||||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||||
|
|
||||||
<div className="glass p-1 rounded-xl flex items-center gap-1">
|
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -503,26 +587,26 @@ function App() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
||||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||||
isClaudeApp
|
hasSkillsSupport
|
||||||
? "opacity-100 w-8 scale-100 px-2"
|
? "opacity-100 w-8 scale-100 px-2"
|
||||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||||
)}
|
)}
|
||||||
title={t("skills.manage")}
|
title={t("skills.manage")}
|
||||||
>
|
>
|
||||||
<Wrench className="h-4 w-4 flex-shrink-0" />
|
<Wrench className="flex-shrink-0 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
|
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
|
||||||
{/* {isClaudeApp && (
|
{/* {isClaudeApp && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentView("agents")}
|
onClick={() => setCurrentView("agents")}
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
title="Agents"
|
title="Agents"
|
||||||
>
|
>
|
||||||
<Bot className="h-4 w-4" />
|
<Bot className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)} */}
|
)} */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -530,7 +614,7 @@ function App() {
|
|||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
title={t("prompts.manage")}
|
title={t("prompts.manage")}
|
||||||
>
|
>
|
||||||
<Book className="h-4 w-4" />
|
<Book className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -539,7 +623,7 @@ function App() {
|
|||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
title={t("mcp.title")}
|
title={t("mcp.title")}
|
||||||
>
|
>
|
||||||
<Server className="h-4 w-4" />
|
<Server className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -548,7 +632,7 @@ function App() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className={`ml-2 ${addActionButtonClass}`}
|
className={`ml-2 ${addActionButtonClass}`}
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -557,9 +641,7 @@ function App() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 pb-12 animate-fade-in ">
|
<main className="flex-1 pb-12 animate-fade-in ">
|
||||||
<div className="pb-12">
|
<div className="pb-12">{renderContent()}</div>
|
||||||
{renderContent()}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AddProviderDialog
|
<AddProviderDialog
|
||||||
@@ -571,7 +653,7 @@ function App() {
|
|||||||
|
|
||||||
<EditProviderDialog
|
<EditProviderDialog
|
||||||
open={Boolean(editingProvider)}
|
open={Boolean(editingProvider)}
|
||||||
provider={editingProvider}
|
provider={lastEditingProviderRef.current}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setEditingProvider(null);
|
setEditingProvider(null);
|
||||||
@@ -579,16 +661,19 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
onSubmit={handleEditProvider}
|
onSubmit={handleEditProvider}
|
||||||
appId={activeApp}
|
appId={activeApp}
|
||||||
|
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{usageProvider && (
|
{lastUsageProviderRef.current && (
|
||||||
<UsageScriptModal
|
<UsageScriptModal
|
||||||
provider={usageProvider}
|
provider={lastUsageProviderRef.current}
|
||||||
appId={activeApp}
|
appId={activeApp}
|
||||||
isOpen={Boolean(usageProvider)}
|
isOpen={Boolean(usageProvider)}
|
||||||
onClose={() => setUsageProvider(null)}
|
onClose={() => setUsageProvider(null)}
|
||||||
onSave={(script) => {
|
onSave={(script) => {
|
||||||
void saveUsageScript(usageProvider, script);
|
if (usageProvider) {
|
||||||
|
void saveUsageScript(usageProvider, script);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1">
|
<div className="inline-flex bg-muted rounded-xl p-1 gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSwitch("claude")}
|
onClick={() => handleSwitch("claude")}
|
||||||
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
activeApp === "claude"
|
activeApp === "claude"
|
||||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
|
? "bg-background text-foreground shadow-sm"
|
||||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
@@ -41,7 +41,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
className={
|
className={
|
||||||
activeApp === "claude"
|
activeApp === "claude"
|
||||||
? "text-foreground"
|
? "text-foreground"
|
||||||
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
|
: "text-muted-foreground group-hover:text-foreground transition-colors"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>{appDisplayName.claude}</span>
|
<span>{appDisplayName.claude}</span>
|
||||||
@@ -50,10 +50,10 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSwitch("codex")}
|
onClick={() => handleSwitch("codex")}
|
||||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
activeApp === "codex"
|
activeApp === "codex"
|
||||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
|
? "bg-background text-foreground shadow-sm"
|
||||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
@@ -63,7 +63,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
className={
|
className={
|
||||||
activeApp === "codex"
|
activeApp === "codex"
|
||||||
? "text-foreground"
|
? "text-foreground"
|
||||||
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
|
: "text-muted-foreground group-hover:text-foreground transition-colors"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>{appDisplayName.codex}</span>
|
<span>{appDisplayName.codex}</span>
|
||||||
@@ -72,10 +72,10 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSwitch("gemini")}
|
onClick={() => handleSwitch("gemini")}
|
||||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
activeApp === "gemini"
|
activeApp === "gemini"
|
||||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
|
? "bg-background text-foreground shadow-sm"
|
||||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
@@ -85,7 +85,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
className={
|
className={
|
||||||
activeApp === "gemini"
|
activeApp === "gemini"
|
||||||
? "text-foreground"
|
? "text-foreground"
|
||||||
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
|
: "text-muted-foreground group-hover:text-foreground transition-colors"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>{appDisplayName.gemini}</span>
|
<span>{appDisplayName.gemini}</span>
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export function DeepLinkImportDialog() {
|
|||||||
description: t("deeplink.mcpImportSuccessDescription", {
|
description: t("deeplink.mcpImportSuccessDescription", {
|
||||||
count: summary.importedCount,
|
count: summary.importedCount,
|
||||||
}),
|
}),
|
||||||
|
closeButton: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -142,6 +143,7 @@ export function DeepLinkImportDialog() {
|
|||||||
description: t("deeplink.importSuccessDescription", {
|
description: t("deeplink.importSuccessDescription", {
|
||||||
name: request.name,
|
name: request.name,
|
||||||
}),
|
}),
|
||||||
|
closeButton: true,
|
||||||
});
|
});
|
||||||
} else if (result.type === "prompt") {
|
} else if (result.type === "prompt") {
|
||||||
// Prompts don't use React Query, trigger a custom event for refresh
|
// Prompts don't use React Query, trigger a custom event for refresh
|
||||||
@@ -154,6 +156,7 @@ export function DeepLinkImportDialog() {
|
|||||||
description: t("deeplink.promptImportSuccessDescription", {
|
description: t("deeplink.promptImportSuccessDescription", {
|
||||||
name: request.name,
|
name: request.name,
|
||||||
}),
|
}),
|
||||||
|
closeButton: true,
|
||||||
});
|
});
|
||||||
} else if (result.type === "mcp") {
|
} else if (result.type === "mcp") {
|
||||||
await refreshMcp(result);
|
await refreshMcp(result);
|
||||||
@@ -171,6 +174,7 @@ export function DeepLinkImportDialog() {
|
|||||||
description: t("deeplink.skillImportSuccessDescription", {
|
description: t("deeplink.skillImportSuccessDescription", {
|
||||||
repo: request.repo,
|
repo: request.repo,
|
||||||
}),
|
}),
|
||||||
|
closeButton: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (isMcpImportResult(result)) {
|
} else if (isMcpImportResult(result)) {
|
||||||
@@ -185,6 +189,7 @@ export function DeepLinkImportDialog() {
|
|||||||
description: t("deeplink.importSuccessDescription", {
|
description: t("deeplink.importSuccessDescription", {
|
||||||
name: request.name,
|
name: request.name,
|
||||||
}),
|
}),
|
||||||
|
closeButton: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,6 +610,86 @@ export function DeepLinkImportDialog() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Usage Script Configuration (v3.9+) */}
|
||||||
|
{request.usageScript && (
|
||||||
|
<div className="space-y-3 pt-2 border-t border-border-default">
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.usageScript", {
|
||||||
|
defaultValue: "用量查询",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium ${
|
||||||
|
request.usageEnabled !== false
|
||||||
|
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
|
||||||
|
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{request.usageEnabled !== false
|
||||||
|
? t("deeplink.usageScriptEnabled", {
|
||||||
|
defaultValue: "已启用",
|
||||||
|
})
|
||||||
|
: t("deeplink.usageScriptDisabled", {
|
||||||
|
defaultValue: "未启用",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage API Key (if different from provider) */}
|
||||||
|
{request.usageApiKey &&
|
||||||
|
request.usageApiKey !== request.apiKey && (
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.usageApiKey", {
|
||||||
|
defaultValue: "用量 API Key",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm font-mono text-muted-foreground">
|
||||||
|
{request.usageApiKey.length > 4
|
||||||
|
? `${request.usageApiKey.substring(0, 4)}${"*".repeat(12)}`
|
||||||
|
: "****"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Usage Base URL (if different from provider) */}
|
||||||
|
{request.usageBaseUrl &&
|
||||||
|
request.usageBaseUrl !== request.endpoint && (
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.usageBaseUrl", {
|
||||||
|
defaultValue: "用量查询地址",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm break-all">
|
||||||
|
{request.usageBaseUrl}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Auto Query Interval */}
|
||||||
|
{request.usageAutoInterval &&
|
||||||
|
request.usageAutoInterval > 0 && (
|
||||||
|
<div className="grid grid-cols-3 items-center gap-4">
|
||||||
|
<div className="font-medium text-sm text-muted-foreground">
|
||||||
|
{t("deeplink.usageAutoInterval", {
|
||||||
|
defaultValue: "自动查询",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm">
|
||||||
|
{t("deeplink.usageAutoIntervalValue", {
|
||||||
|
defaultValue: "每 {{minutes}} 分钟",
|
||||||
|
minutes: request.usageAutoInterval,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Warning */}
|
{/* Warning */}
|
||||||
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
{t("deeplink.warning")}
|
{t("deeplink.warning")}
|
||||||
|
|||||||
@@ -234,7 +234,9 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
try {
|
try {
|
||||||
const formatted = formatJSON(currentValue);
|
const formatted = formatJSON(currentValue);
|
||||||
onChange(formatted);
|
onChange(formatted);
|
||||||
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }), {
|
||||||
|
closeButton: true,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface ProviderIconProps {
|
|||||||
export const ProviderIcon: React.FC<ProviderIconProps> = ({
|
export const ProviderIcon: React.FC<ProviderIconProps> = ({
|
||||||
icon,
|
icon,
|
||||||
name,
|
name,
|
||||||
|
color,
|
||||||
size = 32,
|
size = 32,
|
||||||
className,
|
className,
|
||||||
showFallback = true,
|
showFallback = true,
|
||||||
@@ -46,7 +47,7 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
|
|||||||
"inline-flex items-center justify-center flex-shrink-0",
|
"inline-flex items-center justify-center flex-shrink-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={sizeStyle}
|
style={{ ...sizeStyle, color }}
|
||||||
dangerouslySetInnerHTML={{ __html: iconSvg }}
|
dangerouslySetInnerHTML={{ __html: iconSvg }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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 { Provider, UsageScript } from "@/types";
|
import { Provider, UsageScript, UsageData } from "@/types";
|
||||||
import { usageApi, type AppId } from "@/lib/api";
|
import { usageApi, type AppId } from "@/lib/api";
|
||||||
import JsonEditor from "./JsonEditor";
|
import JsonEditor from "./JsonEditor";
|
||||||
import * as prettier from "prettier/standalone";
|
import * as prettier from "prettier/standalone";
|
||||||
@@ -220,13 +220,14 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
);
|
);
|
||||||
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
|
||||||
.map((plan) => {
|
.map((plan: UsageData) => {
|
||||||
const planInfo = plan.planName ? `[${plan.planName}]` : "";
|
const planInfo = plan.planName ? `[${plan.planName}]` : "";
|
||||||
return `${planInfo} ${t("usage.remaining")} ${plan.remaining} ${plan.unit}`;
|
return `${planInfo} ${t("usage.remaining")} ${plan.remaining} ${plan.unit}`;
|
||||||
})
|
})
|
||||||
.join(", ");
|
.join(", ");
|
||||||
toast.success(`${t("usageScript.testSuccess")}${summary}`, {
|
toast.success(`${t("usageScript.testSuccess")}${summary}`, {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
|
closeButton: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -259,7 +260,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
printWidth: 80,
|
printWidth: 80,
|
||||||
});
|
});
|
||||||
setScript({ ...script, code: formatted.trim() });
|
setScript({ ...script, code: formatted.trim() });
|
||||||
toast.success(t("usageScript.formatSuccess"), { duration: 1000 });
|
toast.success(t("usageScript.formatSuccess"), {
|
||||||
|
duration: 1000,
|
||||||
|
closeButton: true,
|
||||||
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`${t("usageScript.formatFailed")}: ${error?.message || t("jsonEditor.invalidJson")}`,
|
`${t("usageScript.formatFailed")}: ${error?.message || t("jsonEditor.invalidJson")}`,
|
||||||
@@ -400,15 +404,25 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
{/* 凭证配置 */}
|
{/* 凭证配置 */}
|
||||||
{shouldShowCredentialsConfig && (
|
{shouldShowCredentialsConfig && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-sm font-medium text-foreground">
|
<div className="flex items-start justify-between">
|
||||||
{t("usageScript.credentialsConfig")}
|
<h4 className="text-sm font-medium text-foreground">
|
||||||
</h4>
|
{t("usageScript.credentialsConfig")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("usageScript.credentialsHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
|
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="usage-api-key">API Key</Label>
|
<Label htmlFor="usage-api-key">
|
||||||
|
API Key{" "}
|
||||||
|
<span className="text-xs text-muted-foreground font-normal">
|
||||||
|
({t("usageScript.optional")})
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="usage-api-key"
|
id="usage-api-key"
|
||||||
@@ -417,7 +431,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setScript({ ...script, apiKey: e.target.value })
|
setScript({ ...script, apiKey: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder="sk-xxxxx"
|
placeholder={t("usageScript.apiKeyPlaceholder")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="border-white/10"
|
className="border-white/10"
|
||||||
/>
|
/>
|
||||||
@@ -444,7 +458,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="usage-base-url">
|
<Label htmlFor="usage-base-url">
|
||||||
{t("usageScript.baseUrl")}
|
{t("usageScript.baseUrl")}{" "}
|
||||||
|
<span className="text-xs text-muted-foreground font-normal">
|
||||||
|
({t("usageScript.optional")})
|
||||||
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="usage-base-url"
|
id="usage-base-url"
|
||||||
@@ -453,7 +470,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setScript({ ...script, baseUrl: e.target.value })
|
setScript({ ...script, baseUrl: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder="https://api.example.com"
|
placeholder={t("usageScript.baseUrlPlaceholder")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="border-white/10"
|
className="border-white/10"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -32,46 +33,57 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<AnimatePresence>
|
||||||
className="fixed inset-0 z-[60] flex flex-col"
|
{isOpen && (
|
||||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
<motion.div
|
||||||
>
|
initial={{ opacity: 0 }}
|
||||||
{/* Header */}
|
animate={{ opacity: 1 }}
|
||||||
<div
|
exit={{ opacity: 0 }}
|
||||||
className="flex-shrink-0 py-3 border-b border-border-default"
|
transition={{ duration: 0.2 }}
|
||||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
className="fixed inset-0 z-[60] flex flex-col"
|
||||||
>
|
|
||||||
<div className="h-4 w-full" data-tauri-drag-region />
|
|
||||||
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
|
|
||||||
<Button type="button" variant="outline" size="icon" onClick={onClose}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto scroll-overlay">
|
|
||||||
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
{footer && (
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 py-4 border-t border-border-default"
|
|
||||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
style={{ backgroundColor: "hsl(var(--background))" }}
|
||||||
>
|
>
|
||||||
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
|
{/* Header */}
|
||||||
{footer}
|
<div
|
||||||
|
className="flex-shrink-0 py-3 border-b border-border-default"
|
||||||
|
style={{ backgroundColor: "hsl(var(--background))" }}
|
||||||
|
>
|
||||||
|
<div className="h-4 w-full" data-tauri-drag-region />
|
||||||
|
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto scroll-overlay">
|
||||||
|
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{footer && (
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 py-4 border-t border-border-default"
|
||||||
|
style={{ backgroundColor: "hsl(var(--background))" }}
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</AnimatePresence>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+1
@@ -79,6 +79,7 @@ export function EnvWarningBanner({
|
|||||||
path: backupInfo.backupPath,
|
path: backupInfo.backupPath,
|
||||||
}),
|
}),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
|
closeButton: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 清空选择并通知父组件
|
// 清空选择并通知父组件
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await upsertMutation.mutateAsync(entry);
|
await upsertMutation.mutateAsync(entry);
|
||||||
toast.success(t("common.success"));
|
toast.success(t("common.success"), { closeButton: true });
|
||||||
await onSave();
|
await onSave();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const detail = extractErrorMessage(error);
|
const detail = extractErrorMessage(error);
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
try {
|
try {
|
||||||
await deleteServerMutation.mutateAsync(id);
|
await deleteServerMutation.mutateAsync(id);
|
||||||
setConfirmDialog(null);
|
setConfirmDialog(null);
|
||||||
toast.success(t("common.success"));
|
toast.success(t("common.success"), { closeButton: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("common.error"), {
|
toast.error(t("common.error"), {
|
||||||
description: String(error),
|
description: String(error),
|
||||||
@@ -237,9 +237,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|||||||
{/* 左侧:服务器信息 */}
|
{/* 左侧:服务器信息 */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="font-medium text-foreground">
|
<h3 className="font-medium text-foreground">{name}</h3>
|
||||||
{name}
|
|
||||||
</h3>
|
|
||||||
{docsUrl && (
|
{docsUrl && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -36,9 +36,7 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-medium text-foreground mb-1">
|
<h3 className="font-medium text-foreground mb-1">{prompt.name}</h3>
|
||||||
{prompt.name}
|
|
||||||
</h3>
|
|
||||||
{prompt.description && (
|
{prompt.description && (
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
{prompt.description}
|
{prompt.description}
|
||||||
|
|||||||
@@ -114,10 +114,7 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
|||||||
) : promptEntries.length === 0 ? (
|
) : promptEntries.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
|
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
|
||||||
<FileText
|
<FileText size={24} className="text-muted-foreground" />
|
||||||
size={24}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
{t("prompts.empty")}
|
{t("prompts.empty")}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface EditProviderDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onSubmit: (provider: Provider) => Promise<void> | void;
|
onSubmit: (provider: Provider) => Promise<void> | void;
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
|
isProxyTakeover?: boolean; // 代理接管模式下不读取 live(避免显示被接管后的代理配置)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditProviderDialog({
|
export function EditProviderDialog({
|
||||||
@@ -24,6 +25,7 @@ export function EditProviderDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
appId,
|
appId,
|
||||||
|
isProxyTakeover = false,
|
||||||
}: EditProviderDialogProps) {
|
}: EditProviderDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -33,13 +35,33 @@ export function EditProviderDialog({
|
|||||||
unknown
|
unknown
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
|
|
||||||
|
// 使用 ref 标记是否已经加载过,防止重复读取覆盖用户编辑
|
||||||
|
const [hasLoadedLive, setHasLoadedLive] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!open || !provider) {
|
if (!open || !provider) {
|
||||||
setLiveSettings(null);
|
setLiveSettings(null);
|
||||||
|
setHasLoadedLive(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关键修复:只在首次打开时加载一次
|
||||||
|
if (hasLoadedLive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代理接管模式:Live 配置已被代理改写,读取 live 会导致编辑界面展示代理地址/占位符等内容
|
||||||
|
// 因此直接回退到 SSOT(数据库)配置,避免用户困惑与误保存
|
||||||
|
if (isProxyTakeover) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLiveSettings(null);
|
||||||
|
setHasLoadedLive(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentId = await providersApi.getCurrent(appId);
|
const currentId = await providersApi.getCurrent(appId);
|
||||||
if (currentId && provider.id === currentId) {
|
if (currentId && provider.id === currentId) {
|
||||||
@@ -49,13 +71,20 @@ export function EditProviderDialog({
|
|||||||
)) as Record<string, unknown>;
|
)) as Record<string, unknown>;
|
||||||
if (!cancelled && live && typeof live === "object") {
|
if (!cancelled && live && typeof live === "object") {
|
||||||
setLiveSettings(live);
|
setLiveSettings(live);
|
||||||
|
setHasLoadedLive(true);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 读取实时配置失败则回退到 SSOT(不打断编辑流程)
|
// 读取实时配置失败则回退到 SSOT(不打断编辑流程)
|
||||||
if (!cancelled) setLiveSettings(null);
|
if (!cancelled) {
|
||||||
|
setLiveSettings(null);
|
||||||
|
setHasLoadedLive(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!cancelled) setLiveSettings(null);
|
if (!cancelled) {
|
||||||
|
setLiveSettings(null);
|
||||||
|
setHasLoadedLive(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// no-op
|
// no-op
|
||||||
@@ -65,14 +94,33 @@ export function EditProviderDialog({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, provider, appId]);
|
}, [open, provider?.id, appId, hasLoadedLive, isProxyTakeover]); // 只依赖 provider.id,不依赖整个 provider 对象
|
||||||
|
|
||||||
const initialSettingsConfig = useMemo(() => {
|
const initialSettingsConfig = useMemo(() => {
|
||||||
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
|
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
|
||||||
string,
|
string,
|
||||||
unknown
|
unknown
|
||||||
>;
|
>;
|
||||||
}, [liveSettings, provider]);
|
}, [liveSettings, provider?.settingsConfig]); // 只依赖 settingsConfig,不依赖整个 provider
|
||||||
|
|
||||||
|
// 固定 initialData,防止 provider 对象更新时重置表单
|
||||||
|
const initialData = useMemo(() => {
|
||||||
|
if (!provider) return null;
|
||||||
|
return {
|
||||||
|
name: provider.name,
|
||||||
|
notes: provider.notes,
|
||||||
|
websiteUrl: provider.websiteUrl,
|
||||||
|
settingsConfig: initialSettingsConfig,
|
||||||
|
category: provider.category,
|
||||||
|
meta: provider.meta,
|
||||||
|
icon: provider.icon,
|
||||||
|
iconColor: provider.iconColor,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算
|
||||||
|
initialSettingsConfig,
|
||||||
|
// 注意:不依赖 provider 的其他字段,防止表单重置
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (values: ProviderFormValues) => {
|
async (values: ProviderFormValues) => {
|
||||||
@@ -104,7 +152,7 @@ export function EditProviderDialog({
|
|||||||
[onSubmit, onOpenChange, provider],
|
[onSubmit, onOpenChange, provider],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider || !initialData) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,17 +178,7 @@ export function EditProviderDialog({
|
|||||||
submitLabel={t("common.save")}
|
submitLabel={t("common.save")}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
initialData={{
|
initialData={initialData}
|
||||||
name: provider.name,
|
|
||||||
notes: provider.notes,
|
|
||||||
websiteUrl: provider.websiteUrl,
|
|
||||||
// 若读取到实时配置则优先使用
|
|
||||||
settingsConfig: initialSettingsConfig,
|
|
||||||
category: provider.category,
|
|
||||||
meta: provider.meta,
|
|
||||||
icon: provider.icon,
|
|
||||||
iconColor: provider.iconColor,
|
|
||||||
}}
|
|
||||||
showButtons={false}
|
showButtons={false}
|
||||||
/>
|
/>
|
||||||
</FullScreenPanel>
|
</FullScreenPanel>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface FailoverPriorityBadgeProps {
|
||||||
|
priority: number; // 1, 2, 3, ...
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 故障转移优先级徽章
|
||||||
|
* 显示供应商在故障转移队列中的优先级顺序
|
||||||
|
*/
|
||||||
|
export function FailoverPriorityBadge({
|
||||||
|
priority,
|
||||||
|
className,
|
||||||
|
}: FailoverPriorityBadgeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold",
|
||||||
|
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
title={t("failover.priority.tooltip", {
|
||||||
|
priority,
|
||||||
|
defaultValue: `故障转移优先级 ${priority}`,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
P{priority}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { HealthStatus } from "@/lib/api/model-test";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface HealthStatusIndicatorProps {
|
||||||
|
status: HealthStatus;
|
||||||
|
responseTimeMs?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
operational: {
|
||||||
|
color: "bg-emerald-500",
|
||||||
|
labelKey: "health.operational",
|
||||||
|
labelFallback: "正常",
|
||||||
|
textColor: "text-emerald-600 dark:text-emerald-400",
|
||||||
|
},
|
||||||
|
degraded: {
|
||||||
|
color: "bg-yellow-500",
|
||||||
|
labelKey: "health.degraded",
|
||||||
|
labelFallback: "降级",
|
||||||
|
textColor: "text-yellow-600 dark:text-yellow-400",
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
color: "bg-red-500",
|
||||||
|
labelKey: "health.failed",
|
||||||
|
labelFallback: "失败",
|
||||||
|
textColor: "text-red-600 dark:text-red-400",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HealthStatusIndicator: React.FC<HealthStatusIndicatorProps> = ({
|
||||||
|
status,
|
||||||
|
responseTimeMs,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const config = statusConfig[status];
|
||||||
|
const label = t(config.labelKey, { defaultValue: config.labelFallback });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-2", className)}>
|
||||||
|
<div className={cn("w-2 h-2 rounded-full", config.color)} />
|
||||||
|
<span className={cn("text-xs font-medium", config.textColor)}>
|
||||||
|
{label}
|
||||||
|
{responseTimeMs !== undefined && ` (${responseTimeMs}ms)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,52 +1,126 @@
|
|||||||
import { BarChart3, Check, Copy, Edit, Play, Trash2 } from "lucide-react";
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
Edit,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
TestTube2,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ProviderActionsProps {
|
interface ProviderActionsProps {
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
|
isTesting?: boolean;
|
||||||
|
isProxyTakeover?: boolean;
|
||||||
onSwitch: () => void;
|
onSwitch: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDuplicate: () => void;
|
onDuplicate: () => void;
|
||||||
|
onTest?: () => void;
|
||||||
onConfigureUsage: () => void;
|
onConfigureUsage: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled?: boolean;
|
||||||
|
isInFailoverQueue?: boolean;
|
||||||
|
onToggleFailover?: (enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderActions({
|
export function ProviderActions({
|
||||||
isCurrent,
|
isCurrent,
|
||||||
|
isTesting,
|
||||||
|
isProxyTakeover = false,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
|
onTest,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled = false,
|
||||||
|
isInFailoverQueue = false,
|
||||||
|
onToggleFailover,
|
||||||
}: ProviderActionsProps) {
|
}: ProviderActionsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const iconButtonClass = "h-8 w-8 p-1";
|
const iconButtonClass = "h-8 w-8 p-1";
|
||||||
|
|
||||||
|
// 故障转移模式下的按钮逻辑
|
||||||
|
const isFailoverMode = isAutoFailoverEnabled && onToggleFailover;
|
||||||
|
|
||||||
|
// 处理主按钮点击
|
||||||
|
const handleMainButtonClick = () => {
|
||||||
|
if (isFailoverMode) {
|
||||||
|
// 故障转移模式:切换队列状态
|
||||||
|
onToggleFailover(!isInFailoverQueue);
|
||||||
|
} else {
|
||||||
|
// 普通模式:切换供应商
|
||||||
|
onSwitch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 主按钮的状态和样式
|
||||||
|
const getMainButtonState = () => {
|
||||||
|
if (isFailoverMode) {
|
||||||
|
// 故障转移模式
|
||||||
|
if (isInFailoverQueue) {
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
variant: "secondary" as const,
|
||||||
|
className:
|
||||||
|
"bg-blue-100 text-blue-600 hover:bg-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:hover:bg-blue-900/70",
|
||||||
|
icon: <Check className="h-4 w-4" />,
|
||||||
|
text: t("failover.inQueue", { defaultValue: "已加入" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
variant: "default" as const,
|
||||||
|
className:
|
||||||
|
"bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||||
|
icon: <Plus className="h-4 w-4" />,
|
||||||
|
text: t("failover.addQueue", { defaultValue: "加入" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通模式
|
||||||
|
if (isCurrent) {
|
||||||
|
return {
|
||||||
|
disabled: true,
|
||||||
|
variant: "secondary" as const,
|
||||||
|
className:
|
||||||
|
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
||||||
|
icon: <Check className="h-4 w-4" />,
|
||||||
|
text: t("provider.inUse"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
variant: "default" as const,
|
||||||
|
className: isProxyTakeover
|
||||||
|
? "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
|
||||||
|
: "",
|
||||||
|
icon: <Play className="h-4 w-4" />,
|
||||||
|
text: t("provider.enable"),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonState = getMainButtonState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={isCurrent ? "secondary" : "default"}
|
variant={buttonState.variant}
|
||||||
onClick={onSwitch}
|
onClick={handleMainButtonClick}
|
||||||
disabled={isCurrent}
|
disabled={buttonState.disabled}
|
||||||
className={cn(
|
className={cn("w-[4.5rem] px-2.5", buttonState.className)}
|
||||||
"w-[4.5rem] px-2.5",
|
|
||||||
isCurrent &&
|
|
||||||
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{isCurrent ? (
|
{buttonState.icon}
|
||||||
<>
|
{buttonState.text}
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
{t("provider.inUse")}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
{t("provider.enable")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -70,6 +144,23 @@ export function ProviderActions({
|
|||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{onTest && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onTest}
|
||||||
|
disabled={isTesting}
|
||||||
|
title={t("modelTest.testProvider", "测试模型")}
|
||||||
|
className={iconButtonClass}
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<TestTube2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState, useEffect } from "react";
|
||||||
import { GripVertical } from "lucide-react";
|
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type {
|
||||||
DraggableAttributes,
|
DraggableAttributes,
|
||||||
@@ -11,6 +11,10 @@ import { cn } from "@/lib/utils";
|
|||||||
import { ProviderActions } from "@/components/providers/ProviderActions";
|
import { ProviderActions } from "@/components/providers/ProviderActions";
|
||||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||||
import UsageFooter from "@/components/UsageFooter";
|
import UsageFooter from "@/components/UsageFooter";
|
||||||
|
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||||||
|
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
|
||||||
|
import { useProviderHealth } from "@/lib/query/failover";
|
||||||
|
import { useUsageQuery } from "@/lib/query/queries";
|
||||||
|
|
||||||
interface DragHandleProps {
|
interface DragHandleProps {
|
||||||
attributes: DraggableAttributes;
|
attributes: DraggableAttributes;
|
||||||
@@ -28,7 +32,17 @@ interface ProviderCardProps {
|
|||||||
onConfigureUsage: (provider: Provider) => void;
|
onConfigureUsage: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
|
onTest?: (provider: Provider) => void;
|
||||||
|
isTesting?: boolean;
|
||||||
|
isProxyRunning: boolean;
|
||||||
|
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
||||||
dragHandleProps?: DragHandleProps;
|
dragHandleProps?: DragHandleProps;
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移
|
||||||
|
failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...)
|
||||||
|
isInFailoverQueue?: boolean; // 是否在故障转移队列中
|
||||||
|
onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列
|
||||||
|
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||||
@@ -76,10 +90,23 @@ export function ProviderCard({
|
|||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
|
onTest,
|
||||||
|
isTesting,
|
||||||
|
isProxyRunning,
|
||||||
|
isProxyTakeover = false,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled = false,
|
||||||
|
failoverPriority,
|
||||||
|
isInFailoverQueue = false,
|
||||||
|
onToggleFailover,
|
||||||
|
activeProviderId,
|
||||||
}: ProviderCardProps) {
|
}: ProviderCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 获取供应商健康状态
|
||||||
|
const { data: health } = useProviderHealth(provider.id, appId);
|
||||||
|
|
||||||
const fallbackUrlText = t("provider.notConfigured", {
|
const fallbackUrlText = t("provider.notConfigured", {
|
||||||
defaultValue: "未配置接口地址",
|
defaultValue: "未配置接口地址",
|
||||||
});
|
});
|
||||||
@@ -104,6 +131,29 @@ export function ProviderCard({
|
|||||||
|
|
||||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||||
|
|
||||||
|
// 获取用量数据以判断是否有多套餐
|
||||||
|
const autoQueryInterval = isCurrent
|
||||||
|
? provider.meta?.usage_script?.autoQueryInterval || 0
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const { data: usage } = useUsageQuery(provider.id, appId, {
|
||||||
|
enabled: usageEnabled,
|
||||||
|
autoQueryInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasMultiplePlans =
|
||||||
|
usage?.success && usage.data && usage.data.length > 1;
|
||||||
|
|
||||||
|
// 多套餐默认展开
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
// 当检测到多套餐时自动展开
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasMultiplePlans) {
|
||||||
|
setIsExpanded(true);
|
||||||
|
}
|
||||||
|
}, [hasMultiplePlans]);
|
||||||
|
|
||||||
const handleOpenWebsite = () => {
|
const handleOpenWebsite = () => {
|
||||||
if (!isClickableUrl) {
|
if (!isClickableUrl) {
|
||||||
return;
|
return;
|
||||||
@@ -111,17 +161,46 @@ export function ProviderCard({
|
|||||||
onOpenWebsite(displayUrl);
|
onOpenWebsite(displayUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 判断是否是"当前使用中"的供应商
|
||||||
|
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||||
|
// - 代理接管模式(非故障转移):isCurrent
|
||||||
|
// - 普通模式:isCurrent
|
||||||
|
const isActiveProvider = isAutoFailoverEnabled
|
||||||
|
? activeProviderId === provider.id
|
||||||
|
: isCurrent;
|
||||||
|
|
||||||
|
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
|
||||||
|
const shouldUseGreen = isProxyTakeover && isActiveProvider;
|
||||||
|
const shouldUseBlue = !isProxyTakeover && isActiveProvider;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"glass-card relative overflow-hidden rounded-xl p-4 transition-all duration-300",
|
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
|
||||||
"group hover:bg-black/[0.02] dark:hover:bg-white/[0.02] hover:border-primary/50",
|
"bg-card text-card-foreground group",
|
||||||
isCurrent ? "glass-card-active" : "hover:scale-[1.01]",
|
// hover 时的边框效果
|
||||||
|
isAutoFailoverEnabled || isProxyTakeover
|
||||||
|
? "hover:border-emerald-500/50"
|
||||||
|
: "hover:border-border-active",
|
||||||
|
// 当前激活的供应商边框样式
|
||||||
|
shouldUseGreen &&
|
||||||
|
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
|
||||||
|
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
|
||||||
|
!isActiveProvider && "hover:shadow-sm",
|
||||||
dragHandleProps?.isDragging &&
|
dragHandleProps?.isDragging &&
|
||||||
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
||||||
|
// 代理接管模式使用绿色渐变,普通模式使用蓝色渐变
|
||||||
|
shouldUseGreen && "from-emerald-500/10",
|
||||||
|
shouldUseBlue && "from-blue-500/10",
|
||||||
|
!isActiveProvider && "from-primary/10",
|
||||||
|
isActiveProvider ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -139,7 +218,7 @@ export function ProviderCard({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 供应商图标 */}
|
{/* 供应商图标 */}
|
||||||
<div className="h-8 w-8 rounded-lg bg-white/5 flex items-center justify-center border border-gray-200 dark:border-white/10 group-hover:scale-105 transition-transform duration-300">
|
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
icon={provider.icon}
|
icon={provider.icon}
|
||||||
name={provider.name}
|
name={provider.name}
|
||||||
@@ -149,10 +228,25 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex flex-wrap items-center gap-2 min-h-[20px]">
|
<div className="flex flex-wrap items-center gap-2 min-h-7">
|
||||||
<h3 className="text-base font-semibold leading-none">
|
<h3 className="text-base font-semibold leading-none">
|
||||||
{provider.name}
|
{provider.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{/* 健康状态徽章 */}
|
||||||
|
{isProxyRunning && isInFailoverQueue && health && (
|
||||||
|
<ProviderHealthBadge
|
||||||
|
consecutiveFailures={health.consecutive_failures}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 故障转移优先级徽章 */}
|
||||||
|
{isAutoFailoverEnabled &&
|
||||||
|
isInFailoverQueue &&
|
||||||
|
failoverPriority && (
|
||||||
|
<FailoverPriorityBadge priority={failoverPriority} />
|
||||||
|
)}
|
||||||
|
|
||||||
{provider.category === "third_party" &&
|
{provider.category === "third_party" &&
|
||||||
provider.meta?.isPartner && (
|
provider.meta?.isPartner && (
|
||||||
<span
|
<span
|
||||||
@@ -164,14 +258,6 @@ export function ProviderCard({
|
|||||||
⭐
|
⭐
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"rounded-full bg-green-500/10 px-2 py-0.5 text-xs font-medium text-green-500 dark:text-green-400 transition-opacity duration-200",
|
|
||||||
isCurrent ? "opacity-100" : "opacity-0 pointer-events-none",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t("provider.currentlyUsing")}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{displayUrl && (
|
{displayUrl && (
|
||||||
@@ -193,30 +279,88 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex items-center ml-auto">
|
<div className="relative flex items-center ml-auto min-w-0">
|
||||||
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[12.25rem] group-focus-within:-translate-x-[12.25rem] sm:group-hover:-translate-x-[14.25rem] sm:group-focus-within:-translate-x-[14.25rem]">
|
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
||||||
<UsageFooter
|
<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]">
|
||||||
provider={provider}
|
<div className="flex items-center gap-1">
|
||||||
providerId={provider.id}
|
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
|
||||||
appId={appId}
|
{hasMultiplePlans ? (
|
||||||
usageEnabled={usageEnabled}
|
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
isCurrent={isCurrent}
|
<span className="font-medium">
|
||||||
inline={true}
|
{t("usage.multiplePlans", {
|
||||||
/>
|
count: usage?.data?.length || 0,
|
||||||
|
defaultValue: `${usage?.data?.length || 0} 个套餐`,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<UsageFooter
|
||||||
|
provider={provider}
|
||||||
|
providerId={provider.id}
|
||||||
|
appId={appId}
|
||||||
|
usageEnabled={usageEnabled}
|
||||||
|
isCurrent={isCurrent}
|
||||||
|
inline={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* 展开/折叠按钮 - 仅在有多套餐时显示 */}
|
||||||
|
{hasMultiplePlans && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-500 dark:text-gray-400 flex-shrink-0"
|
||||||
|
title={
|
||||||
|
isExpanded
|
||||||
|
? t("usage.collapse", { defaultValue: "收起" })
|
||||||
|
: t("usage.expand", { defaultValue: "展开" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp size={14} />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮区域 - 绝对定位在右侧,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 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">
|
||||||
<ProviderActions
|
<ProviderActions
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
|
isTesting={isTesting}
|
||||||
|
isProxyTakeover={isProxyTakeover}
|
||||||
onSwitch={() => onSwitch(provider)}
|
onSwitch={() => onSwitch(provider)}
|
||||||
onEdit={() => onEdit(provider)}
|
onEdit={() => onEdit(provider)}
|
||||||
onDuplicate={() => onDuplicate(provider)}
|
onDuplicate={() => onDuplicate(provider)}
|
||||||
|
onTest={onTest ? () => onTest(provider) : undefined}
|
||||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||||
onDelete={() => onDelete(provider)}
|
onDelete={() => onDelete(provider)}
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||||
|
isInFailoverQueue={isInFailoverQueue}
|
||||||
|
onToggleFailover={onToggleFailover}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 展开的完整套餐列表 */}
|
||||||
|
{isExpanded && hasMultiplePlans && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-border-default">
|
||||||
|
<UsageFooter
|
||||||
|
provider={provider}
|
||||||
|
providerId={provider.id}
|
||||||
|
appId={appId}
|
||||||
|
usageEnabled={usageEnabled}
|
||||||
|
isCurrent={isCurrent}
|
||||||
|
inline={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 p-10 text-center">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border p-10 text-center">
|
||||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||||
<Users className="h-7 w-7 text-muted-foreground" />
|
<Users className="h-7 w-7 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user