Compare commits

..

60 Commits

Author SHA1 Message Date
YoVinchen 6889e1d248 fix(proxy): persist per-app takeover state across app restarts
- Fix proxy toggle color to reflect current app's takeover state only
- Restore proxy service on startup if Live config is still in takeover state
- Preserve per-app backup records instead of clearing all on restart
- Only recover Live config when proxy service fails to start
2025-12-19 15:38:16 +08:00
YoVinchen 771bef16f5 fix(ui): improve AboutSection styling and version detection
- Add framer-motion animations for smooth page transitions
- Unify button sizes and add icons for consistency
- Add gradient backgrounds and hover effects to cards
- Add notInstalled i18n translations (zh/en/ja)
- Fix version detection when stdout/stderr is empty
2025-12-19 14:20:43 +08:00
Jason 6bdbb4df23 chore: update Cargo.lock for version 3.9.0-1 2025-12-18 23:41:45 +08:00
Jason 256903ee70 chore: rename version to 3.9.0-1 for MSI compatibility
MSI installer requires numeric-only pre-release identifiers.
Changed from 3.9.0-beta.1 to 3.9.0-1.
2025-12-18 22:02:41 +08:00
Jason f42f73ebb0 fix: import RunEvent for all platforms
The #[cfg(target_os = "macos")] restriction was a historical artifact
from when RunEvent was only used for macOS-specific events (Reopen, Opened).
After c9ea13a added ExitRequested handling for all platforms, the import
should have been updated but was overlooked.
2025-12-18 21:32:17 +08:00
Jason ec6e113cf2 chore: bump version to 3.9.0-beta.1
- Update version in package.json, Cargo.toml, tauri.conf.json
- Add CHANGELOG entry for v3.9.0-beta.1 with:
  - Local Proxy Server feature
  - Auto Failover with circuit breaker
  - Skills multi-app support
  - Provider icon colors
  - 25+ bug fixes
- Add proxy feature guide documentation (Chinese)
2025-12-18 20:53:02 +08:00
Jason ae837ade02 fix(ui): restore fade transition for Skills button
The Skills button transition was accidentally removed in commit 1fb2d5e
when adding multi-app support. This restores the smooth fade-in/out
animation when switching between apps that support Skills (Claude/Codex)
and those that don't (Gemini).
2025-12-18 17:34:16 +08:00
Jason a8fd1f0dd2 fix(mcp): skip sync when target CLI app is not installed
Add guard functions to check if Claude/Codex/Gemini CLI has been
initialized before attempting to sync MCP configurations. This prevents
creating unwanted config files in directories that don't exist.

- Claude: check ~/.claude dir OR ~/.claude.json file exists
- Codex: check ~/.codex dir exists
- Gemini: check ~/.gemini dir exists

When the target app is not installed, sync operations now silently
succeed without writing any files, allowing users to manage MCP servers
for apps they actually use without side effects on others.
2025-12-18 16:08:10 +08:00
Jason fa33330b3b fix(mcp): improve upsert and import robustness
- Remove server from live config when app is disabled during upsert
- Merge enabled flags instead of overwriting when importing from multiple apps
- Normalize Gemini MCP type field (url-only → sse, command → stdio)
- Use atomic write for Codex config updates
- Add tests for disable-removal, multi-app merge, and Gemini SSE import
2025-12-18 15:14:37 +08:00
Jason 18207771ad feat(proxy): implement per-app takeover mode
Replace global live takeover with granular per-app control:
- Add start_proxy_server command (start without takeover)
- Add get_proxy_takeover_status to query each app's state
- Add set_proxy_takeover_for_app for individual app control
- Use live backup existence as SSOT for takeover state
- Refactor sync_live_to_provider to eliminate code duplication
- Update ProxyToggle to show status per active app
2025-12-18 11:28:10 +08:00
Jason 0cd7d0756c fix(proxy): takeover Codex base_url via model_provider
- Update Codex `model_providers.<model_provider>.base_url` to the proxy origin with `/v1`
- Add route fallbacks for `/responses` and `/chat/completions` (plus double-`/v1` safeguard)
- Add unit tests for the TOML base_url takeover logic
2025-12-17 22:53:32 +08:00
Jason bca0997afa fix(proxy): harden crash recovery with fallback detection
- Set takeover flag before writing proxy config to fix race condition
  where crash during takeover left Live configs corrupted but flag unset
- Add fallback detection by checking for placeholder tokens in Live
  configs when backups exist but flag is false (handles legacy/edge cases)
- Improve error handling with proper rollback at each stage of startup
- Clean up stale backups when Live configs are not in takeover state
  to avoid long-term storage of sensitive tokens
2025-12-17 11:03:49 +08:00
Jason 0ef8a4153f fix(proxy): sync UI when active provider differs from current setting
Previously, UI sync was triggered only when failover happened (retry count > 1).
This missed cases where the first provider in the failover queue succeeded but
was different from the user's selected provider in settings.

Now we capture the current provider ID at request start and compare it with
the actually used provider. This ensures UI/tray always reflects the real
provider handling requests.
2025-12-17 10:22:02 +08:00
Jason 1b73b26c0e fix(proxy): resolve circuit breaker race condition and error classification
This commit addresses two critical issues in the proxy failover logic:

1. Circuit Breaker HalfOpen Concurrency Bug:
   - Introduced `AllowResult` struct to track half-open permit usage
   - Added state guard in `transition_to_half_open()` to prevent duplicate resets
   - Replaced `fetch_sub` with CAS loop in `release_half_open_permit()` to prevent underflow
   - Separated `is_available()` (routing) from `allow_request()` (permit acquisition)

2. Error Classification Conflation:
   - Split retry logic into `should_retry_same_provider()` and `categorize_proxy_error()`
   - Same-provider retry: only for transient errors (timeout, 429, 5xx)
   - Cross-provider failover: now includes ConfigError, TransformError, AuthError
   - 4xx errors (401/403) no longer waste retries on the same provider
2025-12-17 09:36:17 +08:00
Jason 3d514c8250 fix(proxy): stabilize live takeover and provider editing
- Skip live writes when takeover is active and proxy is running
- Refresh live backups from provider edits during takeover
- Sync live tokens to DB without clobbering real keys with placeholders
- Avoid injecting extra placeholder keys into Claude live env
- Reapply takeover after proxy listen address/port changes
- In takeover mode, edit dialog uses DB config and keeps API key state in sync
2025-12-17 09:36:17 +08:00
TinsFox 18e973b920 fix: azure website link (#407) 2025-12-17 08:43:55 +08:00
YoVinchen ec20ff4d8c Feature/error request logging (#401)
* feat(proxy): add error mapper for HTTP status code mapping

- Add error_mapper.rs module to map ProxyError to HTTP status codes
- Implement map_proxy_error_to_status() for error classification
- Implement get_error_message() for user-friendly error messages
- Support all error types: upstream, timeout, connection, provider failures
- Include comprehensive unit tests for all mappings

* feat(proxy): enhance error logging with context support

- Add log_error_with_context() method for detailed error recording
- Support streaming flag, session_id, and provider_type fields
- Remove dead_code warning from log_error() method
- Enable comprehensive error request tracking in database

* feat(proxy): implement error capture and logging in all handlers

- Capture and log all failed requests in handle_messages (Claude)
- Capture and log all failed requests in handle_gemini (Gemini)
- Capture and log all failed requests in handle_responses (Codex)
- Capture and log all failed requests in handle_chat_completions (Codex)
- Record error status codes, messages, and latency for all failures
- Generate unique session_id for each request
- Support both streaming and non-streaming error scenarios

* style: fix clippy warnings and typescript errors

- Add allow(dead_code) for CircuitBreaker::get_state (reserved for future)
- Fix all uninlined format string warnings (27 instances)
- Use inline format syntax for better readability
- Fix unused import and parameter warnings in ProviderActions.tsx
- Achieve zero warnings in both Rust and TypeScript

* style: apply code formatting

- Remove trailing whitespace in misc.rs
- Add trailing comma in App.tsx
- Format multi-line className in ProviderCard.tsx

* feat(proxy): add settings button to proxy panel

Add configuration buttons in both running and stopped states to
provide easy access to proxy settings dialog.

* fix(speedtest): skip client build for invalid inputs

* chore(clippy): fix uninlined format args

* Merge branch 'main' into feature/error-request-logging
2025-12-16 21:02:08 +08:00
Jason e6654bd7f9 refactor(proxy): remove is_proxy_target in favor of failover_queue
- Remove `is_proxy_target` field from Provider struct (Rust & TypeScript)
- Remove related DAO methods: get_proxy_target_provider, set_proxy_target
- Remove deprecated Tauri commands: get_proxy_targets, set_proxy_target
- Add `is_available()` method to CircuitBreaker for availability checks
  without consuming HalfOpen probe permits (used in select_providers)
- Keep `allow_request()` for actual request gating with permit tracking
- Update stream_check to use failover_queue instead of is_proxy_target
- Clean up commented-out reset circuit breaker button in ProviderActions
- Remove unused useProxyTargets and useSetProxyTarget hooks
2025-12-16 15:45:15 +08:00
Jason d4f33224c6 fix(ui): add close button to all success toasts 2025-12-16 10:56:39 +08:00
Jason 510a013449 chore(i18n): remove restart prompt from provider switch notification
Claude Code now supports hot reload, so users no longer need to restart
the terminal after switching providers. Also removes unused `restartClaude`
i18n string.
2025-12-16 10:11:29 +08:00
Jason 1e5bab1cb6 fix(proxy): invalidate health cache when proxy stops
The previous fix (e081c75) only cleared the database records but
didn't invalidate the React Query cache. This caused stale health
badges to appear when restarting the proxy.

Now invalidates all providerHealth queries on stop, ensuring the
frontend fetches fresh data from the database.
2025-12-15 23:12:26 +08:00
Jason 6dcf268317 chore: update aigocode partner logo 2025-12-15 23:12:16 +08:00
Jason 5efc0cdd5e fix(ui): prevent card jitter when health badge appears
Increase min-height of the name row from 20px to 28px (min-h-7)
to accommodate the health badge height, preventing layout shift
when the badge dynamically appears.
2025-12-15 22:57:41 +08:00
Jason e081c7560c fix(proxy): reset health badges when proxy stops
Clear all provider_health records when stopping the proxy server,
ensuring health badges reset to "healthy" state. This fixes the
inconsistency where circuit breakers (in memory) would reset on
stop but health badges (in database) would retain stale state.
2025-12-15 22:52:58 +08:00
Jason 007813e09e fix(proxy): retry failover for all HTTP errors including 4xx
Previously, only 429, 408, and 5xx errors triggered failover to the next
provider. Other 4xx errors (like 400) were considered non-retryable and
caused immediate disconnection.

This was problematic because different providers have different restrictions
(e.g., "Do not use this API outside Claude Code CLI"), and a 400 error from
one provider doesn't mean other providers will fail.

Now all upstream HTTP errors trigger failover, allowing the system to try
all configured providers before giving up.
2025-12-15 17:12:36 +08:00
Jason 9196d07925 feat(proxy): sync UI when failover succeeds
Add FailoverSwitchManager to handle provider switching after successful
failover. This ensures the UI reflects the actual provider in use:

- Create failover_switch.rs with deduplication and async switching logic
- Pass AppHandle through ProxyService -> ProxyServer -> RequestForwarder
- Update is_current in database when failover succeeds
- Emit provider-switched event for frontend refresh
- Update tray menu and live backup synchronously

The switching runs asynchronously via tokio::spawn to avoid blocking
API responses while still providing immediate UI feedback.
2025-12-15 17:12:36 +08:00
Sirhexs 1172209f49 fix(usage): add fallback to provider config for usage credentials (#360)
- Make usage script credential fields optional with provider config fallback
- Optimize multi-plan card display: show plan count by default, expandable for details
- Add hint text to explain credential fallback mechanism
2025-12-15 17:09:46 +08:00
千羽 c49cfa5ac5 feat(deeplink): 深链支持用量查询配置 (#400)
## 新增功能
- 深链导入支持用量查询配置参数:
  - `usageEnabled`: 是否启用用量查询
  - `usageScript`: Base64 编码的用量查询脚本
  - `usageApiKey`: 用量查询专用 API Key
  - `usageBaseUrl`: 用量查询专用 Base URL
  - `usageAccessToken`: 访问令牌(NewAPI 模板)
  - `usageUserId`: 用户 ID(NewAPI 模板)
  - `usageAutoInterval`: 自动查询间隔(分钟)

## 修改文件
- **mod.rs**: DeepLinkImportRequest 结构体添加用量查询字段
- **parser.rs**: 解析 URL 中的用量查询参数
- **provider.rs**: 构建 ProviderMeta 包含 UsageScript 配置
- **deeplink.ts**: 添加 TypeScript 类型定义
- **DeepLinkImportDialog.tsx**: 确认对话框显示用量查询配置

## Bug 修复
- **formatters.ts**: 修复 formatJSON() 格式化时删除 "env" 键的问题

## 深链格式示例
```
ccswitch://v1/import?resource=provider&app=claude&name=xxx&usageEnabled=true&usageScript={base64}&usageAutoInterval=30
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 21:52:13 +08:00
Jason bfe9bb6a0c fix(proxy): resolve HalfOpen counter underflow and config field inconsistencies
- Fix HalfOpen counter underflow: increment half_open_requests when
  transitioning from Open to HalfOpen to prevent underflow in
  record_success/record_failure

- Fix Gemini config field names: unify to GEMINI_API_KEY and
  GOOGLE_GEMINI_BASE_URL (removed GOOGLE_API_KEY and GEMINI_API_BASE)

- Fix Codex proxy takeover: write base_url to config.toml instead of
  OPENAI_BASE_URL in auth.json (Codex CLI reads from config.toml)
2025-12-14 20:38:04 +08:00
Jason 5a5ca2a989 fix(proxy): resolve circuit breaker state persistence and HalfOpen deadlock
This commit addresses several critical issues in the failover system:

**Circuit breaker state persistence (previous fix)**
- Promote ProviderRouter to ProxyState for cross-request state sharing
- Remove redundant router.rs module
- Fix 429 errors to be retryable (rate limiting should try other providers)

**Hot-update circuit breaker config**
- Add update_circuit_breaker_configs() to ProxyServer and ProxyService
- Connect update_circuit_breaker_config command to running circuit breakers
- Add reset_provider_circuit_breaker() for manual breaker reset

**Fix HalfOpen deadlock bug**
- Change half_open_requests from cumulative count to in-flight count
- Release quota in record_success()/record_failure() when in HalfOpen state
- Prevents permanent deadlock when success_threshold > 1

**Fix duplicate select_providers() call**
- Store providers list in RequestContext, pass to forward_with_retry()
- Avoid consuming HalfOpen quota twice per request
- Single call to select_providers() per request lifecycle

**Add per-provider retry with exponential backoff**
- Implement forward_with_provider_retry() with configurable max_retries
- Backoff delays: 100ms, 200ms, 400ms, etc.
2025-12-13 22:47:49 +08:00
Jason 5d424b1383 feat(proxy): implement independent failover queue management
Add a new failover queue system that operates independently from provider
sortIndex, allowing users to configure failover order per app type.

Backend changes:
- Add failover_queue table to schema.rs for persistent storage
- Create dao/failover.rs with CRUD operations for queue management
- Add Tauri commands for queue operations (get, add, remove, reorder, toggle)
- Refactor provider_router.rs select_providers() to use failover queue:
  - Current provider always takes first priority
  - Queue providers ordered by queue_order as fallback
  - Only providers with open circuit breakers are included

Frontend changes:
- Add FailoverQueueItem type to proxy.ts
- Extend failover.ts API with queue management methods
- Add React Query hooks for queue data fetching and mutations
- Create FailoverQueueManager component with drag-and-drop reordering
- Integrate queue management into SettingsPage under "Auto Failover"
- Add i18n translations for zh and en locales
2025-12-12 16:13:07 +08:00
Jason c42a0dccaf fix(proxy): auto-recover live config after abnormal exit
When the app crashes or is force-killed while proxy mode is active,
the live config files remain pointing to the dead proxy server with
placeholder tokens, causing CLI tools to fail.

This change adds startup detection:
- Check `live_takeover_active` flag on app launch
- If flag is true but proxy is not running → abnormal exit detected
- Automatically restore live configs from database backup
- Clear takeover flag and delete backups

The recovery runs before auto-start, ensuring correct sequence even
when proxy auto-start is enabled.
2025-12-12 10:43:01 +08:00
Jason ebe2a665ae refactor(proxy): modularize handlers.rs to reduce code duplication
Extract common request handling logic into dedicated modules:
- handler_config.rs: Usage parser configurations for each API type
- handler_context.rs: Request lifecycle context management
- response_processor.rs: Unified streaming/non-streaming response handling

Reduces handlers.rs from ~1130 lines to ~418 lines (-63%), eliminating
repeated initialization and response processing patterns across the
four API handlers (Claude, Codex Chat, Codex Responses, Gemini).
2025-12-11 23:22:05 +08:00
Jason 1926af4988 fix(proxy): update live backup when hot-switching provider in proxy mode
When proxy is active, switching providers only updated the database flags
but not the live backup. This caused the wrong provider config to be
restored when stopping the proxy.

Added `update_live_backup_from_provider()` method to ProxyService that
generates backup from provider's settings_config instead of reading from
live files (which are already taken over by proxy).
2025-12-11 21:14:22 +08:00
Jason 1e3a978ecb fix(proxy): wait for server shutdown before exiting app
The previous cleanup logic only sent a shutdown signal but didn't wait
for the proxy server to actually stop. This caused a race condition
where the app would exit before cleanup completed, leaving Live configs
in an inconsistent state.

Changes:
- Add `server_handle` field to ProxyServer to track the spawned task
- Modify `stop()` to wait for server task completion (5s timeout)
- Add 100ms delay before process exit to ensure I/O flush
- Export ProxyService and fix test files that were missing proxy_service field
2025-12-11 20:10:21 +08:00
YoVinchen 395783e22a Feat/provider icon color (#385)
* feat(ui): add color prop support to ProviderIcon component

* feat(health): add stream check core functionality

Add new stream-based health check module to replace model_test:
- Add stream_check command layer with single and batch provider checks
- Add stream_check DAO layer for config and log persistence
- Add stream_check service layer with retry mechanism and health status evaluation
- Add frontend HealthStatusIndicator component
- Add frontend useStreamCheck hook

This provides more comprehensive health checking capabilities.

* refactor(health): replace model_test with stream_check

Replace model_test module with stream_check across the codebase:
- Remove model_test command and service modules
- Update command registry in lib.rs to use stream_check commands
- Update module exports in commands/mod.rs and services/mod.rs
- Remove frontend useModelTest hook
- Update stream_check command implementation

This refactoring provides clearer naming and better separation of concerns.

* refactor(db): clean up unused database tables and optimize schema

Remove deprecated and unused database tables:
- Remove proxy_usage table (replaced by proxy_request_logs)
- Remove usage_daily_stats table (aggregation done on-the-fly)
- Rename model_test_logs to stream_check_logs with updated schema
- Remove related DAO methods for proxy_usage
- Update usage_stats service to use proxy_request_logs only
- Refactor usage_script to work with new schema

This simplifies the database schema and removes redundant data storage.

* refactor(ui): update frontend components for stream check

Update frontend components to use stream check API:
- Refactor ModelTestConfigPanel to use stream check config
- Update API layer to use stream_check commands
- Add HealthStatus type and StreamCheckResult interface
- Update ProviderList to use new health check integration
- Update AutoFailoverConfigPanel with stream check references
- Improve UI layout and configuration options

This completes the frontend migration from model_test to stream_check.

* feat(health): add configurable test models and reasoning effort support

Enhance stream check service with configurable test models:
- Add claude_model, codex_model, gemini_model to StreamCheckConfig
- Support reasoning effort syntax (model@level or model#level)
- Parse and apply reasoning_effort for OpenAI-compatible models
- Remove hardcoded model names from check functions
- Add unit tests for model parsing logic
- Remove obsolete model_test source files

This allows users to customize which models are used for health checks.
2025-12-11 17:20:44 +08:00
YoVinchen 038b74b844 Fix/misc updates (#387)
* fix(misc): improve CLI version detection with path scanning

- Extract version helper to parse semver from raw output
- Add fallback path scanning when direct execution fails
- Scan common npm global paths: .npm-global, .local/bin, homebrew
- Support nvm by scanning all node versions under ~/.nvm/versions/node
- Add platform-specific paths for macOS, Linux, and Windows
- Ensure node is in PATH when executing CLI tools from scanned paths

* fix(ui): use semantic color tokens for text foreground

- Replace hardcoded gray-900/gray-100 with text-foreground in EndpointSpeedTest
- Add text-foreground class to Input component for consistent theming
- Ensures proper text color in both light and dark modes
2025-12-11 17:02:04 +08:00
Jason f1e5afdae2 update readme 2025-12-11 16:51:20 +08:00
Jason c9ea13a7ce feat(proxy): cleanup proxy server on app exit and remove unused non-takeover mode
- Add cleanup_before_exit() to gracefully stop proxy and restore live configs
- Handle ExitRequested event to catch Cmd+Q, Alt+F4, and tray quit
- Use std::process::exit(0) after cleanup to avoid infinite loop
- Remove unused start_proxy_server and stop_proxy_server commands
- Remove unused startMutation and stopMutation from useProxyStatus hook
- Simplify API to only expose takeover mode (start_proxy_with_takeover/stop_proxy_with_restore)
2025-12-11 16:21:14 +08:00
Jason 404ab5a1ae fix(proxy): disable auto-start on app launch by resetting enabled flag on stop
Previously, when proxy was started, the enabled flag was set to true and
persisted to database. However, stopping the proxy didn't reset this flag,
causing the proxy to auto-start on every subsequent app launch.

Now the enabled flag is set to false when proxy stops, ensuring the proxy
remains off after restart unless explicitly started by the user.
2025-12-11 12:32:02 +08:00
Jason 9a8f12a490 style(rust): apply cargo fmt formatting 2025-12-11 12:13:27 +08:00
Jason 6a7c2df2d2 fix(proxy): sync live config tokens to database before takeover
When proxy takeover is activated, tokens are replaced with placeholders
in live config files. However, the proxy reads tokens from the database,
not from live files. If the user's token only exists in the live config
(e.g., manually added), the database won't have it, causing auth failures.

Changes:
- Add sync_live_to_providers() to sync tokens from live configs to DB
- Add update_provider_settings_config() DAO method for partial updates
- Use "PROXY_MANAGED" placeholder instead of empty string to avoid
  "missing API key" warnings in Claude Code status bar
- Integrate sync step into start_with_takeover() flow before clearing tokens

The new takeover flow:
1. setup_proxy_targets()
2. backup_live_configs()
3. sync_live_to_providers() <- NEW
4. takeover_live_configs()
5. Start proxy server
2025-12-11 11:57:53 +08:00
Jason 735b3b7d39 style(ui): improve provider card hover animation and layout
- Increase translate offset for usage footer to prevent overlap with action buttons
- Add ease-out timing function and extend duration to 300ms for smoother animation
- Temporarily disable circuit breaker reset button to reduce button clutter
2025-12-11 10:51:25 +08:00
Jason cbc23764c0 style(ui): apply emerald theme when proxy takeover is active
- Change provider card gradient background from hover to selected state
- Use emerald color for card hover border, selected border, and gradient
  when proxy takeover mode is active
- Update "CC Switch" title to emerald when proxy is running
- Update "Enable" button to emerald in proxy takeover mode
2025-12-11 10:21:33 +08:00
Jason 2a541cfda4 refactor(proxy): simplify provider selection to use is_current directly
Changes:
- Modify provider_router to select provider based on is_current flag
  instead of is_proxy_target queue
- Remove proxy target toggle UI from ProviderCard
- Remove proxyPriority and allProviders props from ProviderList
- Remove isProxyTarget prop from ProviderHealthBadge
- Use start_with_takeover() for auto-start to ensure proper setup

This simplifies the proxy architecture by directly using the current
provider for proxying, eliminating the need for separate proxy target
management. Switching providers now immediately takes effect in proxy
mode.
2025-12-10 21:08:41 +08:00
Jason 5cc864c6aa fix(proxy): resolve 404 error and auto-setup proxy targets
Issues fixed:
1. Route prefix mismatch - Live config was set to use /claude, /codex,
   /gemini prefixes but server only had routes without prefixes
2. Proxy targets not auto-configured - start_with_takeover only modified
   Live config but didn't set is_proxy_target=true for current providers

Changes:
- Add prefixed routes (/claude/v1/messages, /codex/v1/*, /gemini/v1beta/*)
  to server.rs for backward compatibility
- Remove URL prefixes from takeover_live_configs() - server can route
  by API endpoint path alone (/v1/messages=Claude, /v1/chat/completions=Codex)
- Add setup_proxy_targets() method that automatically sets current providers
  as proxy targets when starting proxy with takeover
- Unify proxy toggle in SettingsPage to use takeover mode (same as main UI)
- Fix error message extraction in useProxyStatus hooks
- Fix provider switch logic to check both takeover flag AND proxy running state
2025-12-10 19:58:43 +08:00
YoVinchen 3cdce2eced feat(ui): add color prop support to ProviderIcon component (#384) 2025-12-10 15:20:10 +08:00
luo jiyin 8876d67807 Security fixes javascript executor and usage script (#151)
* dependencies: url

* fix: comprehensive security improvements for usage script execution

🛡️ Security Fixes:
- Implement robust SSRF protection with same-origin URL validation
- Add precise IP address validation for IPv4/IPv6 private networks
- Fix port comparison to handle default ports correctly (443/80)
- Remove hardcoded domain whitelist, support custom domains flexibly
- Add comprehensive input validation and hostname security checks

🔧 Technical Improvements:
- Replace string-based IP checks with proper IpAddr parsing
- Use port_or_known_default() for accurate port validation
- Add comprehensive unit tests covering edge cases
- Implement CIDR-compliant private IP detection (RFC1918)
- Fix IPv6 address validation to prevent false positives

📊 Fixed Issues:
- Prevent access to private IP addresses while allowing public services
- Support Cloudflare (172.67.x.x) and other public 172.x.x.x ranges
- Fix port matching between explicit (e.g., :443) and implicit (default) ports
- Resolve IPv6 false positives for addresses containing ::1 substrings
- Maintain backward compatibility with existing script usage patterns

 Testing:
- Add comprehensive test suite for IP validation (IPv4/IPv6)
- Add port comparison tests for various scenarios
- Add edge case tests for CIDR boundaries
- All tests passing, ensuring no regressions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: add is_loopback_host for proper localhost validation

* fix: use Database::memory() in tests

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-09 19:57:02 +08:00
YoVinchen 493b154a9d Feat/skill multi app migration (#378)
* feat(skill): add database migration and Gemini support for multi-app skills

- Refactor skills table from single key to (directory, app_type) composite primary key
- Add migration logic to convert existing skill records
- Support skill installation/uninstallation for Claude/Codex/Gemini independently
- Add new Tauri commands: get_skills_for_app, install_skill_for_app, uninstall_skill_for_app
- Update frontend API and components to support app-specific skill operations

* fix(usage): correct cache token column order in request log table

- Swap cache read and cache creation columns to match data binding
- Add whitespace-nowrap to all table headers for better display
2025-12-09 19:39:31 +08:00
Jason 56b40bdad2 fix(misc): use correct npm package for Codex CLI version check
- Change Codex version source from GitHub `openai/openai-python` to npm `@openai/codex`
- Remove unused `fetch_github_latest_release` helper function
- All three tools (Claude, Codex, Gemini) now consistently use npm registry
2025-12-09 10:14:46 +08:00
YoVinchen 41267135f5 Feat/auto failover (#367)
* feat(db): add circuit breaker config table and provider proxy target APIs

Add database support for auto-failover feature:

- Add circuit_breaker_config table for storing failover thresholds
- Add get/update_circuit_breaker_config methods in proxy DAO
- Add reset_provider_health method for manual recovery
- Add set_proxy_target and get_proxy_targets methods in providers DAO
  for managing multi-provider failover configuration

* feat(proxy): implement circuit breaker and provider router for auto-failover

Add core failover logic:

- CircuitBreaker: Tracks provider health with three states:
  - Closed: Normal operation, requests pass through
  - Open: Circuit broken after consecutive failures, skip provider
  - HalfOpen: Testing recovery with limited requests
- ProviderRouter: Routes requests across multiple providers with:
  - Health tracking and automatic failover
  - Configurable failure/success thresholds
  - Auto-disable proxy target after reaching failure threshold
  - Support for manual circuit breaker reset
- Export new types in proxy module

* feat(proxy): add failover Tauri commands and integrate with forwarder

Expose failover functionality to frontend:

- Add Tauri commands: get_proxy_targets, set_proxy_target,
  get_provider_health, reset_circuit_breaker,
  get/update_circuit_breaker_config, get_circuit_breaker_stats
- Register all new commands in lib.rs invoke handler
- Update forwarder with improved error handling and logging
- Integrate ProviderRouter with proxy server startup
- Add provider health tracking in request handlers

* feat(frontend): add failover API layer and TanStack Query hooks

Add frontend data layer for failover management:

- Add failover.ts API: Tauri invoke wrappers for all failover commands
- Add failover.ts query hooks: TanStack Query mutations and queries
  - useProxyTargets, useProviderHealth queries
  - useSetProxyTarget, useResetCircuitBreaker mutations
  - useCircuitBreakerConfig query and mutation
- Update queries.ts with provider health query key
- Update mutations.ts to invalidate health on provider changes
- Add CircuitBreakerConfig and ProviderHealth types

* feat(ui): add auto-failover configuration UI and provider health display

Add comprehensive UI for failover management:

Components:
- ProviderHealthBadge: Display provider health status with color coding
- CircuitBreakerConfigPanel: Configure failure/success thresholds,
  timeout duration, and error rate limits
- AutoFailoverConfigPanel: Manage proxy targets with drag-and-drop
  priority ordering and individual enable/disable controls
- ProxyPanel: Integrate failover tabs for unified proxy management

Provider enhancements:
- ProviderCard: Show health badge and proxy target indicator
- ProviderActions: Add "Set as Proxy Target" action
- EditProviderDialog: Add is_proxy_target toggle
- ProviderList: Support proxy target filtering mode

Other:
- Update App.tsx routing for settings integration
- Update useProviderActions hook with proxy target mutation
- Fix ProviderList tests for updated component API

* fix(usage): stabilize date range to prevent infinite re-renders

* feat(backend): add tool version check command

Add get_tool_versions command to check local and latest versions of
Claude, Codex, and Gemini CLI tools:

- Detect local installed versions via command line execution
- Fetch latest versions from npm registry (Claude, Gemini)
  and GitHub releases API (Codex)
- Return comprehensive version info including error details
  for uninstalled tools
- Register command in Tauri invoke handler

* style(ui): format accordion component code style

Apply consistent code formatting to accordion component:
- Convert double quotes to semicolons at line endings
- Adjust indentation to 2-space standard
- Align with project code style conventions

* refactor(providers): update provider card styling to use theme tokens

Replace hardcoded color classes with semantic design tokens:
- Use bg-card, border-border, text-card-foreground instead of glass-card
- Replace gray/white color literals with muted/foreground tokens
- Change proxy target indicator color from purple to green
- Improve hover states with border-border-active
- Ensure consistent dark mode support via CSS variables

* refactor(proxy): simplify auto-failover config panel structure

Restructure AutoFailoverConfigPanel for better integration:
- Remove internal Card wrapper and expansion toggle (now handled by parent)
- Extract enabled state to props for external control
- Simplify loading state display
- Clean up redundant CardHeader/CardContent wrappers
- ProxyPanel: reduce complexity by delegating to parent components

* feat(settings): enhance settings page with accordion layout and tool versions

Major settings page improvements:

AboutSection:
- Add local tool version detection (Claude, Codex, Gemini)
- Display installed vs latest version comparison with visual indicators
- Show update availability badges and environment check cards

SettingsPage:
- Reorganize advanced settings into collapsible accordion sections
- Add proxy control panel with inline status toggle
- Integrate auto-failover configuration with accordion UI
- Add database and cost calculation config sections

DirectorySettings & WindowSettings:
- Minor styling adjustments for consistency

settings.ts API:
- Add getToolVersions() wrapper for new backend command

* refactor(usage): restructure usage dashboard components

Comprehensive usage statistics panel refactoring:

UsageDashboard:
- Reorganize layout with improved section headers
- Add better loading states and empty state handling

ModelStatsTable & ProviderStatsTable:
- Minor styling updates for consistency

ModelTestConfigPanel & PricingConfigPanel:
- Simplify component structure
- Remove redundant Card wrappers
- Improve form field organization

RequestLogTable:
- Enhance table layout with better column sizing
- Improve pagination controls

UsageSummaryCards:
- Update card styling with semantic tokens
- Better responsive grid layout

UsageTrendChart:
- Refine chart container styling
- Improve legend and tooltip display

* chore(deps): add accordion and animation dependencies

Package updates:
- Add @radix-ui/react-accordion for collapsible sections
- Add cmdk for command palette support
- Add framer-motion for enhanced animations

Tailwind config:
- Add accordion-up/accordion-down animations
- Update darkMode config to support both selector and class
- Reorganize color and keyframe definitions for clarity

* style(app): update header and app switcher styling

App.tsx:
- Replace glass-header with explicit bg-background/80 backdrop-blur
- Update navigation button container to use bg-muted

AppSwitcher:
- Replace hardcoded gray colors with semantic muted/foreground tokens
- Ensure consistent dark mode support via CSS variables
- Add group class for better hover state transitions
2025-12-08 21:14:06 +08:00
YoVinchen 1fb2d5ed44 feat(skill): add multi-app skill support for Claude/Codex (#365)
* feat(skill): add multi-app skill support for Claude/Codex/Gemini

- Add app-specific skill management with AppType prefix in skill keys
- Implement per-app skill tracking in database schema
- Add get_skills_for_app command to retrieve skills by application
- Update SkillsPage to support app-specific skill loading with initialApp prop
- Parse app parameter and validate against supported app types
- Maintain backward compatibility with default claude app

* fix(usage): reorder cache columns and prevent header text wrapping

- Swap cache read and cache write columns order
- Add whitespace-nowrap to all table headers to prevent text wrapping
- Improves table readability and layout consistency
2025-12-08 20:54:17 +08:00
YoVinchen 622a24ded4 fix(skill): use directory basename for skill installation path (#358)
Extract last segment from skill directory path to prevent nested directory
issues during install/uninstall operations. For example, "skills/codex" now
correctly installs to "codex" instead of creating nested "skills/codex" path.
2025-12-05 14:57:06 +08:00
Jason 6713368657 fix(windows): use system titlebar to prevent black screen on startup
The `titleBarStyle: "Overlay"` setting in tauri.conf.json causes black
screen and crash on Windows due to WebView2 compatibility issues.

Add platform-specific config `tauri.windows.conf.json` to override
titleBarStyle to "Visible" on Windows while keeping the overlay style
on macOS for the immersive UI experience.

Fixes black screen issue on Windows for v3.8.x releases.
2025-12-05 11:44:54 +08:00
YoVinchen b1103c8a59 Feat/proxy server (#355)
* feat(proxy): implement local HTTP proxy server with multi-provider failover

Add a complete HTTP proxy server implementation built on Axum framework,
enabling local API request forwarding with automatic provider failover
and load balancing capabilities.

Backend Implementation (Rust):
- Add proxy server module with 7 core components:
  * server.rs: Axum HTTP server lifecycle management (start/stop/status)
  * router.rs: API routing configuration for Claude/OpenAI/Gemini endpoints
  * handlers.rs: Request/response handling and transformation
  * forwarder.rs: Upstream forwarding logic with retry mechanism (652 lines)
  * error.rs: Comprehensive error handling and HTTP status mapping
  * types.rs: Shared types (ProxyConfig, ProxyStatus, ProxyServerInfo)
  * health.rs: Provider health check infrastructure

Service Layer:
- Add ProxyService (services/proxy.rs, 157 lines):
  * Manage proxy server lifecycle
  * Handle configuration updates
  * Track runtime status and metrics

Database Layer:
- Add proxy configuration DAO (dao/proxy.rs, 242 lines):
  * Persist proxy settings (listen address, port, timeout)
  * Store provider priority and availability flags
- Update schema with proxy_config table (schema.rs):
  * Support runtime configuration persistence

Tauri Commands:
- Add 6 command endpoints (commands/proxy.rs):
  * start_proxy_server: Launch proxy server
  * stop_proxy_server: Gracefully shutdown server
  * get_proxy_status: Query runtime status
  * get_proxy_config: Retrieve current configuration
  * update_proxy_config: Modify settings without restart
  * is_proxy_running: Check server state

Frontend Implementation (React + TypeScript):
- Add ProxyPanel component (222 lines):
  * Real-time server status display
  * Start/stop controls
  * Provider availability monitoring
- Add ProxySettingsDialog component (420 lines):
  * Configuration editor (address, port, timeout)
  * Provider priority management
  * Settings validation
- Add React hooks:
  * useProxyConfig: Manage proxy configuration state
  * useProxyStatus: Poll and display server status
- Add TypeScript types (types/proxy.ts):
  * Define ProxyConfig, ProxyStatus interfaces

Provider Integration:
- Extend Provider model with availability field (providers.rs):
  * Track provider health for failover logic
- Update ProviderCard UI to display proxy status
- Integrate proxy controls in Settings page

Dependencies:
- Add Axum 0.7 (async web framework)
- Add Tower 0.4 (middleware and service abstractions)
- Add Tower-HTTP (CORS layer)
- Add Tokio sync primitives (oneshot, RwLock)

Technical Details:
- Graceful shutdown via oneshot channel
- Shared state with Arc<RwLock<T>> for thread-safe config updates
- CORS enabled for cross-origin frontend access
- Request/response streaming support
- Automatic retry with exponential backoff (forwarder)
- API key extraction from multiple config formats (Claude/Codex/Gemini)

File Statistics:
- 41 files changed
- 3491 insertions(+), 41 deletions(-)
- Core modules: 1393 lines (server + forwarder + handlers)
- Frontend UI: 642 lines (ProxyPanel + ProxySettingsDialog)
- Database/DAO: 326 lines

This implementation provides the foundation for advanced features like:
- Multi-provider load balancing
- Automatic failover on provider errors
- Request logging and analytics
- Usage tracking and cost monitoring

* fix(proxy): resolve UI/UX issues and database constraint error

Simplify proxy control interface and fix database persistence issues:

Backend Fixes:
- Fix NOT NULL constraint error in proxy_config.created_at field
  * Use COALESCE to preserve created_at on updates
  * Ensure proper INSERT OR REPLACE behavior
- Remove redundant enabled field validation on startup
  * Auto-enable when user clicks start button
  * Persist enabled state after successful start
- Preserve enabled state during config updates
  * Prevent accidental service shutdown on config save

Frontend Improvements:
- Remove duplicate proxy enable switch from settings dialog
  * Keep only runtime toggle in ProxyPanel
  * Simplify user experience with single control point
- Hide proxy target button when proxy service is stopped
  * Add isProxyRunning prop to ProviderCard
  * Conditionally render proxy controls based on service status
- Update form schema to omit enabled field
  * Managed automatically by backend

Files: 5 changed, 81 insertions(+), 94 deletions(-)

* fix(proxy): improve URL building and Gemini request handling

- Refactor URL construction with version path deduplication (/v1, /v1beta)
- Preserve query parameters for Gemini API requests
- Support GOOGLE_GEMINI_API_KEY field name (with fallback)
- Change default proxy port from 5000 to 15721
- Fix test: use Option type for is_proxy_target field

* refactor(proxy): remove unused request handlers and routes

- Remove unused GET/DELETE request forwarding methods
- Remove count_tokens, get/delete response handlers
- Simplify router by removing unused endpoints
- Keep only essential routes: /v1/messages, /v1/responses, /v1beta/*

* Merge branch 'main' into feat/proxy-server

* fix(proxy): resolve clippy warnings for dead code and uninlined format args

- Add #[allow(dead_code)] to unused ProviderUnhealthy variant
- Inline format string arguments in handlers.rs and codex.rs log macros
- Refactor error response handling to properly pass through upstream errors
- Add URL deduplication logic for /v1/v1 paths in CodexAdapter

* feat(proxy): implement provider adapter pattern with OpenRouter support

This major refactoring introduces a modular provider adapter architecture
to support format transformation between different AI API formats.

New features:
- Add ProviderAdapter trait for unified provider abstraction
- Implement Claude, Codex, and Gemini adapters with specific logic
- Add Anthropic ↔ OpenAI format transformation for OpenRouter compatibility
- Support model mapping from provider configuration (ANTHROPIC_MODEL, etc.)
- Add OpenRouter preset to Claude provider presets

Refactoring:
- Extract authentication logic into auth.rs with AuthInfo and AuthStrategy
- Move URL building and request transformation to individual adapters
- Simplify ProviderRouter to only use proxy target providers
- Refactor RequestForwarder to use adapter-based request/response handling
- Use whitelist mode for header forwarding (only pass necessary headers)

Architecture:
- providers/adapter.rs: ProviderAdapter trait definition
- providers/auth.rs: AuthInfo, AuthStrategy types
- providers/claude.rs: Claude adapter with OpenRouter detection
- providers/codex.rs: Codex (OpenAI) adapter
- providers/gemini.rs: Gemini (Google) adapter
- providers/models/: Anthropic and OpenAI API data models
- providers/transform.rs: Bidirectional format transformation

* feat(proxy): add streaming SSE transform and thinking parameter support

New features:
- Add OpenAI → Anthropic SSE streaming response transformation
- Support thinking parameter detection for reasoning model selection
- Add ANTHROPIC_REASONING_MODEL config option for extended thinking

Changes:
- streaming.rs: Implement SSE event parsing and Anthropic format conversion
- transform.rs: Add thinking detection logic and reasoning model mapping
- handlers.rs: Integrate streaming transform for OpenRouter compatibility
- Cargo.toml: Add async-stream and bytes dependencies

* feat(db): add usage tracking schema and types

Add database tables for proxy request logs and model pricing.
Extend Provider and error types to support usage statistics.

* feat(proxy): implement usage tracking subsystem

Add request logger with automatic cost calculation.
Implement token parser for Claude/OpenAI/Gemini responses.
Add cost calculator based on model pricing configuration.

* feat(proxy): integrate usage logging into request handlers

Add usage logging to forwarder and streaming handlers.
Track token usage and costs for each proxy request.

* feat(commands): add usage statistics Tauri commands

Register usage commands for summary, trends, logs, and pricing.
Expose usage stats service through Tauri command layer.

* feat(api): add frontend usage API and query hooks

Add TypeScript types for usage statistics.
Implement usage API with Tauri invoke calls.
Add TanStack Query hooks for usage data fetching.

* feat(ui): add usage dashboard components

Add UsageDashboard with summary cards, trend chart, and data tables.
Implement model pricing configuration panel.
Add request log viewer with filtering and detail panel.

* fix(ui): integrate usage dashboard and fix type errors

Add usage dashboard tab to settings page.
Fix UsageScriptModal TypeScript type annotations.

* deps: add recharts for charts and rust_decimal/uuid for usage tracking

- recharts: Chart visualization for usage trends
- rust_decimal: Precise cost calculations
- uuid: Request ID generation

* feat(proxy): add ProviderType enum for fine-grained provider detection

Introduce ProviderType enum to distinguish between different provider
implementations (Claude, ClaudeAuth, Codex, Gemini, GeminiCli, OpenRouter).
This enables proper authentication handling and request transformation
based on the actual provider type rather than just AppType.

- Add ProviderType enum with detection logic from config
- Enhance Claude adapter with OpenRouter detection
- Enhance Gemini adapter with CLI mode detection
- Add helper methods for provider type inference

* feat(database): extend schema with streaming and timing fields

Add new columns to proxy_request_logs table for enhanced usage tracking:
- first_token_ms and duration_ms for performance metrics
- provider_type and is_streaming for request classification
- cost_multiplier for flexible pricing

Update model pricing with accurate rates for Claude/GPT/Gemini models.
Add ensure_model_pricing_seeded() call on database initialization.
Add test for model pricing auto-seeding verification.

* feat(proxy/usage): enhance token parser and logger for multi-format support

Parser enhancements:
- Add OpenAI Chat Completions format parsing (prompt_tokens/completion_tokens)
- Add model field to TokenUsage for actual model name extraction
- Add from_codex_response_adjusted() for proper cache token handling
- Add debug logging for better stream event tracing

Logger enhancements:
- Add first_token_ms, provider_type, is_streaming, cost_multiplier fields
- Extend RequestLog struct with full metadata tracking
- Update log_with_calculation() signature for new fields

Calculator: Update tests with model field in TokenUsage.

* feat(proxy): enhance proxy server with session tracking and OpenAI route

Error handling:
- Add StreamIdleTimeout and AuthError variants for better error classification

Module exports:
- Export ResponseType, StreamHandler, NonStreamHandler from response_handler
- Export ProxySession, ClientFormat from session module

Server routing:
- Add /v1/chat/completions route for OpenAI Chat Completions API

Handlers:
- Add log_usage_with_session() for enhanced usage tracking with session context
- Add first_token_ms timing measurement for streaming responses
- Use SseUsageCollector with start_time for accurate latency calculation
- Track is_streaming flag in usage logs

* feat(services): add pagination and enhanced filtering for request logs

Usage stats service:
- Change get_request_logs() from limit/offset to page/page_size pagination
- Return PaginatedLogs with total count, page, and page_size
- Add appType and providerName filters with LIKE search
- Add is_streaming, first_token_ms, duration_ms to RequestLogDetail
- Join with providers table for provider name lookup

Commands:
- Update get_request_logs command signature for pagination params

Module exports:
- Export PaginatedLogs struct

* feat(frontend): update usage types and API for pagination support

Types (usage.ts):
- Add isStreaming, firstTokenMs, durationMs to RequestLog
- Add PaginatedLogs interface with data, total, page, pageSize
- Change LogFilters: providerId -> appType + providerName

API (usage.ts):
- Change getRequestLogs params from limit/offset to page/pageSize
- Return PaginatedLogs instead of RequestLog[]
- Pass filters object directly to backend

Query (usage.ts):
- Update usageKeys.logs key generation for pagination
- Update useRequestLogs hook signature

* refactor(ui): enhance RequestLogTable with filtering and pagination

UI improvements:
- Add filter bar with app type, provider name, model, status selectors
- Add date range picker (startDate/endDate)
- Add search/reset/refresh buttons

Pagination:
- Implement proper page-based pagination with page info display
- Show total count and current page range
- Add prev/next navigation buttons

Features:
- Default to last 24 hours filter
- Streamlined table columns layout
- Query invalidation on refresh

* style(config): format mcpPresets code style

Apply consistent formatting to createNpxCommand function and
sequential-thinking server configuration.

* fix(ui): update SettingsPage tab styles for improved appearance (#342)

* feat(model-test): add provider model availability testing

Implement standalone model testing feature to verify provider API connectivity:
- Add ModelTestService for Claude/Codex/Gemini endpoint testing
- Create model_test_logs table for test result persistence
- Add test button to ProviderCard with loading state
- Include ModelTestConfigPanel for customizing test parameters

* fix(proxy): resolve token parsing for OpenRouter streaming responses

Problem:
- OpenRouter and similar third-party services return streaming responses
  where input_tokens appear in message_delta instead of message_start
- The previous implementation only extracted input_tokens from message_start,
  causing input_tokens to be recorded as 0 for these providers

Changes:
- streaming.rs: Add prompt_tokens field to Usage struct and include
  input_tokens in the transformed message_delta event when converting
  OpenAI format to Anthropic format
- parser.rs: Update from_claude_stream_events() to handle input_tokens
  from both message_start (native Claude API) and message_delta (OpenRouter)
  - Use if-let pattern instead of direct unwrap for safer parsing
  - Only update input_tokens from message_delta if not already set
- logger.rs: Adjust test parameters to match updated function signature

Tests:
- Add test_openrouter_stream_parsing() for OpenRouter format validation
- Add test_native_claude_stream_parsing() for native Claude API validation

* fix(pricing): standardize model ID format for pricing lookup

Normalize model IDs by removing vendor prefixes and converting dots to hyphens to ensure consistent pricing lookups across different API response formats.

Changes:
- Update seed data to use hyphen format (e.g., gpt-5-1, gemini-2-5-pro)
- Add normalize_model_id() function to strip vendor prefixes (anthropic/, openai/)
- Convert dots to hyphens in model IDs (claude-haiku-4.5 → claude-haiku-4-5)
- Try both original and normalized IDs for exact matching
- Use normalized ID for suffix-based fallback matching
- Add comprehensive test cases for prefix and dot handling
- Add warning log when no pricing found

This ensures pricing lookups work correctly for:
- Models with vendor prefixes: anthropic/claude-haiku-4.5
- Models with dots in version: claude-sonnet-4.5
- Models with date suffixes: claude-haiku-4-5-20240229

* style(rust): apply clippy formatting suggestions

Apply automatic clippy fixes for uninlined_format_args warnings across Rust codebase. Replace format string placeholders with inline variable syntax for improved readability.

Changes:
- Convert format!("{}", var) to format!("{var}")
- Apply to model_test.rs, parser.rs, and usage_stats.rs
- Fix line length issues by breaking long function calls
- Improve code formatting consistency

All changes are automatic formatting with no functional impact.

* fix(ui): restore card borders in usage statistics panels

Restore proper card styling for ModelTestConfigPanel and PricingConfigPanel by adding back border and rounded-lg classes. The transparent background styling was causing visual inconsistency.

Changes:
- Replace border-none bg-transparent shadow-none with border rounded-lg
- Apply to both loading and error states for consistency
- Format TypeScript code for better readability
- Break long function signatures across multiple lines

This ensures the usage statistics panels have consistent visual appearance with proper borders and rounded corners.

* feat(pricing): add GPT-5 Codex model pricing presets

Add pricing configuration for GPT-5 Codex variants to support cost tracking for Codex-specific models.

Changes:
- Add gpt-5-codex model with standard GPT-5 pricing
- Add gpt-5-1-codex model with standard GPT-5.1 pricing
- Input: $1.25/M tokens, Output: $10/M tokens
- Cache read: $0.125/M tokens, Cache creation: $0

This ensures accurate cost calculation for Codex API requests using GPT-5 Codex models.
2025-12-05 11:26:41 +08:00
Jason bf9228093b fix: add fallback for crypto.randomUUID() on older WebViews
crypto.randomUUID() is not available on older systems (macOS < 12.3,
Safari < 15.4). This caused "crypto.randomUUID is not a function"
error when adding providers.

- Add generateUUID() utility with getRandomValues() fallback
- Provide user-friendly error message if crypto API unavailable
2025-12-04 17:01:17 +08:00
Forte Scarlet 420b5d4389 feat: 为切换弹出提示框增加可关闭按钮 (#350) 2025-12-04 15:04:39 +08:00
wenyuan b1e4c37f9c fix(ui): update SettingsPage tab styles for improved appearance (#342) 2025-12-03 10:21:37 +08:00
farion1231 74a2e9c08b fix(mcp): use browser-compatible platform detection for MCP presets
Replace Node.js process.platform check with navigator.userAgent-based
detection from @/lib/platform. The previous implementation using
process.platform failed in Tauri's browser environment, causing
Windows platform to always return false and not apply the required
'cmd /c' wrapper for npx commands.

This fix ensures that on Windows, MCP presets like sequential-thinking
correctly use 'cmd /c npx' format instead of direct 'npx', eliminating
the warning: "Windows requires 'cmd /c' wrapper to execute npx"

Changes:
- Import isWindows() from @/lib/platform instead of defining locally
- Remove incorrect process.platform-based detection
- Now properly detects Windows in browser/WebView environment
2025-12-02 15:43:14 +08:00
farion1231 169db5b6d8 chore: add nul to .gitignore to prevent Windows reserved filename tracking 2025-12-02 10:00:15 +08:00
185 changed files with 22276 additions and 1237 deletions
+1
View File
@@ -17,3 +17,4 @@ GEMINI.md
/.idea
/.vscode
vitest-report.json
nul
+84 -18
View File
@@ -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/),
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
- **macOS tray template icon** - Use system template icon for better dark/light mode adaptation
- **AiGoCode partner** - Added AiGoCode icon and partner promotion
- **Skip first-run confirmation** - New setting to skip Claude Code first-run confirmation dialog
- **DMXAPI partner** - Added DMXAPI as official partner
- **Provider icons** - Added icons for OpenRouter, LongCat, ModelScope, AiHubMix
- **Provider Icon Colors** - Customize provider icon colors (#385)
- **Deeplink Usage Config** - Import usage query config via deeplink (#400)
- **Error Request Logging** - Detailed logging for proxy requests (#401)
- **Closable Toast** - Added close button to switch notification toast (#350)
- **Icon Color Component** - ProviderIcon component supports color prop (#384)
### Fixed
- **UI header layout** - Fixed content being covered by fixed header (added padding-top)
- **Dark mode visibility** - Improved text visibility in dark mode
- **Azure link** - Corrected Azure website link (#407)
- **Skill installation** - Use directory basename for skill installation path (#358)
- **SQL import refresh** - Refresh all providers immediately after SQL import
- **MCP sync safety** - Skip sync when target CLI app is not installed
#### Proxy Related
- Takeover Codex base_url via model_provider
- Harden crash recovery with fallback detection
- Sync UI when active provider differs from current setting
- Resolve circuit breaker race condition and error classification
- Stabilize live takeover and provider editing
- Reset health badges when proxy stops
- Retry failover for all HTTP errors including 4xx
- Fix HalfOpen counter underflow and config field inconsistencies
- Resolve circuit breaker state persistence and HalfOpen deadlock
- Auto-recover live config after abnormal exit
- Update live backup when hot-switching provider in proxy mode
- Wait for server shutdown before exiting app
- Disable auto-start on app launch by resetting enabled flag on stop
- Sync live config tokens to database before takeover
- Resolve 404 error and auto-setup proxy targets
### 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
- **Provider switch notification** - Removed restart prompt from notification
- **SQL import restriction** - Restrict SQL import to CC Switch exported backups only
- **GLM partner images** - Updated partner banner images
#### UI Related
- Restore fade transition for Skills button
- Add close button to all success toasts
- Prevent card jitter when health badge appears
- Update SettingsPage tab styles (#342)
#### Other
- Fix Azure website link (#407)
- Add fallback to provider config for usage credentials (#360)
- Fix Windows black screen on startup (use system titlebar)
- Add fallback for crypto.randomUUID() on older WebViews
- Use correct npm package for Codex CLI version check
- Security fixes for JavaScript executor and usage script (#151)
### Improved
- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active
- **Card Animation** - Improved provider card hover animation
- **Remove Restart Prompt** - No longer prompts restart when switching providers
### Technical
- Implement per-app takeover mode
- Proxy module contains 20+ Rust files with complete layered architecture
- Add 5 new database tables for proxy functionality
- Modularize handlers.rs to reduce code duplication
- Remove is_proxy_target in favor of failover_queue
### Stats
- 55 commits since v3.8.2
- 164 files changed
- +22,164 / -570 lines
---
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

+165
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.8.3",
"version": "3.9.0-1",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"scripts": {
"dev": "pnpm tauri dev",
@@ -26,16 +26,16 @@
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.20",
"cross-fetch": "^4.1.0",
"jsdom": "^25.0.0",
"msw": "^2.11.6",
"postcss": "^8.4.49",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^2.0.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17"
"vitest": "^2.0.5"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
@@ -50,6 +50,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@lobehub/icons-static-svg": "^1.73.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -67,7 +68,9 @@
"@tauri-apps/plugin-updater": "^2.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"framer-motion": "^12.23.25",
"i18next": "^25.5.2",
"jsonc-parser": "^3.2.1",
"lucide-react": "^0.542.0",
@@ -75,6 +78,7 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.65.0",
"react-i18next": "^16.0.0",
"recharts": "^3.5.1",
"smol-toml": "^1.4.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
+423
View File
@@ -44,6 +44,9 @@ importers:
'@lobehub/icons-static-svg':
specifier: ^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':
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)
@@ -95,9 +98,15 @@ importers:
clsx:
specifier: ^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:
specifier: ^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:
specifier: ^25.5.2
version: 25.5.2(typescript@5.9.2)
@@ -119,6 +128,9 @@ importers:
react-i18next:
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)
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:
specifier: ^1.4.2
version: 1.4.2
@@ -649,6 +661,19 @@ packages:
'@radix-ui/primitive@1.1.3':
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':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
@@ -675,6 +700,19 @@ packages:
'@types/react-dom':
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':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies:
@@ -1047,6 +1085,17 @@ packages:
'@radix-ui/rect@1.1.1':
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':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -1161,6 +1210,9 @@ packages:
cpu: [x64]
os: [win32]
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
@@ -1307,6 +1359,33 @@ packages:
'@types/babel__traverse@7.28.0':
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':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1327,6 +1406,9 @@ packages:
'@types/statuses@2.0.6':
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':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -1480,6 +1562,12 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
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:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
@@ -1526,6 +1614,50 @@ packages:
csstype@3.1.3:
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:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -1539,6 +1671,9 @@ packages:
supports-color:
optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -1609,6 +1744,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
es-toolkit@1.42.0:
resolution: {integrity: sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -1621,6 +1759,9 @@ packages:
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
expect-type@1.2.2:
resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==}
engines: {node: '>=12.0.0'}
@@ -1652,6 +1793,20 @@ packages:
fraction.js@5.3.4:
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:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1738,10 +1893,20 @@ packages:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
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:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
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:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@@ -1925,6 +2090,12 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
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:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2115,6 +2286,18 @@ packages:
react-is@17.0.2:
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:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -2160,14 +2343,33 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
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:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
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:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
@@ -2284,6 +2486,9 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2392,9 +2597,17 @@ packages:
'@types/react':
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:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -3031,6 +3244,23 @@ snapshots:
'@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)':
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)
@@ -3056,6 +3286,22 @@ snapshots:
'@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)':
dependencies:
'@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': {}
'@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': {}
'@rollup/rollup-android-arm-eabi@4.46.2':
@@ -3478,6 +3736,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.46.2':
optional: true
'@standard-schema/spec@1.0.0': {}
'@standard-schema/utils@0.3.0': {}
'@tanstack/query-core@5.90.3': {}
@@ -3609,6 +3869,30 @@ snapshots:
dependencies:
'@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/node@20.19.9':
@@ -3628,6 +3912,8 @@ snapshots:
'@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))':
dependencies:
'@babel/core': 7.28.0
@@ -3796,6 +4082,18 @@ snapshots:
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:
dependencies:
'@codemirror/autocomplete': 6.18.7
@@ -3841,6 +4139,44 @@ snapshots:
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:
dependencies:
whatwg-mimetype: 4.0.0
@@ -3850,6 +4186,8 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js-light@2.5.1: {}
decimal.js@10.6.0: {}
deep-eql@5.0.2: {}
@@ -3902,6 +4240,8 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.2
es-toolkit@1.42.0: {}
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -3934,6 +4274,8 @@ snapshots:
dependencies:
'@types/estree': 1.0.8
eventemitter3@5.0.1: {}
expect-type@1.2.2: {}
fast-glob@3.3.3:
@@ -3966,6 +4308,15 @@ snapshots:
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:
optional: true
@@ -4051,8 +4402,14 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
immer@10.2.0: {}
immer@11.0.1: {}
indent-string@4.0.0: {}
internmap@2.0.3: {}
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
@@ -4202,6 +4559,12 @@ snapshots:
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: {}
msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2):
@@ -4352,6 +4715,15 @@ snapshots:
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-remove-scroll-bar@2.3.8(@types/react@18.3.23)(react@18.3.1):
@@ -4393,13 +4765,41 @@ snapshots:
dependencies:
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:
dependencies:
indent-string: 4.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: {}
reselect@5.1.1: {}
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
@@ -4545,6 +4945,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@@ -4629,8 +5031,29 @@ snapshots:
optionalDependencies:
'@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: {}
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):
dependencies:
cac: 6.7.14
+355 -67
View File
@@ -257,6 +257,28 @@ dependencies = [
"windows-sys 0.61.1",
]
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "async-task"
version = "4.7.1"
@@ -320,6 +342,61 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "backtrace"
version = "0.3.76"
@@ -509,12 +586,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.10.1"
@@ -624,14 +695,18 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.8.3"
version = "3.9.0-1"
dependencies = [
"anyhow",
"async-stream",
"auto-launch",
"axum",
"base64 0.22.1",
"bytes",
"chrono",
"dirs 5.0.1",
"futures",
"hyper",
"indexmap 2.11.4",
"log",
"objc2 0.5.2",
@@ -641,6 +716,7 @@ dependencies = [
"reqwest",
"rquickjs",
"rusqlite",
"rust_decimal",
"serde",
"serde_json",
"serde_yaml",
@@ -656,11 +732,14 @@ dependencies = [
"tauri-plugin-store",
"tauri-plugin-updater",
"tempfile",
"thiserror 1.0.69",
"thiserror 2.0.17",
"tokio",
"toml 0.8.2",
"toml_edit 0.22.27",
"tower 0.4.13",
"tower-http 0.5.2",
"url",
"uuid",
"winreg 0.52.0",
"zip 2.4.2",
]
@@ -789,6 +868,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -812,9 +901,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.9.4",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -825,7 +914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.9.4",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -1210,6 +1299,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.0"
@@ -1375,6 +1473,15 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1382,7 +1489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -1396,6 +1503,12 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1839,6 +1952,25 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "h2"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.11.4",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1963,6 +2095,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.7.0"
@@ -1973,9 +2111,11 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@@ -2001,6 +2141,22 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.17"
@@ -2020,9 +2176,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2056,7 +2214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
dependencies = [
"byteorder",
"png 0.17.16",
"png",
]
[[package]]
@@ -2172,19 +2330,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.0",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2560,6 +2705,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.7.6"
@@ -2608,16 +2759,6 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "moxcms"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -2633,12 +2774,29 @@ dependencies = [
"objc2-core-foundation",
"objc2-foundation 0.3.1",
"once_cell",
"png 0.17.16",
"png",
"serde",
"thiserror 2.0.17",
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -3056,6 +3214,50 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.9.4",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -3361,19 +3563,6 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "png"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
dependencies = [
"bitflags 2.9.4",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -3505,15 +3694,6 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "pxfm"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84"
dependencies = [
"num-traits",
]
[[package]]
name = "quick-xml"
version = "0.37.5"
@@ -3821,16 +4001,21 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -3841,10 +4026,11 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower 0.5.2",
"tower-http 0.6.6",
"tower-service",
"url",
"wasm-bindgen",
@@ -4088,6 +4274,15 @@ dependencies = [
"sdd",
]
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.1",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -4163,6 +4358,29 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -4257,6 +4475,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -4491,7 +4720,7 @@ dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics",
"foreign-types",
"foreign-types 0.5.0",
"js-sys",
"log",
"objc2 0.5.2",
@@ -4632,6 +4861,27 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -4653,7 +4903,7 @@ checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7"
dependencies = [
"bitflags 2.9.4",
"block2 0.6.1",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dispatch",
@@ -4737,7 +4987,6 @@ dependencies = [
"heck 0.5.0",
"http",
"http-range",
"image",
"jni",
"libc",
"log",
@@ -4806,7 +5055,7 @@ dependencies = [
"ico",
"json-patch",
"plist",
"png 0.17.16",
"png",
"proc-macro2",
"quote",
"semver",
@@ -5293,6 +5542,16 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -5430,6 +5689,17 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109"
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower"
version = "0.5.2"
@@ -5443,6 +5713,23 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags 2.9.4",
"bytes",
"http",
"http-body",
"http-body-util",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]]
@@ -5458,7 +5745,7 @@ dependencies = [
"http-body",
"iri-string",
"pin-project-lite",
"tower",
"tower 0.5.2",
"tower-layer",
"tower-service",
]
@@ -5481,6 +5768,7 @@ version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -5522,7 +5810,7 @@ dependencies = [
"objc2-core-graphics",
"objc2-foundation 0.3.1",
"once_cell",
"png 0.17.16",
"png",
"serde",
"thiserror 2.0.17",
"windows-sys 0.59.0",
+13 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.8.3"
version = "3.9.0-1"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
@@ -26,7 +26,7 @@ serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
chrono = { version = "0.4", features = ["serde"] }
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset", "image-png"] }
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] }
tauri-plugin-log = "2"
tauri-plugin-opener = "2"
tauri-plugin-process = "2"
@@ -37,12 +37,18 @@ tauri-plugin-deep-link = "2"
dirs = "5.0"
toml = "0.8"
toml_edit = "0.22"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
futures = "0.3"
async-stream = "0.3"
bytes = "1.5"
axum = "0.7"
tower = "0.4"
tower-http = { version = "0.5", features = ["cors"] }
hyper = { version = "1.0", features = ["full"] }
regex = "1.10"
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
thiserror = "1.0"
thiserror = "2.0"
anyhow = "1.0"
zip = "2.2"
serde_yaml = "0.9"
@@ -53,6 +59,8 @@ once_cell = "1.21.3"
base64 = "0.22"
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
indexmap = { version = "2", features = ["serde"] }
rust_decimal = "1.33"
uuid = { version = "1.11", features = ["v4"] }
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

-49
View File
@@ -105,55 +105,6 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
Ok(Some(content))
}
/// 在 ~/.claude.json 根对象写入 hasCompletedOnboarding=true(用于跳过 Claude Code 初次安装确认)
/// 仅增量写入该字段,其他字段保持不变
pub fn set_has_completed_onboarding() -> Result<bool, AppError> {
let path = user_config_path();
let mut root = if path.exists() {
read_json_value(&path)?
} else {
serde_json::json!({})
};
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
let already = obj
.get("hasCompletedOnboarding")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if already {
return Ok(false);
}
obj.insert("hasCompletedOnboarding".into(), Value::Bool(true));
write_json_value(&path, &root)?;
Ok(true)
}
/// 删除 ~/.claude.json 根对象的 hasCompletedOnboarding 字段(恢复 Claude Code 初次安装确认)
/// 仅增量删除该字段,其他字段保持不变
pub fn clear_has_completed_onboarding() -> Result<bool, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(false);
}
let mut root = read_json_value(&path)?;
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
let existed = obj.remove("hasCompletedOnboarding").is_some();
if !existed {
return Ok(false);
}
write_json_value(&path, &root)?;
Ok(true)
}
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
+84
View File
@@ -0,0 +1,84 @@
//! 故障转移队列命令
//!
//! 管理代理模式下的故障转移队列
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())
}
/// 重新排序故障转移队列
#[tauri::command]
pub async fn reorder_failover_queue(
state: tauri::State<'_, AppState>,
app_type: String,
provider_ids: Vec<String>,
) -> Result<(), String> {
state
.db
.reorder_failover_queue(&app_type, &provider_ids)
.map_err(|e| e.to_string())
}
/// 设置故障转移队列项的启用状态
#[tauri::command]
pub async fn set_failover_item_enabled(
state: tauri::State<'_, AppState>,
app_type: String,
provider_id: String,
enabled: bool,
) -> Result<(), String> {
state
.db
.set_failover_item_enabled(&app_type, &provider_id, enabled)
.map_err(|e| e.to_string())
}
+201
View File
@@ -58,3 +58,204 @@ pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
pub async fn get_migration_result() -> Result<bool, String> {
Ok(crate::init_status::take_migration_success())
}
#[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;
let output = if cfg!(target_os = "windows") {
Command::new("cmd")
.args(["/C", &format!("{tool} --version")])
.output()
} else {
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);
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()))
}
+8
View File
@@ -3,23 +3,31 @@
mod config;
mod deeplink;
mod env;
mod failover;
mod import_export;
mod mcp;
mod misc;
mod plugin;
mod prompt;
mod provider;
mod proxy;
mod settings;
pub mod skill;
mod stream_check;
mod usage;
pub use config::*;
pub use deeplink::*;
pub use env::*;
pub use failover::*;
pub use import_export::*;
pub use mcp::*;
pub use misc::*;
pub use plugin::*;
pub use prompt::*;
pub use provider::*;
pub use proxy::*;
pub use settings::*;
pub use skill::*;
pub use stream_check::*;
pub use usage::*;
-12
View File
@@ -34,15 +34,3 @@ pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String>
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())
}
/// Claude Code:跳过初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding=true
#[tauri::command]
pub async fn apply_claude_onboarding_skip() -> Result<bool, String> {
crate::claude_mcp::set_has_completed_onboarding().map_err(|e| e.to_string())
}
/// Claude Code:恢复初次安装确认(删除 ~/.claude.json 的 hasCompletedOnboarding 字段)
#[tauri::command]
pub async fn clear_claude_onboarding_skip() -> Result<bool, String> {
crate::claude_mcp::clear_has_completed_onboarding().map_err(|e| e.to_string())
}
+179
View File
@@ -0,0 +1,179 @@
//! 代理服务相关的 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(true).await
}
/// 启动代理服务器(带 Live 配置接管)
#[tauri::command]
pub async fn start_proxy_with_takeover(
state: tauri::State<'_, AppState>,
) -> Result<ProxyServerInfo, String> {
state.proxy_service.start_with_takeover().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
}
/// 检查代理服务器是否正在运行
#[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())
}
/// 重置熔断器
#[tauri::command]
pub async fn reset_circuit_breaker(
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?;
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)
}
+76 -16
View File
@@ -1,3 +1,4 @@
use crate::app_config::AppType;
use crate::error::format_skill_error;
use crate::services::skill::SkillState;
use crate::services::{Skill, SkillRepo, SkillService};
@@ -8,15 +9,46 @@ use tauri::State;
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]
pub async fn get_skills(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> {
get_skills_for_app("claude".to_string(), service, app_state).await
}
#[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 skills = service
.0
.list_skills(repos)
.await
.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();
for skill in &skills {
if skill.installed && !existing_states.contains_key(&skill.directory) {
// 本地有该 skill,但数据库中没有记录,自动添加
if let Err(e) = app_state.db.update_skill_state(
&skill.directory,
&SkillState {
installed: true,
installed_at: Utc::now(),
},
) {
log::warn!("同步本地 skill {} 状态到数据库失败: {}", skill.directory, e);
if skill.installed {
let key = get_skill_key(&app_type, &skill.directory);
if !existing_states.contains_key(&key) {
// 本地有该 skill,但数据库中没有记录,自动添加
if let Err(e) = app_state.db.update_skill_state(
&key,
&SkillState {
installed: true,
installed_at: Utc::now(),
},
) {
log::warn!("同步本地 skill {key} 状态到数据库失败: {e}");
}
}
}
}
@@ -49,11 +84,23 @@ pub async fn install_skill(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> 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 skills = service
.0
.list_skills(repos)
.await
.map_err(|e| e.to_string())?;
@@ -93,16 +140,16 @@ pub async fn install_skill(
};
service
.0
.install_skill(directory.clone(), repo)
.await
.map_err(|e| e.to_string())?;
}
let key = get_skill_key(&app_type, &directory);
app_state
.db
.update_skill_state(
&directory,
&key,
&SkillState {
installed: true,
installed_at: Utc::now(),
@@ -119,16 +166,29 @@ pub fn uninstall_skill(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> 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
.0
.uninstall_skill(directory.clone())
.map_err(|e| e.to_string())?;
// Remove from database by setting installed = false
let key = get_skill_key(&app_type, &directory);
app_state
.db
.update_skill_state(
&directory,
&key,
&SkillState {
installed: false,
installed_at: Utc::now(),
+108
View File
@@ -0,0 +1,108 @@
//! 流式健康检查命令
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 {
if item.enabled {
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)
}
+178
View File
@@ -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,
}
+22 -23
View File
@@ -13,8 +13,6 @@ use std::fs;
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
impl Database {
/// 导出为 SQLite 兼容的 SQL 文本
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
@@ -38,8 +36,7 @@ impl Database {
}
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
let sql_content = sql_raw.trim_start_matches('\u{feff}');
Self::validate_cc_switch_sql_export(sql_content)?;
let sql_content = Self::sanitize_import_sql(&sql_raw);
// 导入前备份现有数据库
let backup_path = self.backup_database_file()?;
@@ -54,7 +51,7 @@ impl Database {
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
temp_conn
.execute_batch(sql_content)
.execute_batch(&sql_content)
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
// 补齐缺失表/索引并进行基础校验
@@ -96,17 +93,26 @@ impl Database {
Ok(snapshot)
}
fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> {
let trimmed = sql.trim_start();
if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) {
return Ok(());
/// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错
fn sanitize_import_sql(sql: &str) -> String {
let mut cleaned = String::new();
let lower_keyword = "sqlite_sequence";
for stmt in sql.split(';') {
let trimmed = stmt.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.to_ascii_lowercase().contains(lower_keyword) {
continue;
}
cleaned.push_str(trimmed);
cleaned.push_str(";\n");
}
Err(AppError::localized(
"backup.sql.invalid_format",
"仅支持导入由 CC Switch 导出的 SQL 备份文件。",
"Only SQL backups exported by CC Switch are supported.",
))
cleaned
}
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
@@ -123,15 +129,8 @@ impl Database {
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
let mut backup_id = base_id.clone();
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
let mut counter = 1;
while backup_path.exists() {
backup_id = format!("{base_id}_{counter}");
backup_path = backup_dir.join(format!("{backup_id}.db"));
counter += 1;
}
let backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
let backup_path = backup_dir.join(format!("{backup_id}.db"));
{
let conn = lock_conn!(self.conn);
+241
View File
@@ -0,0 +1,241 @@
//! 故障转移队列 DAO
//!
//! 管理代理模式下的故障转移队列
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::provider::Provider;
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// 故障转移队列条目
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FailoverQueueItem {
pub provider_id: String,
pub provider_name: String,
pub queue_order: i32,
pub enabled: bool,
pub created_at: i64,
}
impl Database {
/// 获取故障转移队列(按 queue_order 排序)
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 fq.provider_id, p.name, fq.queue_order, fq.enabled, fq.created_at
FROM failover_queue fq
JOIN providers p ON fq.provider_id = p.id AND fq.app_type = p.app_type
WHERE fq.app_type = ?1
ORDER BY fq.queue_order 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)?,
queue_order: row.get(2)?,
enabled: row.get(3)?,
created_at: row.get(4)?,
})
})
.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 queue = self.get_failover_queue(app_type)?;
let all_providers = self.get_all_providers(app_type)?;
let mut result = Vec::new();
for item in queue {
if item.enabled {
if let Some(provider) = all_providers.get(&item.provider_id) {
result.push(provider.clone());
}
}
}
Ok(result)
}
/// 添加供应商到故障转移队列末尾
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 获取当前最大 queue_order
let max_order: i32 = conn
.query_row(
"SELECT COALESCE(MAX(queue_order), 0) FROM failover_queue WHERE app_type = ?1",
[app_type],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
conn.execute(
"INSERT OR IGNORE INTO failover_queue (app_type, provider_id, queue_order, enabled, created_at)
VALUES (?1, ?2, ?3, 1, ?4)",
rusqlite::params![app_type, provider_id, max_order + 1, now],
)
.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);
// 获取被删除项的 queue_order
let removed_order: Option<i32> = conn
.query_row(
"SELECT queue_order FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
[app_type, provider_id],
|row| row.get(0),
)
.ok();
// 删除该项
conn.execute(
"DELETE FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
[app_type, provider_id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 重新排序后面的项(填补空隙)
if let Some(order) = removed_order {
conn.execute(
"UPDATE failover_queue
SET queue_order = queue_order - 1
WHERE app_type = ?1 AND queue_order > ?2",
rusqlite::params![app_type, order],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
}
/// 重新排序故障转移队列
/// provider_ids: 按新顺序排列的 provider_id 列表
pub fn reorder_failover_queue(
&self,
app_type: &str,
provider_ids: &[String],
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 使用事务确保原子性
conn.execute("BEGIN TRANSACTION", [])
.map_err(|e| AppError::Database(e.to_string()))?;
let result = (|| {
for (index, provider_id) in provider_ids.iter().enumerate() {
conn.execute(
"UPDATE failover_queue
SET queue_order = ?3
WHERE app_type = ?1 AND provider_id = ?2",
rusqlite::params![app_type, provider_id, (index + 1) as i32],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
})();
match result {
Ok(_) => {
conn.execute("COMMIT", [])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
Err(e) => {
conn.execute("ROLLBACK", []).ok();
Err(e)
}
}
}
/// 设置故障转移队列中供应商的启用状态
pub fn set_failover_item_enabled(
&self,
app_type: &str,
provider_id: &str,
enabled: bool,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2",
rusqlite::params![app_type, provider_id, enabled],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 清空故障转移队列
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM failover_queue 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 count: i32 = conn
.query_row(
"SELECT COUNT(*) FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
[app_type, provider_id],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
}
/// 获取可添加到故障转移队列的供应商(不在队列中的)
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 queue = self.get_failover_queue(app_type)?;
let queue_ids: std::collections::HashSet<_> =
queue.iter().map(|item| &item.provider_id).collect();
let available: Vec<Provider> = all_providers
.into_values()
.filter(|p| !queue_ids.contains(&p.id))
.collect();
Ok(available)
}
}
+12 -7
View File
@@ -1,11 +1,16 @@
//! 数据访问对象 (DAO) 模块
//! Data Access Object layer
//!
//! 提供各类数据的 CRUD 操作。
//! Database access operations for each domain
mod mcp;
mod prompts;
mod providers;
mod settings;
mod skills;
pub mod failover;
pub mod mcp;
pub mod prompts;
pub mod providers;
pub mod proxy;
pub mod settings;
pub mod skills;
pub mod stream_check;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
// 导出 FailoverQueueItem 供外部使用
pub use failover::FailoverQueueItem;
+69
View File
@@ -121,6 +121,55 @@ 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
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 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,
})
},
);
match result {
Ok(provider) => Ok(Some(provider)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 保存供应商(新增或更新)
///
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
@@ -256,6 +305,26 @@ impl Database {
Ok(())
}
/// 更新供应商的 settings_config(仅更新配置,不改变其他字段)
pub fn update_provider_settings_config(
&self,
app_type: &str,
provider_id: &str,
settings_config: &serde_json::Value,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
params![
serde_json::to_string(settings_config).unwrap(),
provider_id,
app_type
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 添加自定义端点
pub fn add_custom_endpoint(
&self,
+375
View File
@@ -0,0 +1,375 @@
//! 代理功能数据访问层
//!
//! 处理代理配置、Provider健康状态和使用统计的数据库操作
use crate::error::AppError;
use crate::proxy::types::*;
use super::super::{lock_conn, Database};
impl Database {
// ==================== Proxy Config ====================
/// 获取代理配置
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
// 在一个作用域内获取锁并查询,确保锁在await之前释放
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT enabled, listen_address, listen_port, max_retries,
request_timeout, enable_logging, live_takeover_active
FROM proxy_config WHERE id = 1",
[],
|row| {
Ok(ProxyConfig {
enabled: row.get::<_, i32>(0)? != 0,
listen_address: row.get(1)?,
listen_port: row.get::<_, i32>(2)? as u16,
max_retries: row.get::<_, i32>(3)? as u8,
request_timeout: row.get::<_, i32>(4)? as u64,
enable_logging: row.get::<_, i32>(5)? != 0,
live_takeover_active: row.get::<_, i32>(6).unwrap_or(0) != 0,
})
},
)
}; // conn锁在这里释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,插入默认配置
let default_config = ProxyConfig::default();
self.update_proxy_config(default_config.clone()).await?;
Ok(default_config)
}
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(
"INSERT OR REPLACE INTO proxy_config
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, live_takeover_active, target_app, created_at, updated_at)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
datetime('now'))",
rusqlite::params![
if config.enabled { 1 } else { 0 },
config.listen_address,
config.listen_port as i32,
config.max_retries as i32,
config.request_timeout as i32,
if config.enable_logging { 1 } else { 0 },
if config.live_takeover_active { 1 } else { 0 },
"claude", // 兼容旧字段,写入默认值
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 设置 Live 接管状态
pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1",
rusqlite::params![if active { 1 } else { 0 }],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 检查是否处于 Live 接管模式
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
// v3.7.0+:以 proxy_live_backup 是否存在作为“接管状态”的真实来源(更贴近 per-app 接管)
self.has_any_live_backup().await
}
// ==================== Provider Health ====================
/// 获取Provider健康状态
pub async fn get_provider_health(
&self,
provider_id: &str,
app_type: &str,
) -> Result<ProviderHealth, AppError> {
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)?,
})
},
)
.map_err(|e| 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(())
}
/// 清空所有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 ====================
/// 获取熔断器配置
pub async fn get_circuit_breaker_config(
&self,
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
let conn = lock_conn!(self.conn);
let config = conn
.query_row(
"SELECT failure_threshold, success_threshold, timeout_seconds,
error_rate_threshold, min_requests
FROM circuit_breaker_config WHERE id = 1",
[],
|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,
})
},
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(config)
}
/// 更新熔断器配置
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 circuit_breaker_config
SET failure_threshold = ?1,
success_threshold = ?2,
timeout_seconds = ?3,
error_rate_threshold = ?4,
min_requests = ?5,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1",
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(())
}
}
+20 -6
View File
@@ -13,18 +13,22 @@ impl Database {
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC")
.prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC")
.map_err(|e| AppError::Database(e.to_string()))?;
let skill_iter = stmt
.query_map([], |row| {
let key: String = row.get(0)?;
let installed: bool = row.get(1)?;
let installed_at_ts: i64 = row.get(2)?;
let directory: String = row.get(0)?;
let app_type: String = row.get(1)?;
let installed: bool = row.get(2)?;
let installed_at_ts: i64 = row.get(3)?;
let installed_at =
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
// 构建复合 key"app_type:directory"
let key = format!("{app_type}:{directory}");
Ok((
key,
SkillState {
@@ -44,11 +48,21 @@ impl Database {
}
/// 更新 Skill 状态
/// key 格式为 "app_type:directory"
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
// 解析 key
let (app_type, directory) = if let Some(idx) = key.find(':') {
let (app, dir) = key.split_at(idx);
(app, &dir[1..]) // 跳过冒号
} else {
// 向后兼容:如果没有前缀,默认为 claude
("claude", key)
};
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
params![key, state.installed, state.installed_at.timestamp()],
"INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
params![directory, app_type, state.installed, state.installed_at.timestamp()],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
@@ -0,0 +1,57 @@
//! 流式健康检查日志 DAO
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::services::stream_check::{StreamCheckConfig, StreamCheckResult};
impl Database {
/// 保存流式检查日志
pub fn save_stream_check_log(
&self,
provider_id: &str,
provider_name: &str,
app_type: &str,
result: &StreamCheckResult,
) -> Result<i64, AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT INTO stream_check_logs
(provider_id, provider_name, app_type, status, success, message,
response_time_ms, http_status, model_used, retry_count, tested_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
provider_id,
provider_name,
app_type,
format!("{:?}", result.status).to_lowercase(),
result.success,
result.message,
result.response_time_ms.map(|t| t as i64),
result.http_status.map(|s| s as i64),
result.model_used,
result.retry_count as i64,
result.tested_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(conn.last_insert_rowid())
}
/// 获取流式检查配置
pub fn get_stream_check_config(&self) -> Result<StreamCheckConfig, AppError> {
match self.get_setting("stream_check_config")? {
Some(json) => serde_json::from_str(&json)
.map_err(|e| AppError::Message(format!("解析配置失败: {e}"))),
None => Ok(StreamCheckConfig::default()),
}
}
/// 保存流式检查配置
pub fn save_stream_check_config(&self, config: &StreamCheckConfig) -> Result<(), AppError> {
let json = serde_json::to_string(config)
.map_err(|e| AppError::Message(format!("序列化配置失败: {e}")))?;
self.set_setting("stream_check_config", &json)
}
}
+6 -1
View File
@@ -31,6 +31,9 @@ mod schema;
#[cfg(test)]
mod tests;
// DAO 类型导出供外部使用
pub use dao::FailoverQueueItem;
use crate::config::get_app_config_dir;
use crate::error::AppError;
use rusqlite::Connection;
@@ -44,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 1;
pub(crate) const SCHEMA_VERSION: i32 = 2;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
@@ -95,6 +98,7 @@ impl Database {
};
db.create_tables()?;
db.apply_schema_migrations()?;
db.ensure_model_pricing_seeded()?;
Ok(db)
}
@@ -111,6 +115,7 @@ impl Database {
conn: Mutex::new(conn),
};
db.create_tables()?;
db.ensure_model_pricing_seeded()?;
Ok(db)
}
+528 -3
View File
@@ -89,9 +89,11 @@ impl Database {
// 5. Skills 表
conn.execute(
"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_at INTEGER NOT NULL DEFAULT 0
installed_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (directory, app_type)
)",
[],
)
@@ -120,6 +122,220 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 8. Proxy Config 表 (代理服务器配置)
// 代理配置表(单例)
conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 0,
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000,
max_retries INTEGER NOT NULL DEFAULT 3,
request_timeout INTEGER NOT NULL DEFAULT 300,
enable_logging INTEGER NOT NULL DEFAULT 1,
target_app TEXT NOT NULL DEFAULT 'claude',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 尝试添加 target_app 列(如果表已存在但缺少该列)
// 忽略 "duplicate column name" 错误
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN target_app TEXT NOT NULL DEFAULT 'claude'",
[],
);
// 9. Provider Health 表 (Provider健康状态)
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()))?;
// 13. Circuit Breaker Config 表 (熔断器配置)
conn.execute(
"CREATE TABLE IF NOT EXISTS circuit_breaker_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
failure_threshold INTEGER NOT NULL DEFAULT 5,
success_threshold INTEGER NOT NULL DEFAULT 2,
timeout_seconds INTEGER NOT NULL DEFAULT 60,
error_rate_threshold REAL NOT NULL DEFAULT 0.5,
min_requests INTEGER NOT NULL DEFAULT 10,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 插入默认熔断器配置
conn.execute(
"INSERT OR IGNORE INTO circuit_breaker_config (id) VALUES (1)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 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",
[],
);
// 14. Failover Queue 表 (故障转移队列)
conn.execute(
"CREATE TABLE IF NOT EXISTS failover_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_type TEXT NOT NULL,
provider_id TEXT NOT NULL,
queue_order INTEGER NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
UNIQUE (app_type, provider_id),
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 为故障转移队列创建索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_failover_queue_order
ON failover_queue(app_type, queue_order)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
@@ -150,7 +366,14 @@ impl Database {
0 => {
log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)");
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!(
@@ -237,6 +460,308 @@ impl Database {
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")?;
// 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)?;
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> {
+59
View File
@@ -272,3 +272,62 @@ fn dry_run_validates_schema_compatibility() {
"Dry-run should succeed with provider data: {result:?}"
);
}
#[test]
fn model_pricing_is_seeded_on_init() {
let db = Database::memory().expect("create memory db");
let conn = db.conn.lock().expect("lock conn");
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
.expect("count pricing");
assert!(
count > 0,
"模型定价数据应该在初始化时自动填充,实际数量: {}",
count
);
// 验证包含 Claude 模型
let claude_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'claude-%'",
[],
|row| row.get(0),
)
.expect("check claude");
assert!(
claude_count > 0,
"应该包含 Claude 模型定价,实际数量: {}",
claude_count
);
// 验证包含 GPT 模型
let gpt_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gpt-%'",
[],
|row| row.get(0),
)
.expect("check gpt");
assert!(
gpt_count > 0,
"应该包含 GPT 模型定价,实际数量: {}",
gpt_count
);
// 验证包含 Gemini 模型
let gemini_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gemini-%'",
[],
|row| row.get(0),
)
.expect("check gemini");
assert!(
gemini_count > 0,
"应该包含 Gemini 模型定价,实际数量: {}",
gemini_count
);
}
+23
View File
@@ -113,4 +113,27 @@ pub struct DeepLinkImportRequest {
/// Remote config URL
#[serde(skip_serializing_if = "Option::is_none")]
pub config_url: Option<String>,
// ============ Usage script fields (v3.9+) ============
/// Whether to enable usage query (default: true if usage_script is provided)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_enabled: Option<bool>,
/// Base64 encoded usage query script code
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<String>,
/// Usage query API key (if different from provider API key)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_api_key: Option<String>,
/// Usage query base URL (if different from provider endpoint)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_base_url: Option<String>,
/// Usage query access token (for NewAPI template)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_access_token: Option<String>,
/// Usage query user ID (for NewAPI template)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_user_id: Option<String>,
/// Auto query interval in minutes (0 to disable)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_auto_interval: Option<u64>,
}
+41
View File
@@ -122,6 +122,19 @@ fn parse_provider_deeplink(
let config_url = params.get("configUrl").cloned();
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
// Extract usage script fields (v3.9+)
let usage_enabled = params
.get("usageEnabled")
.and_then(|v| v.parse::<bool>().ok());
let usage_script = params.get("usageScript").cloned();
let usage_api_key = params.get("usageApiKey").cloned();
let usage_base_url = params.get("usageBaseUrl").cloned();
let usage_access_token = params.get("usageAccessToken").cloned();
let usage_user_id = params.get("usageUserId").cloned();
let usage_auto_interval = params
.get("usageAutoInterval")
.and_then(|v| v.parse::<u64>().ok());
Ok(DeepLinkImportRequest {
version,
resource,
@@ -146,6 +159,13 @@ fn parse_provider_deeplink(
config,
config_format,
config_url,
usage_enabled,
usage_script,
usage_api_key,
usage_base_url,
usage_access_token,
usage_user_id,
usage_auto_interval,
})
}
@@ -206,6 +226,13 @@ fn parse_prompt_deeplink(
config: None,
config_format: None,
config_url: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
})
}
@@ -261,6 +288,13 @@ fn parse_mcp_deeplink(
directory: None,
branch: None,
config_url: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
})
}
@@ -309,5 +343,12 @@ fn parse_skill_deeplink(
config: None,
config_format: None,
config_url: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
})
}
+56 -2
View File
@@ -5,7 +5,7 @@
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
use super::DeepLinkImportRequest;
use crate::error::AppError;
use crate::provider::Provider;
use crate::provider::{Provider, ProviderMeta, UsageScript};
use crate::services::ProviderService;
use crate::store::AppState;
use crate::AppType;
@@ -117,6 +117,9 @@ pub(crate) fn build_provider_from_request(
AppType::Gemini => build_gemini_settings(request),
};
// Build usage script configuration if provided
let meta = build_provider_meta(request)?;
let provider = Provider {
id: String::new(), // Will be generated by caller
name: request.name.clone().unwrap_or_default(),
@@ -126,7 +129,7 @@ pub(crate) fn build_provider_from_request(
created_at: None,
sort_index: None,
notes: request.notes.clone(),
meta: None,
meta,
icon: request.icon.clone(),
icon_color: None,
};
@@ -134,6 +137,57 @@ pub(crate) fn build_provider_from_request(
Ok(provider)
}
/// Build provider meta with usage script configuration
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
// Check if any usage script fields are provided
if request.usage_script.is_none()
&& request.usage_enabled.is_none()
&& request.usage_api_key.is_none()
&& request.usage_base_url.is_none()
&& request.usage_access_token.is_none()
&& request.usage_user_id.is_none()
&& request.usage_auto_interval.is_none()
{
return Ok(None);
}
// Decode usage script code if provided
let code = if let Some(script_b64) = &request.usage_script {
let decoded = decode_base64_param("usage_script", script_b64)?;
String::from_utf8(decoded)
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))?
} else {
String::new()
};
// Determine enabled state: explicit param > has code > false
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
// Build UsageScript - use provider's API key and endpoint as defaults
let usage_script = UsageScript {
enabled,
language: "javascript".to_string(),
code,
timeout: Some(10),
api_key: request
.usage_api_key
.clone()
.or_else(|| request.api_key.clone()),
base_url: request
.usage_base_url
.clone()
.or_else(|| request.endpoint.clone()),
access_token: request.usage_access_token.clone(),
user_id: request.usage_user_id.clone(),
auto_query_interval: request.usage_auto_interval,
};
Ok(Some(ProviderMeta {
usage_script: Some(usage_script),
..Default::default()
}))
}
/// Build Claude settings configuration
fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let mut env = serde_json::Map::new();
+28
View File
@@ -145,6 +145,13 @@ fn test_build_gemini_provider_with_model() {
content: None,
description: None,
enabled: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
@@ -191,6 +198,13 @@ fn test_build_gemini_provider_without_model() {
content: None,
description: None,
enabled: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
@@ -232,6 +246,13 @@ fn test_parse_and_merge_config_claude() {
content: None,
description: None,
enabled: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let merged = parse_and_merge_config(&request).unwrap();
@@ -275,6 +296,13 @@ fn test_parse_and_merge_config_url_override() {
content: None,
description: None,
enabled: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let merged = parse_and_merge_config(&request).unwrap();
+15
View File
@@ -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 {
fn from(err: AppError) -> Self {
err.to_string()
}
}
impl serde::Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
/// 格式化为 JSON 错误字符串,前端可解析为结构化错误
pub fn format_skill_error(
code: &str,
+11 -4
View File
@@ -42,8 +42,8 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
///
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
/// - httpUrl → url + type: "http"
/// - 仅有 url 字段 → 保持不变(SSE 类型)
/// - 仅有 command 字段 → 保持不变(stdio 类型)
/// - 仅有 url 字段 → 补齐 type: "sse"Gemini 以字段名推断传输类型)
/// - 仅有 command 字段 → 补齐 type: "stdio"
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path();
if !path.exists() {
@@ -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("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()));
}
}
}
}
+180 -40
View File
@@ -17,6 +17,7 @@ mod prompt;
mod prompt_files;
mod provider;
mod provider_defaults;
mod proxy;
mod services;
mod settings;
mod store;
@@ -38,8 +39,8 @@ pub use mcp::{
};
pub use provider::{Provider, ProviderMeta};
pub use services::{
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SkillService,
SpeedtestService,
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
SkillService, SpeedtestService,
};
pub use settings::{update_settings, AppSettings};
pub use store::AppState;
@@ -47,10 +48,7 @@ use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc;
#[cfg(target_os = "macos")]
use tauri::image::Image;
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
#[cfg(target_os = "macos")]
use tauri::RunEvent;
use tauri::{Emitter, Manager};
@@ -135,19 +133,6 @@ async fn update_tray_menu(
}
}
#[cfg(target_os = "macos")]
fn macos_tray_icon() -> Option<Image<'static>> {
const ICON_BYTES: &[u8] = include_bytes!("../icons/tray/macos/statusbar_template_3x.png");
match Image::from_bytes(ICON_BYTES) {
Ok(icon) => Some(icon),
Err(err) => {
log::warn!("Failed to load macOS tray icon: {err}");
None
}
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut builder = tauri::Builder::default();
@@ -346,6 +331,9 @@ pub fn run() {
let app_state = AppState::new(db);
// 设置 AppHandle 用于代理故障转移时的 UI 更新
app_state.proxy_service.set_app_handle(app.handle().clone());
// ============================================================
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响)
// ============================================================
@@ -515,26 +503,11 @@ pub fn run() {
})
.show_menu_on_left_click(true);
// 使用平台对应的托盘图标(macOS 使用模板图标适配深浅色)
#[cfg(target_os = "macos")]
{
if let Some(icon) = macos_tray_icon() {
tray_builder = tray_builder.icon(icon).icon_as_template(true);
} else if let Some(icon) = app.default_window_icon() {
log::warn!("Falling back to default window icon for tray");
tray_builder = tray_builder.icon(icon.clone());
} else {
log::warn!("Failed to load macOS tray icon for tray");
}
}
#[cfg(not(target_os = "macos"))]
{
if let Some(icon) = app.default_window_icon() {
tray_builder = tray_builder.icon(icon.clone());
} else {
log::warn!("Failed to get default window icon for tray");
}
// 统一使用应用默认图标;待托盘模板图标就绪后再启用
if let Some(icon) = app.default_window_icon() {
tray_builder = tray_builder.icon(icon.clone());
} else {
log::warn!("Failed to get default window icon for tray");
}
let _tray = tray_builder.build(app)?;
@@ -551,6 +524,72 @@ 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
}
};
// 获取代理配置
let proxy_config = match state.db.get_proxy_config().await {
Ok(config) => Some(config),
Err(e) => {
log::error!("启动时获取代理配置失败: {e}");
None
}
};
if has_backups {
// 有备份说明上次退出时有接管状态
// 检查 Live 配置是否仍处于被接管状态(包含占位符)
let live_taken_over = state.proxy_service.detect_takeover_in_live_configs();
if live_taken_over {
// Live 配置仍是接管状态,尝试重新启动代理服务以恢复接管
log::info!("检测到上次接管状态,正在重新启动代理服务...");
match state.proxy_service.start(false).await {
Ok(info) => {
log::info!("代理服务器已恢复启动: {}:{}", info.address, info.port);
}
Err(e) => {
// 启动失败,恢复 Live 配置
log::error!("恢复代理服务失败: {e},正在恢复 Live 配置...");
if let Err(e) = state.proxy_service.recover_from_crash().await {
log::error!("恢复 Live 配置失败: {e}");
} else {
log::info!("Live 配置已恢复");
}
}
}
} else {
// Live 配置已经是正常状态,清理残留备份
log::info!("Live 配置已是正常状态,清理残留备份...");
if let Err(e) = state.db.delete_all_live_backups().await {
log::warn!("清理残留 Live 备份失败: {e}");
}
}
} else if let Some(config) = proxy_config {
// 没有备份,检查是否需要自动启动代理服务器(总开关)
if config.enabled {
log::info!("代理服务配置为启用,正在启动...");
match state.proxy_service.start(true).await {
Ok(info) => {
log::info!("代理服务器自动启动成功: {}:{}", info.address, info.port)
}
Err(e) => log::error!("代理服务器自动启动失败: {e}"),
}
}
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -586,8 +625,6 @@ pub fn run() {
commands::read_claude_plugin_config,
commands::apply_claude_plugin_config,
commands::is_claude_plugin_applied,
commands::apply_claude_onboarding_skip,
commands::clear_claude_onboarding_skip,
// Claude MCP management
commands::get_claude_mcp_status,
commands::read_claude_mcp_config,
@@ -643,14 +680,59 @@ pub fn run() {
commands::restore_env_backup,
// Skill management
commands::get_skills,
commands::get_skills_for_app,
commands::install_skill,
commands::install_skill_for_app,
commands::uninstall_skill,
commands::uninstall_skill_for_app,
commands::get_skill_repos,
commands::add_skill_repo,
commands::remove_skill_repo,
// Auto launch
commands::set_auto_launch,
commands::get_auto_launch_status,
// Proxy server management
commands::start_proxy_server,
commands::start_proxy_with_takeover,
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,
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::reorder_failover_queue,
commands::set_failover_item_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
@@ -658,6 +740,26 @@ pub fn run() {
.expect("error while running tauri application");
app.run(|app_handle, event| {
// 处理退出请求(所有平台)
if let RunEvent::ExitRequested { api, .. } = &event {
log::info!("收到退出请求,开始清理...");
// 阻止立即退出,执行清理
api.prevent_exit();
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
cleanup_before_exit(&app_handle).await;
log::info!("清理完成,退出应用");
// 短暂等待确保所有 I/O 操作(如数据库写入)刷新到磁盘
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// 使用 std::process::exit 避免再次触发 ExitRequested
std::process::exit(0);
});
return;
}
#[cfg(target_os = "macos")]
{
match event {
@@ -737,6 +839,44 @@ pub fn run() {
});
}
// ============================================================
// 应用退出清理
// ============================================================
/// 应用退出前的清理工作
///
/// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。
/// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。
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;
// 检查代理是否在运行
if proxy_service.is_running().await {
log::info!("检测到代理服务器正在运行,开始清理...");
// 检查是否处于 Live 接管模式
if let Ok(is_takeover) = state.db.is_live_takeover_active().await {
if is_takeover {
// 接管模式:停止并恢复配置
if let Err(e) = proxy_service.stop_with_restore().await {
log::error!("退出时恢复 Live 配置失败: {e}");
} else {
log::info!("已恢复 Live 配置");
}
} else {
// 非接管模式:仅停止代理
if let Err(e) = proxy_service.stop().await {
log::error!("退出时停止代理失败: {e}");
}
}
}
log::info!("代理服务器清理完成");
}
}
}
// ============================================================
// 迁移错误对话框辅助函数
// ============================================================
+4 -2
View File
@@ -388,7 +388,8 @@ pub fn sync_single_server_to_codex(
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(())
}
@@ -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(())
}
+9
View File
@@ -151,6 +151,15 @@ pub struct ProviderMeta {
skip_serializing_if = "Option::is_none"
)]
pub partner_promotion_key: Option<String>,
/// 成本倍数(用于计算实际成本)
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
pub cost_multiplier: Option<String>,
/// 每日消费限额(USD
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
pub limit_daily_usd: Option<String>,
/// 每月消费限额(USD
#[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
pub limit_monthly_usd: Option<String>,
}
impl ProviderManager {
+506
View File
@@ -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=0allow_request 会立即切换到 HalfOpen 并占用探测名额
breaker.transition_to_open().await;
let first = breaker.allow_request().await;
assert!(first.allowed);
assert!(first.used_half_open_permit);
assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);
// 模拟并发下的“重复 HalfOpen 转换调用”,不应重置 in-flight 计数
breaker.transition_to_half_open().await;
// 由于名额仍被占用,第二次请求应被拒绝
let second = breaker.allow_request().await;
assert!(!second.allowed);
assert!(!second.used_half_open_permit);
}
#[tokio::test]
async fn test_circuit_breaker_reset() {
let config = CircuitBreakerConfig {
failure_threshold: 2,
..Default::default()
};
let breaker = CircuitBreaker::new(config);
// 打开熔断器
breaker.record_failure(false).await;
breaker.record_failure(false).await;
assert_eq!(breaker.get_state().await, CircuitState::Open);
// 重置
breaker.reset().await;
assert_eq!(breaker.get_state().await, CircuitState::Closed);
assert!(breaker.allow_request().await.allowed);
}
}
+183
View File
@@ -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
}
}
+110
View File
@@ -0,0 +1,110 @@
//! 错误类型到 HTTP 状态码的映射
//!
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录
use super::ProxyError;
/// 将 ProxyError 映射到 HTTP 状态码
///
/// 映射规则:
/// - 上游错误:直接使用上游返回的状态码
/// - 超时:504 Gateway Timeout
/// - 连接失败:502 Bad Gateway
/// - 无可用 Provider503 Service Unavailable
/// - 重试耗尽:503 Service Unavailable
/// - 其他错误:500 Internal Server Error
pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
match error {
// 上游错误:使用实际状态码
ProxyError::UpstreamError { status, .. } => *status,
// 超时错误:504 Gateway Timeout
ProxyError::Timeout(_) => 504,
// 转发失败/连接失败:502 Bad Gateway
ProxyError::ForwardFailed(_) => 502,
// 无可用 Provider503 Service Unavailable
ProxyError::NoAvailableProvider => 503,
// 重试耗尽:503 Service Unavailable
ProxyError::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"));
}
}
+134
View File
@@ -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)
}
}
+541
View File
@@ -0,0 +1,541 @@
//! 请求转发器
//!
//! 负责将请求转发到上游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 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 {
pub fn new(
router: Arc<ProviderRouter>,
timeout_secs: 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,
) -> Self {
let mut client_builder = Client::builder();
if timeout_secs > 0 {
client_builder = client_builder.timeout(Duration::from_secs(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<Response, ProxyError> {
// 获取适配器
let adapter = get_adapter(app_type);
let app_type_str = app_type.as_str();
if providers.is_empty() {
return Err(ProxyError::NoAvailableProvider);
}
log::info!(
"[{}] 故障转移链: {} 个可用供应商",
app_type_str,
providers.len()
);
let mut last_error = None;
let mut attempted_providers = 0usize;
// 依次尝试每个供应商
for provider in providers.iter() {
// 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额)
let permit = self
.router
.allow_provider_request(&provider.id, app_type_str)
.await;
if !permit.allowed {
log::debug!(
"[{}] Provider {} 熔断器拒绝本次请求,跳过",
app_type_str,
provider.name
);
continue;
}
let used_half_open_permit = permit.used_half_open_permit;
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(response);
}
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);
// 继续尝试下一个供应商
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(e);
}
}
}
}
}
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(ProxyError::NoAvailableProvider);
}
// 所有供应商都失败了
{
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(last_error.unwrap_or(ProxyError::MaxRetriesExceeded))
}
/// 转发单个请求(使用适配器)
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);
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, endpoint);
// 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider);
// 记录原始请求 JSON
log::info!(
"[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}",
adapter.name(),
serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string())
);
// 转换请求体(如果需要)
let request_body = if needs_transform {
log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name());
let transformed = adapter.transform_request(body.clone(), provider)?;
log::info!(
"[{}] >>> 转换后的请求 JSON:\n{}",
adapter.name(),
serde_json::to_string_pretty(&transformed).unwrap_or_default()
);
transformed
} else {
body.clone()
};
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 KeyProvider: {}",
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,
}
}
}
+164
View File
@@ -0,0 +1,164 @@
//! 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 流式响应模型提取(直接使用请求模型)
fn claude_model_extractor(_events: &[Value], request_model: &str) -> String {
request_model.to_string()
}
/// OpenAI Chat Completions 流式响应模型提取
fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
events
.iter()
.find_map(|e| e.get("model")?.as_str())
.unwrap_or(request_model)
.to_string()
}
/// Codex Responses API 流式响应模型提取
fn codex_model_extractor(events: &[Value], request_model: &str) -> String {
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,
};
+160
View File
@@ -0,0 +1,160 @@
//! 请求上下文模块
//!
//! 提供请求生命周期的上下文管理,封装通用初始化逻辑
use crate::app_config::AppType;
use crate::provider::Provider;
use crate::proxy::{
forwarder::RequestForwarder, server::ProxyState, types::ProxyConfig, ProxyError,
};
use std::time::Instant;
/// 请求上下文
///
/// 贯穿整个请求生命周期,包含:
/// - 计时信息
/// - 代理配置
/// - 选中的 Provider 列表(用于故障转移)
/// - 请求模型名称
/// - 日志标签
pub struct RequestContext {
/// 请求开始时间
pub start_time: Instant,
/// 代理配置快照
pub config: ProxyConfig,
/// 选中的 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();
let config = state.config.read().await.clone();
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,
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.config.request_timeout,
self.config.max_retries,
state.status.clone(),
state.current_providers.clone(),
state.failover_manager.clone(),
state.app_handle.clone(),
self.current_provider_id.clone(),
)
}
/// 获取 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
}
}
+506
View File
@@ -0,0 +1,506 @@
//! 请求处理器
//!
//! 处理各种API端点的HTTP请求
//!
//! 重构后的结构:
//! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块
//! - 各 handler 只保留独特的业务逻辑
//! - Claude 的格式转换逻辑保留在此文件(独有功能)
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 等中转服务时,需要将 Anthropic 格式转换为 OpenAI 格式
/// - 响应需要从 OpenAI 格式转回 Anthropic 格式
pub async fn handle_messages(
State(state): State<ProxyState>,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
let ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
// 检查是否需要格式转换(OpenRouter 等中转服务)
let adapter = get_adapter(&AppType::Claude);
let needs_transform = adapter.needs_transform(&ctx.provider);
let is_stream = body
.get("stream")
.and_then(|s| s.as_bool())
.unwrap_or(false);
log::info!(
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
ctx.provider.name,
needs_transform,
is_stream
);
// 转发请求
let forwarder = ctx.create_forwarder(&state);
let response = match forwarder
.forward_with_retry(
&AppType::Claude,
"/v1/messages",
body.clone(),
headers,
ctx.get_providers(),
)
.await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
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 等需要格式转换的中转服务
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 logged_stream = create_logged_passthrough_stream(
sse_stream,
"Claude/OpenRouter",
Some(usage_collector),
);
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 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 response = match forwarder
.forward_with_retry(
&AppType::Codex,
"/v1/chat/completions",
body,
headers,
ctx.get_providers(),
)
.await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
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 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 response = match forwarder
.forward_with_retry(
&AppType::Codex,
"/v1/responses",
body,
headers,
ctx.get_providers(),
)
.await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
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 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 response = match forwarder
.forward_with_retry(
&AppType::Gemini,
endpoint,
body,
headers,
ctx.get_providers(),
)
.await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
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}");
}
}
+7
View File
@@ -0,0 +1,7 @@
//! 健康检查器
//!
//! 负责定期检查Provider健康状态(占位实现)
// 占位实现,稍后添加完整逻辑
#[allow(dead_code)]
pub struct HealthChecker;
+42
View File
@@ -0,0 +1,42 @@
//! 代理服务器模块
//!
//! 提供本地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 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::*;
+311
View File
@@ -0,0 +1,311 @@
//! 供应商路由器模块
//!
//! 负责选择和管理代理目标供应商,实现智能故障转移
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())),
}
}
/// 选择可用的供应商(支持故障转移)
///
/// 返回按优先级排序的可用供应商列表:
/// 1. 当前供应商(is_current=true)始终第一位
/// 2. 故障转移队列中的其他供应商(按 queue_order 排序)
/// 3. 只返回熔断器未打开的供应商
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
let mut result = Vec::new();
let all_providers = self.db.get_all_providers(app_type)?;
// 1. 当前供应商始终第一位
if let Some(current_id) = self.db.get_current_provider(app_type)? {
if let Some(current) = all_providers.get(&current_id) {
let circuit_key = format!("{}:{}", app_type, current.id);
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
if breaker.is_available().await {
log::info!(
"[{}] Current provider available: {} ({})",
app_type,
current.name,
current.id
);
result.push(current.clone());
} else {
log::warn!(
"[{}] Current provider {} circuit breaker open, checking failover queue",
app_type,
current.name
);
}
}
}
// 2. 获取故障转移队列中的供应商
let queue = self.db.get_failover_queue(app_type)?;
for item in queue {
// 跳过已添加的当前供应商
if result.iter().any(|p| p.id == item.provider_id) {
continue;
}
// 跳过禁用的队列项
if !item.enabled {
continue;
}
// 获取供应商信息
if let Some(provider) = all_providers.get(&item.provider_id) {
// 检查熔断器状态
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!(
"[{}] Failover provider available: {} ({}) at queue position {}",
app_type,
provider.name,
provider.id,
item.queue_order
);
result.push(provider.clone());
} else {
log::debug!(
"[{}] Failover provider {} circuit breaker open, skipping",
app_type,
provider.name
);
}
}
}
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!(
"[{}] Failover 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 config = self.db.get_circuit_breaker_config().await.ok();
let failure_threshold = config.map(|c| c.failure_threshold).unwrap_or(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();
}
// 从数据库加载配置
let config = self
.db
.get_circuit_breaker_config()
.await
.unwrap_or_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 select_providers_does_not_consume_half_open_permit() {
let db = Arc::new(Database::memory().unwrap());
// 配置:让熔断器 Open 后立刻进入 HalfOpentimeout_seconds=0),并用 1 次失败就打开熔断器
db.update_circuit_breaker_config(&CircuitBreakerConfig {
failure_threshold: 1,
timeout_seconds: 0,
..Default::default()
})
.await
.unwrap();
// 准备 2 个 ProviderA(当前)+ B(队列)
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());
// 让 B 进入 Open 状态(failure_threshold=1
router
.record_result("b", "claude", false, false, Some("fail".to_string()))
.await
.unwrap();
// select_providers 只做“可用性判断”,不应占用 HalfOpen 探测名额
let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 2);
// 如果 select_providers 错误地消耗了 HalfOpen 名额,这里会返回 false(被限流拒绝)
assert!(router.allow_provider_request("b", "claude").await.allowed);
}
}
+131
View File
@@ -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)才返回 `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)
}
}
+210
View File
@@ -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);
}
}
}
}
}
+402
View File
@@ -0,0 +1,402 @@
//! Claude (Anthropic) Provider Adapter
//!
//! 支持透传模式和 OpenRouter 兼容模式
//!
//! ## 认证模式
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
//! - **OpenRouter**: 需要 Anthropic ↔ OpenAI 格式转换
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 let Ok(base_url) = self.extract_base_url(provider) {
if base_url.contains("openrouter.ai") {
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
}
/// 检测是否为仅 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());
}
// 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 {
// OpenRouter 使用 /v1/chat/completions
if base_url.contains("openrouter.ai") {
return format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
}
// Anthropic 直连
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))
}
// OpenRouter: Bearer
AuthStrategy::Bearer => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
_ => request,
}
}
fn needs_transform(&self, provider: &Provider) -> bool {
self.is_openrouter(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,
}
}
#[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_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/chat/completions");
}
#[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));
}
}
+264
View File
@@ -0,0 +1,264 @@
//! 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,
}
}
#[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 已包含 /v1endpoint 也包含 /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"
));
}
}
+421
View File
@@ -0,0 +1,421 @@
//! 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,
}
}
#[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 已包含 /v1betaendpoint 也包含 /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());
}
}
+423
View File
@@ -0,0 +1,423 @@
//! 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 (需要 Anthropic ↔ OpenAI 格式转换)
OpenRouter,
}
impl ProviderType {
/// 是否需要格式转换
///
/// OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式
#[allow(dead_code)]
pub fn needs_transform(&self) -> bool {
matches!(self, ProviderType::OpenRouter)
}
/// 获取默认端点
#[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,
}
}
#[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,
}
+338
View File
@@ -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;
}
// 处理 reasoningthinking
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()
})
}
+639
View File
@@ -0,0 +1,639 @@
//! 格式转换模块
//!
//! 实现 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,
}
}
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");
}
}
+214
View File
@@ -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));
}
}
+411
View File
@@ -0,0 +1,411 @@
//! 响应处理器模块
//!
//! 统一处理流式和非流式 API 响应
use super::{
handler_config::UsageParserConfig, handler_context::RequestContext, 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,
},
};
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 logged_stream = create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector));
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) {
let model = json_value
.get("model")
.and_then(|m| m.as_str())
.unwrap_or(&ctx.request_model);
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>,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! {
let mut buffer = String::new();
let mut collector = usage_collector;
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);
// 尝试解析并记录完整的 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);
}
Err(e) => {
log::error!("[{tag}] 流错误: {e}");
yield Err(std::io::Error::other(e.to_string()));
break;
}
}
}
log::info!("[{}] ====== 流结束 ======", tag);
if let Some(c) = collector.take() {
c.finish().await;
}
}
}
+241
View File
@@ -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;
}
}
+298
View File
@@ -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");
}
}
+128
View File
@@ -0,0 +1,128 @@
use serde::{Deserialize, Serialize};
/// 代理服务器配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyConfig {
/// 是否启用代理服务
pub enabled: bool,
/// 监听地址
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,
}
impl Default for ProxyConfig {
fn default() -> Self {
Self {
enabled: false,
listen_address: "127.0.0.1".to_string(),
listen_port: 15721, // 使用较少占用的高位端口
max_retries: 3,
request_timeout: 300,
enable_logging: true,
live_takeover_active: false,
}
}
}
/// 代理服务器状态
#[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,
}
+186
View File
@@ -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); // 确保保留了小数位
}
}
+325
View File
@@ -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(())
}
}
+15
View File
@@ -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};
+534
View File
@@ -0,0 +1,534 @@
//! 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")?;
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: None,
})
}
/// 从 Claude API 流式响应解析
#[allow(dead_code)]
pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> {
let mut usage = Self::default();
for event in events {
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
match event_type {
"message_start" => {
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 {
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_tokensusage: {usage:?}");
return None;
}
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: None,
})
}
/// 从 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;
Some(Self {
input_tokens: prompt_tokens as u32,
output_tokens: completion_tokens as u32,
cache_read_tokens: cached_tokens,
cache_creation_tokens: 0,
model: None,
})
}
/// 从 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!({
"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);
}
#[test]
fn test_claude_stream_parsing() {
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);
}
#[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_tokensinput_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": {
"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);
}
#[test]
fn test_native_claude_stream_parsing() {
// 测试原生 Claude API 流式响应解析
// 原生 Claude API 的 input_tokens 在 message_start 中
let events = vec![
json!({
"type": "message_start",
"message": {
"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);
}
}
+64 -9
View File
@@ -17,8 +17,27 @@ impl McpService {
/// 添加或更新 MCP 服务器
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)?;
// 处理禁用:若旧版本启用但新版本取消,则需要从该应用的 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)?;
@@ -190,10 +209,22 @@ impl McpService {
// 如果有导入的服务器,保存到数据库
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() {
state.db.save_mcp_server(server)?;
// 同步到 Claude live 配置
Self::sync_server_to_apps(state, server)?;
// 已存在:仅启用 Claude,不覆盖其他字段(与导入模块语义保持一致)
let to_save = if let Some(existing_server) = existing.get(&server.id) {
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 let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() {
state.db.save_mcp_server(server)?;
// 同步到 Codex live 配置
Self::sync_server_to_apps(state, server)?;
// 已存在:仅启用 Codex,不覆盖其他字段(与导入模块语义保持一致)
let to_save = if let Some(existing_server) = existing.get(&server.id) {
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 let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() {
state.db.save_mcp_server(server)?;
// 同步到 Gemini live 配置
Self::sync_server_to_apps(state, server)?;
// 已存在:仅启用 Gemini,不覆盖其他字段(与导入模块语义保持一致)
let to_save = if let Some(existing_server) = existing.get(&server.id) {
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)?;
}
}
}
+9
View File
@@ -4,12 +4,21 @@ pub mod env_manager;
pub mod mcp;
pub mod prompt;
pub mod provider;
pub mod proxy;
pub mod skill;
pub mod speedtest;
pub mod stream_check;
pub mod usage_stats;
pub use config::ConfigService;
pub use mcp::McpService;
pub use prompt::PromptService;
pub use provider::{ProviderService, ProviderSortUpdate};
pub use proxy::ProxyService;
pub use skill::{Skill, SkillRepo, SkillService};
pub use speedtest::{EndpointLatency, SpeedtestService};
#[allow(unused_imports)]
pub use usage_stats::{
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
RequestLogDetail, UsageSummary,
};
+91 -8
View File
@@ -144,9 +144,29 @@ impl ProviderService {
state.db.save_provider(app_type.as_str(), &provider)?;
if is_current {
write_live_snapshot(&app_type, &provider)?;
// Sync MCP
McpService::sync_all_enabled(state)?;
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
// - 不写 Live 配置(否则会破坏接管)
// - 仅更新 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)
@@ -173,14 +193,77 @@ impl ProviderService {
///
/// Switch flow:
/// 1. Validate target provider exists
/// 2. **Backfill mechanism**: Backfill current live config to current provider, protect user manual modifications
/// 3. Update local settings current_provider_xxx (device-level)
/// 4. Update database is_current (as default for new devices)
/// 5. Write target provider config to live files
/// 6. Sync MCP configuration
/// 2. Check if proxy takeover mode is active AND proxy server is running
/// 3. If takeover mode active: hot-switch proxy target only (no Live config write)
/// 4. If normal mode:
/// a. **Backfill mechanism**: Backfill current live config to current provider
/// 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> {
// Check if provider exists
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
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
+45 -3
View File
@@ -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)
pub async fn query_usage(
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.timeout.unwrap_or(10),
usage_script.api_key.clone().unwrap_or_default(),
usage_script.base_url.clone().unwrap_or_default(),
api_key,
base_url,
usage_script.access_token.clone(),
usage_script.user_id.clone(),
)
File diff suppressed because it is too large Load Diff
+31 -3
View File
@@ -7,6 +7,7 @@ use std::fs;
use std::path::{Path, PathBuf};
use tokio::time::timeout;
use crate::app_config::AppType;
use crate::error::format_skill_error;
/// 技能对象
@@ -106,11 +107,16 @@ pub struct SkillMetadata {
pub struct SkillService {
http_client: Client,
install_dir: PathBuf,
app_type: AppType,
}
impl SkillService {
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)?;
@@ -122,16 +128,38 @@ impl SkillService {
.timeout(std::time::Duration::from_secs(10))
.build()?,
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(
"GET_HOME_DIR_FAILED",
&[],
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
}
}
+43 -26
View File
@@ -31,40 +31,51 @@ impl SpeedtestService {
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 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();
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 start = Instant::now();
match client.get(parsed_url).send().await {
let latency = match client.get(parsed_url).send().await {
Ok(resp) => EndpointLatency {
url: trimmed,
latency: Some(start.elapsed().as_millis()),
@@ -88,11 +99,17 @@ impl SpeedtestService {
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> {
+436
View File
@@ -0,0 +1,436 @@
//! 流式健康检查服务
//!
//! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。
use futures::StreamExt;
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 result = match app_type {
AppType::Claude => {
Self::check_claude_stream(&client, &base_url, &auth, &config.claude_model).await
}
AppType::Codex => {
Self::check_codex_stream(&client, &base_url, &auth, &config.codex_model).await
}
AppType::Gemini => {
Self::check_gemini_stream(&client, &base_url, &auth, &config.gemini_model).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())
}
}
}
#[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
-8
View File
@@ -31,9 +31,6 @@ pub struct AppSettings {
/// 是否启用 Claude 插件联动
#[serde(default)]
pub enable_claude_plugin_integration: bool,
/// 是否跳过 Claude Code 初次安装确认
#[serde(default = "default_true")]
pub skip_claude_onboarding: bool,
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
@@ -68,17 +65,12 @@ fn default_minimize_to_tray_on_close() -> bool {
true
}
fn default_true() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
show_in_tray: true,
minimize_to_tray_on_close: true,
enable_claude_plugin_integration: false,
skip_claude_onboarding: true,
launch_on_startup: false,
language: None,
claude_config_dir: None,
+5 -1
View File
@@ -1,14 +1,18 @@
use crate::database::Database;
use crate::services::ProxyService;
use std::sync::Arc;
/// 全局应用状态
pub struct AppState {
pub db: Arc<Database>,
pub proxy_service: ProxyService,
}
impl AppState {
/// 创建新的应用状态
pub fn new(db: Arc<Database>) -> Self {
Self { db }
let proxy_service = ProxyService::new(db.clone());
Self { db, proxy_service }
}
}
+492 -18
View File
@@ -3,6 +3,7 @@ use rquickjs::{Context, Function, Runtime};
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
use url::{Host, Url};
use crate::error::AppError;
@@ -15,20 +16,14 @@ pub async fn execute_usage_script(
access_token: Option<&str>,
user_id: Option<&str>,
) -> Result<Value, AppError> {
// 1. 替换变量
let mut replaced = script_code
.replace("{{apiKey}}", api_key)
.replace("{{baseUrl}}", base_url);
// 1. 替换模板变量,避免泄露敏感信息
let script_with_vars =
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
// 替换 accessToken 和 userId
if let Some(token) = access_token {
replaced = replaced.replace("{{accessToken}}", token);
}
if let Some(uid) = user_id {
replaced = replaced.replace("{{userId}}", uid);
}
// 2. 验证 base_url 的安全性
validate_base_url(base_url)?;
// 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
let request_config = {
let runtime = Runtime::new().map_err(|e| {
AppError::localized(
@@ -47,7 +42,7 @@ pub async fn execute_usage_script(
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(
"usage_script.config_parse_failed",
format!("解析配置失败: {e}"),
@@ -94,7 +89,7 @@ pub async fn execute_usage_script(
})?
}; // Runtime 和 Context 在这里被 drop
// 3. 解析 request 配置
// 4. 解析 request 配置
let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {
AppError::localized(
"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?;
// 5. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放)
// 7. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放)
let result: Value = {
let runtime = Runtime::new().map_err(|e| {
AppError::localized(
@@ -125,7 +123,7 @@ pub async fn execute_usage_script(
context.with(|ctx| {
// 重新 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(
"usage_script.config_reparse_failed",
format!("重新解析配置失败: {e}"),
@@ -198,7 +196,7 @@ pub async fn execute_usage_script(
})?
}; // Runtime 和 Context 在这里被 drop
// 6. 验证返回值格式
// 8. 验证返回值格式
validate_result(&result)?;
Ok(result)
@@ -394,3 +392,479 @@ fn validate_single_usage(result: &Value) -> Result<(), AppError> {
Ok(())
}
/// 构建替换变量后的脚本,保持与旧版脚本的兼容性
fn build_script_with_vars(
script_code: &str,
api_key: &str,
base_url: &str,
access_token: Option<&str>,
user_id: Option<&str>,
) -> String {
let mut replaced = script_code
.replace("{{apiKey}}", api_key)
.replace("{{baseUrl}}", base_url);
if let Some(token) = access_token {
replaced = replaced.replace("{{accessToken}}", token);
}
if let Some(uid) = user_id {
replaced = replaced.replace("{{userId}}", uid);
}
replaced
}
/// 验证 base_url 的基本安全性
fn validate_base_url(base_url: &str) -> Result<(), AppError> {
if base_url.is_empty() {
return Err(AppError::localized(
"usage_script.base_url_empty",
"base_url 不能为空",
"base_url cannot be empty",
));
}
// 解析 URL
let parsed_url = Url::parse(base_url).map_err(|e| {
AppError::localized(
"usage_script.base_url_invalid",
format!("无效的 base_url: {e}"),
format!("Invalid base_url: {e}"),
)
})?;
let is_loopback = is_loopback_host(&parsed_url);
// 必须是 HTTPS(允许 localhost 用于开发)
if parsed_url.scheme() != "https" && !is_loopback {
return Err(AppError::localized(
"usage_script.base_url_https_required",
"base_url 必须使用 HTTPS 协议(localhost 除外)",
"base_url must use HTTPS (localhost allowed)",
));
}
// 检查主机名格式有效性
let hostname = parsed_url.host_str().ok_or_else(|| {
AppError::localized(
"usage_script.base_url_hostname_missing",
"base_url 必须包含有效的主机名",
"base_url must include a valid hostname",
)
})?;
// 基本的主机名格式检查
if hostname.is_empty() {
return Err(AppError::localized(
"usage_script.base_url_hostname_empty",
"base_url 主机名不能为空",
"base_url hostname cannot be empty",
));
}
// 检查是否为明显的私有IP(但在 base_url 阶段不过于严格,主要在 request_url 阶段检查)
if is_suspicious_hostname(hostname) {
return Err(AppError::localized(
"usage_script.base_url_suspicious",
"base_url 包含可疑的主机名",
"base_url contains a suspicious hostname",
));
}
Ok(())
}
/// 验证请求 URL 是否安全(防止 SSRF)
fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppError> {
// 解析请求 URL
let parsed_request = Url::parse(request_url).map_err(|e| {
AppError::localized(
"usage_script.request_url_invalid",
format!("无效的请求 URL: {e}"),
format!("Invalid request URL: {e}"),
)
})?;
// 解析 base URL
let parsed_base = Url::parse(base_url).map_err(|e| {
AppError::localized(
"usage_script.base_url_invalid",
format!("无效的 base_url: {e}"),
format!("Invalid base_url: {e}"),
)
})?;
let is_request_loopback = is_loopback_host(&parsed_request);
// 必须使用 HTTPS(允许 localhost 用于开发)
if parsed_request.scheme() != "https" && !is_request_loopback {
return Err(AppError::localized(
"usage_script.request_https_required",
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
"Request URL must use HTTPS (localhost allowed)",
));
}
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
if parsed_request.host_str() != parsed_base.host_str() {
return Err(AppError::localized(
"usage_script.request_host_mismatch",
format!(
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
format!(
"Request host {} must match base_url host {} (same-origin required)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
));
}
// 检查端口是否匹配(考虑默认端口)
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443
match (
parsed_request.port_or_known_default(),
parsed_base.port_or_known_default(),
) {
(Some(request_port), Some(base_port)) if request_port == base_port => {
// 端口匹配,继续执行
}
(Some(request_port), Some(base_port)) => {
return Err(AppError::localized(
"usage_script.request_port_mismatch",
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
format!("Request port {request_port} must match base_url port {base_port}"),
));
}
_ => {
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
return Err(AppError::localized(
"usage_script.request_port_unknown",
"无法确定端口号",
"Unable to determine port number",
));
}
}
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
if let Some(host) = parsed_request.host_str() {
let base_host = parsed_base.host_str().unwrap_or("");
// 如果 base_url 不是私有地址,则禁止访问私有IP
if !is_private_ip(base_host) && is_private_ip(host) {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址",
"Access to private IP addresses is blocked",
));
}
}
Ok(())
}
/// 检查是否为私有 IP 地址
fn is_private_ip(host: &str) -> bool {
// localhost 检查
if host.eq_ignore_ascii_case("localhost") {
return true;
}
// 尝试解析为IP地址
if let Ok(ip_addr) = host.parse::<std::net::IpAddr>() {
return is_private_ip_addr(ip_addr);
}
// 如果不是IP地址,不是私有IP
false
}
/// 使用标准库API检查IP地址是否为私有地址
fn is_private_ip_addr(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(ipv4) => {
let octets = ipv4.octets();
// 0.0.0.0/8 (包括未指定地址)
if octets[0] == 0 {
return true;
}
// RFC1918 私有地址范围
// 10.0.0.0/8
if octets[0] == 10 {
return true;
}
// 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
if octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31 {
return true;
}
// 192.168.0.0/16
if octets[0] == 192 && octets[1] == 168 {
return true;
}
// 其他特殊地址
// 169.254.0.0/16 (链路本地地址)
if octets[0] == 169 && octets[1] == 254 {
return true;
}
// 127.0.0.0/8 (环回地址)
if octets[0] == 127 {
return true;
}
false
}
std::net::IpAddr::V6(ipv6) => {
// IPv6 私有地址检查 - 使用标准库方法
// ::1 (环回地址)
if ipv6.is_loopback() {
return true;
}
// 唯一本地地址 (fc00::/7)
// Rust 1.70+ 可以使用 ipv6.is_unique_local()
// 但为了兼容性,我们手动检查
let first_segment = ipv6.segments()[0];
if (first_segment & 0xfe00) == 0xfc00 {
return true;
}
// 链路本地地址 (fe80::/10)
if (first_segment & 0xffc0) == 0xfe80 {
return true;
}
// 未指定地址 ::
if ipv6.is_unspecified() {
return true;
}
false
}
}
}
/// 检查是否为可疑的主机名(只检查明显不安全的模式)
fn is_suspicious_hostname(hostname: &str) -> bool {
// 空主机名
if hostname.is_empty() {
return true;
}
// 检查明显的主机名格式问题
if hostname.contains("..") || hostname.starts_with(".") || hostname.ends_with(".") {
return true;
}
// 检查是否为纯IP地址但没有合理格式(过于宽松的检查在这里可能不够,但主要依赖后续的同源检查)
if hostname.parse::<std::net::IpAddr>().is_ok() {
// IP地址格式的,在这里不直接拒绝,让同源检查来处理
return false;
}
// 检查是否包含明显不当的字符
let suspicious_chars = ['<', '>', '"', '\'', '\n', '\r', '\t', '\0'];
if hostname.chars().any(|c| suspicious_chars.contains(&c)) {
return true;
}
false
}
/// 判断 URL 是否指向本机(localhost / loopback
fn is_loopback_host(url: &Url) -> bool {
match url.host() {
Some(Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
Some(Host::Ipv4(ip)) => ip.is_loopback(),
Some(Host::Ipv6(ip)) => ip.is_loopback(),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_private_ip_validation() {
// 测试IPv4私网地址
// RFC1918私网地址 - 应该返回true
assert!(is_private_ip("10.0.0.1"));
assert!(is_private_ip("10.255.255.254"));
assert!(is_private_ip("172.16.0.1"));
assert!(is_private_ip("172.31.255.255"));
assert!(is_private_ip("192.168.0.1"));
assert!(is_private_ip("192.168.255.255"));
// 链路本地地址 - 应该返回true
assert!(is_private_ip("169.254.0.1"));
assert!(is_private_ip("169.254.255.255"));
// 环回地址 - 应该返回true
assert!(is_private_ip("127.0.0.1"));
assert!(is_private_ip("localhost"));
// 公网172.x.x.x地址 - 应该返回false(这是修复的重点)
assert!(!is_private_ip("172.0.0.1"));
assert!(!is_private_ip("172.15.255.255"));
assert!(!is_private_ip("172.32.0.1"));
assert!(!is_private_ip("172.64.0.1"));
assert!(!is_private_ip("172.67.0.1")); // Cloudflare CDN
assert!(!is_private_ip("172.68.0.1"));
assert!(!is_private_ip("172.100.50.25"));
assert!(!is_private_ip("172.255.255.255"));
// 其他公网地址 - 应该返回false
assert!(!is_private_ip("8.8.8.8")); // Google DNS
assert!(!is_private_ip("1.1.1.1")); // Cloudflare DNS
assert!(!is_private_ip("208.67.222.222")); // OpenDNS
assert!(!is_private_ip("180.76.76.76")); // Baidu DNS
// 域名 - 应该返回false
assert!(!is_private_ip("api.example.com"));
assert!(!is_private_ip("www.google.com"));
}
#[test]
fn test_ipv6_private_validation() {
// IPv6私网地址
assert!(is_private_ip("::1")); // 环回地址
assert!(is_private_ip("fc00::1")); // 唯一本地地址
assert!(is_private_ip("fd00::1")); // 唯一本地地址
assert!(is_private_ip("fe80::1")); // 链路本地地址
assert!(is_private_ip("::")); // 未指定地址
// IPv6公网地址 - 应该返回false(修复的重点)
assert!(!is_private_ip("2001:4860:4860::8888")); // Google DNS IPv6
assert!(!is_private_ip("2606:4700:4700::1111")); // Cloudflare DNS IPv6
assert!(!is_private_ip("2404:6800:4001:c01::67")); // Google DNS IPv6 (其他格式)
assert!(!is_private_ip("2001:db8::1")); // 文档地址(非私网)
// 测试包含 ::1 子串但不是环回地址的公网地址
assert!(!is_private_ip("2001:db8::1abc")); // 包含 ::1abc 但不是环回
assert!(!is_private_ip("2606:4700::1")); // 包含 ::1 但不是环回
}
#[test]
fn test_hostname_bypass_prevention() {
// 看起来像本地,但实际是域名
assert!(!is_private_ip("127.0.0.1.evil.com"));
assert!(!is_private_ip("localhost.evil.com"));
// 0.0.0.0 应该被视为本地/阻断
assert!(is_private_ip("0.0.0.0"));
}
#[test]
fn test_https_bypass_prevention() {
// 非本地域名的 HTTP 应该被拒绝
let result = validate_base_url("http://127.0.0.1.evil.com/api");
assert!(
result.is_err(),
"Should reject HTTP for non-localhost domains"
);
}
#[test]
fn test_edge_cases() {
// 边界情况测试
assert!(is_private_ip("172.16.0.0")); // RFC1918起始
assert!(is_private_ip("172.31.255.255")); // RFC1918结束
assert!(is_private_ip("10.0.0.0")); // 10.0.0.0/8起始
assert!(is_private_ip("10.255.255.255")); // 10.0.0.0/8结束
assert!(is_private_ip("192.168.0.0")); // 192.168.0.0/16起始
assert!(is_private_ip("192.168.255.255")); // 192.168.0.0/16结束
// 紧邻RFC1918的公网地址 - 应该返回false
assert!(!is_private_ip("172.15.255.255")); // 172.16.0.0的前一个
assert!(!is_private_ip("172.32.0.0")); // 172.31.255.255的后一个
}
#[test]
fn test_ip_addr_parsing() {
// 测试IP地址解析功能
let ipv4_private = "10.0.0.1".parse::<std::net::IpAddr>().unwrap();
assert!(is_private_ip_addr(ipv4_private));
let ipv4_public = "172.67.0.1".parse::<std::net::IpAddr>().unwrap();
assert!(!is_private_ip_addr(ipv4_public));
let ipv6_private = "fc00::1".parse::<std::net::IpAddr>().unwrap();
assert!(is_private_ip_addr(ipv6_private));
let ipv6_public = "2001:4860:4860::8888".parse::<std::net::IpAddr>().unwrap();
assert!(!is_private_ip_addr(ipv6_public));
}
#[test]
fn test_port_comparison() {
// 测试端口比较逻辑是否正确处理默认端口和显式端口
// 测试用例:(base_url, request_url, should_match)
let test_cases = vec![
// HTTPS默认端口测试
(
"https://api.example.com",
"https://api.example.com/v1/test",
true,
),
(
"https://api.example.com",
"https://api.example.com:443/v1/test",
true,
),
(
"https://api.example.com:443",
"https://api.example.com/v1/test",
true,
),
(
"https://api.example.com:443",
"https://api.example.com:443/v1/test",
true,
),
// 端口不匹配测试
(
"https://api.example.com",
"https://api.example.com:8443/v1/test",
false,
),
(
"https://api.example.com:443",
"https://api.example.com:8443/v1/test",
false,
),
];
for (base_url, request_url, should_match) in test_cases {
let result = validate_request_url(request_url, base_url);
if should_match {
assert!(
result.is_ok(),
"应该匹配的URL被拒绝: base_url={}, request_url={}, error={}",
base_url,
request_url,
result.unwrap_err()
);
} else {
assert!(
result.is_err(),
"应该不匹配的URL被允许: base_url={}, request_url={}",
base_url,
request_url
);
}
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.8.3",
"version": "3.9.0-1",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://schema.tauri.app/config/2",
"app": {
"windows": [
{
"label": "main",
"titleBarStyle": "Visible"
}
]
}
}
+13 -5
View File
@@ -1,6 +1,8 @@
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"]
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 db = Arc::new(Database::memory().expect("create memory db"));
let state = AppState { db: db.clone() };
let proxy_service = ProxyService::new(db.clone());
let state = AppState {
db: db.clone(),
proxy_service,
};
let provider_id = import_provider_from_deeplink(&state, request.clone())
.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 db = Arc::new(Database::memory().expect("create memory db"));
let state = AppState { db: db.clone() };
let proxy_service = ProxyService::new(db.clone());
let state = AppState {
db: db.clone(),
proxy_service,
};
let provider_id = import_provider_from_deeplink(&state, request.clone())
.expect("import provider from deeplink");
-73
View File
@@ -1003,76 +1003,3 @@ fn export_sql_returns_error_for_invalid_path() {
other => panic!("expected IoContext or Io error, got {other:?}"),
}
}
#[test]
fn import_sql_rejects_non_cc_switch_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let state = create_test_state().expect("create test state");
let import_path = home.join("not-cc-switch.sql");
fs::write(&import_path, "CREATE TABLE x (id INTEGER);").expect("write import sql");
let err = state
.db
.import_sql(&import_path)
.expect_err("non-cc-switch sql should be rejected");
match err {
AppError::Localized { key, .. } => {
assert_eq!(key, "backup.sql.invalid_format");
}
other => panic!("expected Localized error, got {other:?}"),
}
}
#[test]
fn import_sql_accepts_cc_switch_exported_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// Create a database with some data and export it.
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "test-provider".to_string();
manager.providers.insert(
"test-provider".to_string(),
Provider::with_id(
"test-provider".to_string(),
"Test Provider".to_string(),
json!({"env": {"ANTHROPIC_API_KEY": "test-key"}}),
None,
),
);
}
let state = create_test_state_with_config(&config).expect("create test state");
let export_path = home.join("cc-switch-export.sql");
state
.db
.export_sql(&export_path)
.expect("export should succeed");
// Reset database, then import into a fresh one.
reset_test_fs();
let state = create_test_state().expect("create test state");
state
.db
.import_sql(&export_path)
.expect("import should succeed");
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("load providers");
assert!(
providers.contains_key("test-provider"),
"imported providers should contain test-provider"
);
}
+9 -5
View File
@@ -1,7 +1,9 @@
use std::path::{Path, PathBuf};
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 目录,避免污染真实用户数据。
pub fn ensure_test_home() -> &'static Path {
@@ -48,15 +50,17 @@ pub fn test_mutex() -> &'static Mutex<()> {
/// 创建测试用的 AppState,包含一个空的数据库
pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
let db = Database::init()?;
Ok(AppState { db: Arc::new(db) })
let db = Arc::new(Database::init()?);
let proxy_service = ProxyService::new(db.clone());
Ok(AppState { db, proxy_service })
}
/// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据
pub fn create_test_state_with_config(
config: &MultiAppConfig,
) -> Result<AppState, Box<dyn std::error::Error>> {
let db = Database::init()?;
let db = Arc::new(Database::init()?);
db.migrate_from_json(config)?;
Ok(AppState { db: Arc::new(db) })
let proxy_service = ProxyService::new(db.clone());
Ok(AppState { db, proxy_service })
}
+47 -35
View File
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { invoke } from "@tauri-apps/api/core";
import { useQueryClient } from "@tanstack/react-query";
import {
Plus,
Settings,
@@ -24,6 +23,7 @@ import {
} from "@/lib/api";
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { extractErrorMessage } from "@/utils/errorUtils";
import { cn } from "@/lib/utils";
import { AppSwitcher } from "@/components/AppSwitcher";
@@ -34,6 +34,7 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
import { SettingsPage } from "@/components/settings/SettingsPage";
import { UpdateBadge } from "@/components/UpdateBadge";
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
import { ProxyToggle } from "@/components/proxy/ProxyToggle";
import UsageScriptModal from "@/components/UsageScriptModal";
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
import PromptPanel from "@/components/prompts/PromptPanel";
@@ -44,14 +45,8 @@ import { Button } from "@/components/ui/button";
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
// 顶部拖拽区域和 header 的高度常量
const DRAG_BAR_HEIGHT = 28; // px
const HEADER_HEIGHT = 64; // px
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
function App() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeApp, setActiveApp] = useState<AppId>("claude");
const [currentView, setCurrentView] = useState<View>("providers");
@@ -69,10 +64,25 @@ function App() {
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";
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
// 获取代理服务状态
const { isRunning: isProxyRunning, takeoverStatus } = useProxyStatus();
// 当前应用的代理是否开启
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
// 任意应用的代理是否开启
const isTakeoverActive =
takeoverStatus?.claude ||
takeoverStatus?.codex ||
takeoverStatus?.gemini ||
false;
// 获取供应商列表,当代理服务运行时自动刷新
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
isProxyRunning,
});
const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? "";
const isClaudeApp = activeApp === "claude";
// Skills 功能仅支持 Claude 和 Codex
const hasSkillsSupport = activeApp === "claude" || activeApp === "codex";
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
const {
@@ -140,6 +150,7 @@ function App() {
if (migrated) {
toast.success(
t("migration.success", { defaultValue: "配置迁移成功" }),
{ closeButton: true },
);
}
} catch (error) {
@@ -265,20 +276,7 @@ function App() {
// 导入配置成功后刷新
const handleImportSuccess = async () => {
try {
// 导入会影响所有应用的供应商数据:刷新所有 providers 缓存
await queryClient.invalidateQueries({
queryKey: ["providers"],
refetchType: "all",
});
await queryClient.refetchQueries({
queryKey: ["providers"],
type: "all",
});
} catch (error) {
console.error("[App] Failed to refresh providers after import", error);
await refetch();
}
await refetch();
try {
await providersApi.updateTrayMenu();
} catch (error) {
@@ -310,6 +308,7 @@ function App() {
<SkillsPage
ref={skillsPageRef}
onClose={() => setCurrentView("providers")}
initialApp={activeApp}
/>
);
case "mcp":
@@ -332,6 +331,8 @@ function App() {
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
@@ -350,7 +351,7 @@ function App() {
return (
<div
className="flex min-h-screen flex-col bg-background text-foreground selection:bg-primary/30"
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
style={{ overflowX: "hidden" }}
>
{/* 全局拖拽区域(顶部 4px),避免上边框无法拖动 */}
<div
@@ -386,7 +387,7 @@ function App() {
)}
<header
className="glass-header fixed top-0 z-50 w-full py-3 transition-all duration-300"
className="fixed top-0 z-50 w-full py-3 bg-background/80 backdrop-blur-md transition-all duration-300"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any}
>
@@ -426,7 +427,12 @@ function App() {
href="https://github.com/farion1231/cc-switch"
target="_blank"
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
</a>
@@ -446,14 +452,14 @@ function App() {
</div>
<div
className="flex items-center gap-2 h-[32px]"
className="flex items-center gap-2"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{currentView === "prompts" && (
<Button
size="icon"
onClick={() => promptPanelRef.current?.openAdd()}
className={`ml-auto ${addActionButtonClass}`}
className={addActionButtonClass}
title={t("prompts.add")}
>
<Plus className="h-5 w-5" />
@@ -463,7 +469,7 @@ function App() {
<Button
size="icon"
onClick={() => mcpPanelRef.current?.openAdd()}
className={`ml-auto ${addActionButtonClass}`}
className={addActionButtonClass}
title={t("mcp.unifiedPanel.addServer")}
>
<Plus className="h-5 w-5" />
@@ -493,9 +499,11 @@ function App() {
)}
{currentView === "providers" && (
<>
<ProxyToggle activeApp={activeApp} />
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="glass p-1 rounded-xl flex items-center gap-1">
<div className="bg-muted p-1 rounded-xl flex items-center gap-1">
<Button
variant="ghost"
size="sm"
@@ -503,7 +511,7 @@ function App() {
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
isClaudeApp
hasSkillsSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
@@ -556,10 +564,13 @@ function App() {
</div>
</header>
<main className="flex-1 pb-12 animate-fade-in ">
<div className="pb-12">
{renderContent()}
</div>
<main
className={`flex-1 overflow-y-auto pb-12 animate-fade-in scroll-overlay ${
currentView === "providers" ? "pt-24" : "pt-20"
}`}
style={{ overflowX: "hidden" }}
>
{renderContent()}
</main>
<AddProviderDialog
@@ -579,6 +590,7 @@ function App() {
}}
onSubmit={handleEditProvider}
appId={activeApp}
isProxyTakeover={isProxyRunning && isTakeoverActive}
/>
{usageProvider && (
+12 -12
View File
@@ -24,14 +24,14 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
};
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-lg p-1 gap-1">
<button
type="button"
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 ${
activeApp === "claude"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
: "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"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
>
<ProviderIcon
@@ -41,7 +41,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
className={
activeApp === "claude"
? "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>
@@ -50,10 +50,10 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button
type="button"
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 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "codex"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
: "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"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
>
<ProviderIcon
@@ -63,7 +63,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
className={
activeApp === "codex"
? "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>
@@ -72,10 +72,10 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button
type="button"
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 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "gemini"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
: "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"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
>
<ProviderIcon
@@ -85,7 +85,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
className={
activeApp === "gemini"
? "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>
+85
View File
@@ -128,6 +128,7 @@ export function DeepLinkImportDialog() {
description: t("deeplink.mcpImportSuccessDescription", {
count: summary.importedCount,
}),
closeButton: true,
});
}
};
@@ -142,6 +143,7 @@ export function DeepLinkImportDialog() {
description: t("deeplink.importSuccessDescription", {
name: request.name,
}),
closeButton: true,
});
} else if (result.type === "prompt") {
// Prompts don't use React Query, trigger a custom event for refresh
@@ -154,6 +156,7 @@ export function DeepLinkImportDialog() {
description: t("deeplink.promptImportSuccessDescription", {
name: request.name,
}),
closeButton: true,
});
} else if (result.type === "mcp") {
await refreshMcp(result);
@@ -171,6 +174,7 @@ export function DeepLinkImportDialog() {
description: t("deeplink.skillImportSuccessDescription", {
repo: request.repo,
}),
closeButton: true,
});
}
} else if (isMcpImportResult(result)) {
@@ -185,6 +189,7 @@ export function DeepLinkImportDialog() {
description: t("deeplink.importSuccessDescription", {
name: request.name,
}),
closeButton: true,
});
}
@@ -605,6 +610,86 @@ export function DeepLinkImportDialog() {
</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 */}
<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")}
+3 -1
View File
@@ -234,7 +234,9 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
try {
const formatted = formatJSON(currentValue);
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }), {
closeButton: true,
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
+2 -1
View File
@@ -14,6 +14,7 @@ interface ProviderIconProps {
export const ProviderIcon: React.FC<ProviderIconProps> = ({
icon,
name,
color,
size = 32,
className,
showFallback = true,
@@ -46,7 +47,7 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
"inline-flex items-center justify-center flex-shrink-0",
className,
)}
style={sizeStyle}
style={{ ...sizeStyle, color }}
dangerouslySetInnerHTML={{ __html: iconSvg }}
/>
);
+1 -1
View File
@@ -56,7 +56,7 @@ export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
"
aria-label={t("common.close")}
>
<X className="w-3 h-3 text-muted-foreground" />
<X className="w-3 h-3 text-gray-400 dark:text-gray-500" />
</button>
</div>
);
+3 -3
View File
@@ -114,7 +114,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
{/* 第一行:更新时间和刷新按钮 */}
<div className="flex items-center gap-2 justify-end">
{/* 上次查询时间 */}
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
<Clock size={10} />
{lastQueriedAt
? formatRelativeTime(lastQueriedAt, now, t)
@@ -128,7 +128,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
refetch();
}}
disabled={loading}
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0 text-muted-foreground"
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0 text-gray-400 dark:text-gray-500"
title={t("usage.refreshUsage")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
@@ -191,7 +191,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
<div className="flex items-center gap-2">
{/* 自动查询时间提示 */}
{lastQueriedAt && (
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
<Clock size={10} />
{formatRelativeTime(lastQueriedAt, now, t)}
</span>
+27 -10
View File
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { Provider, UsageScript } from "@/types";
import { Provider, UsageScript, UsageData } from "@/types";
import { usageApi, type AppId } from "@/lib/api";
import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
@@ -220,13 +220,14 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
);
if (result.success && result.data && result.data.length > 0) {
const summary = result.data
.map((plan) => {
.map((plan: UsageData) => {
const planInfo = plan.planName ? `[${plan.planName}]` : "";
return `${planInfo} ${t("usage.remaining")} ${plan.remaining} ${plan.unit}`;
})
.join(", ");
toast.success(`${t("usageScript.testSuccess")}${summary}`, {
duration: 3000,
closeButton: true,
});
} else {
toast.error(
@@ -259,7 +260,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
printWidth: 80,
});
setScript({ ...script, code: formatted.trim() });
toast.success(t("usageScript.formatSuccess"), { duration: 1000 });
toast.success(t("usageScript.formatSuccess"), {
duration: 1000,
closeButton: true,
});
} catch (error: any) {
toast.error(
`${t("usageScript.formatFailed")}: ${error?.message || t("jsonEditor.invalidJson")}`,
@@ -400,15 +404,25 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
{/* 凭证配置 */}
{shouldShowCredentialsConfig && (
<div className="space-y-4">
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.credentialsConfig")}
</h4>
<div className="flex items-start justify-between">
<h4 className="text-sm font-medium text-foreground">
{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">
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
<>
<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">
<Input
id="usage-api-key"
@@ -417,7 +431,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onChange={(e) =>
setScript({ ...script, apiKey: e.target.value })
}
placeholder="sk-xxxxx"
placeholder={t("usageScript.apiKeyPlaceholder")}
autoComplete="off"
className="border-white/10"
/>
@@ -444,7 +458,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
<div className="space-y-2">
<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>
<Input
id="usage-base-url"
@@ -453,7 +470,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onChange={(e) =>
setScript({ ...script, baseUrl: e.target.value })
}
placeholder="https://api.example.com"
placeholder={t("usageScript.baseUrlPlaceholder")}
autoComplete="off"
className="border-white/10"
/>
+2 -1
View File
@@ -79,6 +79,7 @@ export function EnvWarningBanner({
path: backupInfo.backupPath,
}),
duration: 5000,
closeButton: true,
});
// 清空选择并通知父组件
@@ -197,7 +198,7 @@ export function EnvWarningBanner({
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all">
{t("env.field.value")}: {conflict.varValue}
</p>
<p className="text-xs text-muted-foreground mt-1">
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{t("env.field.source")}:{" "}
{getSourceDescription(conflict)}
</p>
+1 -1
View File
@@ -391,7 +391,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
await upsertMutation.mutateAsync(entry);
toast.success(t("common.success"));
toast.success(t("common.success"), { closeButton: true });
await onSave();
} catch (error: any) {
const detail = extractErrorMessage(error);
+12 -12
View File
@@ -99,7 +99,7 @@ const UnifiedMcpPanel = React.forwardRef<
try {
await deleteServerMutation.mutateAsync(id);
setConfirmDialog(null);
toast.success(t("common.success"));
toast.success(t("common.success"), { closeButton: true });
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
@@ -129,18 +129,18 @@ const UnifiedMcpPanel = React.forwardRef<
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
{isLoading ? (
<div className="text-center py-12 text-muted-foreground">
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("mcp.loading")}
</div>
) : serverEntries.length === 0 ? (
<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">
<Server size={24} className="text-muted-foreground" />
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<Server size={24} className="text-gray-400 dark:text-gray-500" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("mcp.unifiedPanel.noServers")}
</h3>
<p className="text-muted-foreground text-sm">
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("mcp.emptyDescription")}
</p>
</div>
@@ -237,7 +237,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
{/* 左侧:服务器信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-foreground">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{name}
</h3>
{docsUrl && (
@@ -253,12 +253,12 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
)}
</div>
{description && (
<p className="text-sm text-muted-foreground line-clamp-2">
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{description}
</p>
)}
{!description && tags && tags.length > 0 && (
<p className="text-xs text-muted-foreground/70 truncate">
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
{tags.join(", ")}
</p>
)}
@@ -269,7 +269,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-claude`}
className="text-sm text-foreground/80 cursor-pointer"
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.claude")}
</label>
@@ -285,7 +285,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-codex`}
className="text-sm text-foreground/80 cursor-pointer"
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.codex")}
</label>
@@ -301,7 +301,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-gemini`}
className="text-sm text-foreground/80 cursor-pointer"
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.gemini")}
</label>
+2 -2
View File
@@ -36,11 +36,11 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground mb-1">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{prompt.name}
</h3>
{prompt.description && (
<p className="text-sm text-muted-foreground truncate">
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{prompt.description}
</p>
)}
+5 -5
View File
@@ -108,21 +108,21 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
<div className="flex-1 overflow-y-auto pb-16">
{loading ? (
<div className="text-center py-12 text-muted-foreground">
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("prompts.loading")}
</div>
) : promptEntries.length === 0 ? (
<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-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<FileText
size={24}
className="text-muted-foreground"
className="text-gray-400 dark:text-gray-500"
/>
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("prompts.empty")}
</h3>
<p className="text-muted-foreground text-sm">
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("prompts.emptyDescription")}
</p>
</div>
+54 -16
View File
@@ -16,6 +16,7 @@ interface EditProviderDialogProps {
onOpenChange: (open: boolean) => void;
onSubmit: (provider: Provider) => Promise<void> | void;
appId: AppId;
isProxyTakeover?: boolean; // 代理接管模式下不读取 live(避免显示被接管后的代理配置)
}
export function EditProviderDialog({
@@ -24,6 +25,7 @@ export function EditProviderDialog({
onOpenChange,
onSubmit,
appId,
isProxyTakeover = false,
}: EditProviderDialogProps) {
const { t } = useTranslation();
@@ -33,13 +35,33 @@ export function EditProviderDialog({
unknown
> | null>(null);
// 使用 ref 标记是否已经加载过,防止重复读取覆盖用户编辑
const [hasLoadedLive, setHasLoadedLive] = useState(false);
useEffect(() => {
let cancelled = false;
const load = async () => {
if (!open || !provider) {
setLiveSettings(null);
setHasLoadedLive(false);
return;
}
// 关键修复:只在首次打开时加载一次
if (hasLoadedLive) {
return;
}
// 代理接管模式:Live 配置已被代理改写,读取 live 会导致编辑界面展示代理地址/占位符等内容
// 因此直接回退到 SSOT(数据库)配置,避免用户困惑与误保存
if (isProxyTakeover) {
if (!cancelled) {
setLiveSettings(null);
setHasLoadedLive(true);
}
return;
}
try {
const currentId = await providersApi.getCurrent(appId);
if (currentId && provider.id === currentId) {
@@ -49,13 +71,20 @@ export function EditProviderDialog({
)) as Record<string, unknown>;
if (!cancelled && live && typeof live === "object") {
setLiveSettings(live);
setHasLoadedLive(true);
}
} catch {
// 读取实时配置失败则回退到 SSOT(不打断编辑流程)
if (!cancelled) setLiveSettings(null);
if (!cancelled) {
setLiveSettings(null);
setHasLoadedLive(true);
}
}
} else {
if (!cancelled) setLiveSettings(null);
if (!cancelled) {
setLiveSettings(null);
setHasLoadedLive(true);
}
}
} finally {
// no-op
@@ -65,14 +94,33 @@ export function EditProviderDialog({
return () => {
cancelled = true;
};
}, [open, provider, appId]);
}, [open, provider?.id, appId, hasLoadedLive, isProxyTakeover]); // 只依赖 provider.id,不依赖整个 provider 对象
const initialSettingsConfig = useMemo(() => {
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
string,
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(
async (values: ProviderFormValues) => {
@@ -104,7 +152,7 @@ export function EditProviderDialog({
[onSubmit, onOpenChange, provider],
);
if (!provider) {
if (!provider || !initialData) {
return null;
}
@@ -130,17 +178,7 @@ export function EditProviderDialog({
submitLabel={t("common.save")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
initialData={{
name: provider.name,
notes: provider.notes,
websiteUrl: provider.websiteUrl,
// 若读取到实时配置则优先使用
settingsConfig: initialSettingsConfig,
category: provider.category,
meta: provider.meta,
icon: provider.icon,
iconColor: provider.iconColor,
}}
initialData={initialData}
showButtons={false}
/>
</FullScreenPanel>

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