Compare commits

...

204 Commits

Author SHA1 Message Date
YoVinchen 68e07b350d fix(proxy): fix double encoding issue in proxy auth and add debug logs
- Remove encodeURIComponent in mergeAuth() since URL object's
  username/password setters already do percent-encoding automatically
- Add GP-010 debug log for database read operations
- Add GP-011 debug log to track incoming URL info (length, has_auth)
- Fix username.trim() in fallback branch for consistent behavior
2026-01-12 17:44:12 +08:00
YoVinchen 26486d543c feat(proxy): add username/password authentication support
- Add separate username and password input fields
- Implement password visibility toggle with eye icon
- Add clear button to reset all proxy fields
- Auto-extract auth info from saved URL and merge on save
- Update i18n translations (zh/en/ja)
2026-01-12 17:35:15 +08:00
YoVinchen fe49a0c189 fix(proxy): improve global proxy stability and error handling
- Fix RwLock silent failures with explicit error propagation
- Handle init() duplicate calls gracefully with warning log
- Align fallback client config with build_client settings
- Make scan_local_proxies async to avoid UI blocking
- Add mixed mode support for Clash 7890 port (http+socks5)
- Use multiple test targets for better proxy connectivity test
- Clear invalid proxy config on init failure
- Restore timeout constraints in usage_script
- Fix mask_url output for URLs without port
- Add structured error codes [GP-001 to GP-009]
2026-01-12 17:34:08 +08:00
YoVinchen 813d6adb06 Merge branch 'main' into feature/global-proxy 2026-01-12 16:36:25 +08:00
Dex Miller 99c910e58e fix(provider): persist endpoint auto-select state (#611)
- Add endpointAutoSelect field to ProviderMeta for persistence
- Lift autoSelect state from EndpointSpeedTest to ProviderForm
- Save auto-select preference when provider is saved
- Restore preference when editing existing provider

Fixes https://github.com/farion1231/cc-switch/issues/589
2026-01-12 16:26:17 +08:00
Dex Miller 8f7423f011 Feat/deeplink multi endpoints (#597)
* feat(deeplink): support comma-separated multiple endpoints in URL

Allow importing multiple API endpoints via single endpoint parameter.
First URL becomes primary endpoint, rest are added as custom endpoints.

* feat(deeplink): add usage query fields to deeplink generator

Add form fields for usage query configuration in deeplink HTML generator:
- usageEnabled, usageBaseUrl, usageApiKey
- usageScript, usageAutoInterval
- usageAccessToken, usageUserId

* fix(deeplink): auto-infer homepage and improve multi-endpoint display

- Auto-infer homepage from primary endpoint when not provided
- Display multiple endpoints as list in import dialog (primary marked)
- Update deeplink parser in deplink.html to show multi-endpoint info
- Add test for homepage inference from endpoint
- Minor log format fix in live.rs

* fix(deeplink): use primary endpoint for usage script base_url

- Fix usage_script.base_url getting comma-separated string when multiple endpoints
- Add i18n support for primary endpoint label in DeepLinkImportDialog
2026-01-12 15:57:45 +08:00
YoVinchen d745ab58c4 style: format code with prettier 2026-01-12 12:26:05 +08:00
YoVinchen c2adb1af46 fix(proxy): restore request timeout and fix proxy hot-reload issues
- Add URL scheme validation in build_client (http/https/socks5/socks5h)
- Restore per-request timeout for speedtest, stream_check, usage_script, forwarder
- Fix set_global_proxy_url to validate before persisting to DB
- Mask proxy credentials in all log outputs
- Fix forwarder hot-reload by fetching client on each request
2026-01-12 11:34:31 +08:00
YoVinchen fdd539759e fix(proxy): allow localhost input in proxy address field 2026-01-12 11:32:27 +08:00
YoVinchen 054a5e9e3b Merge branch 'main' into feature/global-proxy 2026-01-12 09:29:44 +08:00
Jason c56523c9c0 Merge tianrking/main: feat: add provider-specific terminal button
Merged PR #452 which adds:
- Terminal button for Claude providers to launch with provider-specific config
- Cross-platform support (macOS/Linux/Windows)
- Auto-cleanup of temporary config files
2026-01-12 09:13:24 +08:00
YoVinchen cba8e8fdb3 feat(proxy): add local proxy auto-scan and fix hot-reload
- Add scan_local_proxies command to detect common proxy ports
- Fix SkillService not using updated proxy after hot-reload
- Move global proxy settings to advanced tab
- Add error handling for scan failures
2026-01-12 01:11:43 +08:00
YoVinchen b18be24384 Merge branch 'main' into feature/global-proxy
Resolve conflict in skill.rs: keep global HTTP client for proxy support
2026-01-12 00:07:49 +08:00
Jason 6aef472fd2 fix(deeplink): prioritize GOOGLE_GEMINI_BASE_URL over GEMINI_BASE_URL
When merging Gemini config from local env file during deeplink import,
check GOOGLE_GEMINI_BASE_URL first (official variable name) before
falling back to GEMINI_BASE_URL.
2026-01-11 23:24:06 +08:00
Xyfer 4a8883ecc3 fix(mcp): skip cmd /c wrapper for WSL target paths (#592)
* fix(mcp): skip cmd /c wrapper for WSL target paths

When the Claude config directory is set to a WSL network path
(e.g., \wsl$\Ubuntu\home\user\.claude), the MCP export should
not wrap npx/npm commands with cmd /c since WSL runs Linux.

- Add is_wsl_path() to detect \wsl$\ and \wsl.localhost\ paths
- Skip wrap_command_for_windows() when target is WSL path
- Add comprehensive tests for various WSL distributions

* chore(mcp): add debug log for WSL path detection

* refactor(mcp): optimize is_wsl_path with next() and rename variable
2026-01-11 20:51:56 +08:00
Dex Miller 6dd809701b Refactor/simplify proxy logs (#585)
* refactor(proxy): simplify logging for better readability

- Delete 17 verbose debug logs from handlers, streaming, and response_processor
- Convert excessive INFO logs to DEBUG level for internal processing details
- Add 2 critical INFO logs in forwarder.rs for failover scenarios:
  - Log when switching to next provider after failure
  - Log when all providers have been exhausted
- Fix clippy uninlined_format_args warning

This reduces log noise while maintaining visibility into key user-facing decisions.

* fix: replace unsafe unwrap() calls with proper error handling

- database/dao/mcp.rs: Use map_err for serde_json serialization
- database/dao/providers.rs: Use map_err for settings_config and meta serialization
- commands/misc.rs: Use expect() for compile-time regex pattern
- services/prompt.rs: Use unwrap_or_default() for SystemTime
- deeplink/provider.rs: Replace unwrap() with is_none_or pattern for Option checks

Reduces potential panic points from 26 to 1 (static regex init, safe).

* refactor(proxy): simplify verbose logging output

- Remove response JSON full output logging in response_processor
- Remove per-request INFO logs in provider_router (failover status, provider selection)
- Change model mapping log from INFO to DEBUG
- Change usage logging failure from INFO to WARN
- Remove redundant debug logs for circuit breaker operations

Reduces log noise significantly while preserving important warnings and errors.

* feat(proxy): add structured log codes for i18n support

Add error code system to proxy module logs for multi-language support:

- CB-001~006: Circuit breaker state transitions and triggers
- SRV-001~004: Proxy server lifecycle events
- FWD-001~002: Request forwarding and failover
- FO-001~005: Failover switch operations
- USG-001~002: Usage logging errors

Log format: [CODE] Chinese message
Frontend/log tools can map codes to any language.

New file: src/proxy/log_codes.rs - centralized code definitions

* chore: bump version to 3.9.1

* style: format code with prettier and rustfmt

* fix(ui): allow number inputs to be fully cleared before saving

- Convert numeric state to string type for controlled inputs
- Use isNaN() check instead of || fallback to allow 0 values
- Apply fix to ProxyPanel, CircuitBreakerConfigPanel,
  AutoFailoverConfigPanel, and ModelTestConfigPanel

* feat(pricing): support @ separator in model name matching

- Refactor model name cleaning into chained method calls
- Add @ to - replacement (e.g., gpt-5.2-codex@low → gpt-5.2-codex-low)
- Add test case for @ separator matching

* fix(proxy): improve validation and error handling in proxy config panels

- Add StopTimeout/StopFailed error types for proper stop() error reporting
- Replace silent clamp with validation-and-block in config panels
- Add listenAddress format validation in ProxyPanel
- Use log_codes constants instead of hardcoded strings
- Use once_cell::Lazy for regex precompilation

* fix(proxy): harden error handling and input validation

- Handle RwLock poisoning in settings.rs with unwrap_or_else
- Add fallback for dirs::home_dir() in config modules
- Normalize localhost to 127.0.0.1 in ProxyPanel
- Format IPv6 addresses with brackets for valid URLs
- Strict port validation with pure digit regex
- Treat NaN as validation failure in config panels
- Log warning on cost_multiplier parse failure
- Align timeoutSeconds range to [0, 300] across all panels
2026-01-11 20:50:54 +08:00
YoVinchen 86288ee77e Merge branch 'refactor/simplify-proxy-logs' into feature/global-proxy
Resolve conflict in skill.rs: keep global HTTP client for proxy support
2026-01-11 16:58:40 +08:00
YoVinchen c1e27d3cf2 Merge branch 'main' into refactor/simplify-proxy-logs 2026-01-11 16:46:53 +08:00
YoVinchen c5d3732b9f fix(proxy): harden error handling and input validation
- Handle RwLock poisoning in settings.rs with unwrap_or_else
- Add fallback for dirs::home_dir() in config modules
- Normalize localhost to 127.0.0.1 in ProxyPanel
- Format IPv6 addresses with brackets for valid URLs
- Strict port validation with pure digit regex
- Treat NaN as validation failure in config panels
- Log warning on cost_multiplier parse failure
- Align timeoutSeconds range to [0, 300] across all panels
2026-01-11 16:45:05 +08:00
Jason 76fa830688 fix(live): sync skills to app directories on config path change
When users change app config directories (claudeConfigDir, codexConfigDir,
geminiConfigDir), MCP servers were being synced to the new paths but Skills
were not. This adds Skill synchronization to sync_current_to_live() to ensure
installed Skills are also copied to the new app directories.
2026-01-11 16:39:50 +08:00
Jason c9a4938866 fix(provider-form): reset baseUrl and apiKey states when switching presets
Fix state synchronization in useBaseUrlState and useApiKeyState hooks
to properly clear values when config is reset. Previously, when switching
from a preset to "custom", the baseUrl and apiKey states would retain
their old values because the sync logic only updated when new values
existed, not when they were cleared.

Changes:
- useBaseUrlState: Always sync baseUrl to config value (empty if undefined)
- useApiKeyState: Remove hasApiKeyField check that prevented clearing
2026-01-11 16:39:50 +08:00
Jason 95ed6d6903 fix(usage): prevent usage script config from leaking between providers
Add key prop to UsageScriptModal to ensure component remounts when
switching between different providers. This fixes issue #569 where
configuring usage query for one provider would incorrectly apply
the same configuration to all providers.

The root cause was that useState initialization only runs on first
mount, and due to useLastValidValue hook keeping the modal rendered
during close animation, the component might not fully unmount when
switching providers rapidly.

Closes #569
2026-01-11 16:39:50 +08:00
Jason 83db457b10 refactor(proxy): disable OpenRouter compat mode by default and hide UI toggle
OpenRouter now natively supports Claude Code compatible API (/v1/messages),
so format transformation (Anthropic ↔ OpenAI) is no longer needed by default.

- Change default value from `true` to `false` in both frontend and backend
- Hide the "OpenRouter Compatibility Mode" toggle in provider form
- Users can still enable it manually by adding `"openrouter_compat_mode": true` in config JSON
- Update unit tests to reflect new default behavior
2026-01-11 16:39:50 +08:00
YoVinchen d33f99aca4 fix(proxy): improve validation and error handling in proxy config panels
- Add StopTimeout/StopFailed error types for proper stop() error reporting
- Replace silent clamp with validation-and-block in config panels
- Add listenAddress format validation in ProxyPanel
- Use log_codes constants instead of hardcoded strings
- Use once_cell::Lazy for regex precompilation
2026-01-11 14:12:57 +08:00
YoVinchen 42d1d23618 feat(proxy): add global proxy settings support
Add ability to configure a global HTTP/HTTPS proxy for all outbound
requests including provider API calls, speed tests, and stream checks.
2026-01-11 12:26:15 +08:00
Xyfer 392756e373 fix(gemini): convert timeout params to Gemini CLI format (#580)
Claude Code/Codex uses startup_timeout_sec and tool_timeout_sec,
but Gemini CLI only supports a single timeout param in milliseconds.

- Collect startup and tool timeout separately with defaults
  - startup_timeout_sec default: 10s
  - tool_timeout_sec default: 60s
- Take max of both values as final timeout
- Support both sec and ms variants
- Remove original fields and insert Gemini-compatible timeout
2026-01-11 10:42:44 +08:00
YoVinchen 7bc9dbbbb0 feat(pricing): support @ separator in model name matching
- Refactor model name cleaning into chained method calls
- Add @ to - replacement (e.g., gpt-5.2-codex@low → gpt-5.2-codex-low)
- Add test case for @ separator matching
2026-01-11 01:53:23 +08:00
YoVinchen e002bdba25 fix(ui): allow number inputs to be fully cleared before saving
- Convert numeric state to string type for controlled inputs
- Use isNaN() check instead of || fallback to allow 0 values
- Apply fix to ProxyPanel, CircuitBreakerConfigPanel,
  AutoFailoverConfigPanel, and ModelTestConfigPanel
2026-01-11 01:33:41 +08:00
YoVinchen 5694353798 style: format code with prettier and rustfmt 2026-01-11 01:00:04 +08:00
YoVinchen 17c3dffe8c chore: bump version to 3.9.1 2026-01-11 00:55:02 +08:00
YoVinchen 07ba3df0f4 feat(proxy): add structured log codes for i18n support
Add error code system to proxy module logs for multi-language support:

- CB-001~006: Circuit breaker state transitions and triggers
- SRV-001~004: Proxy server lifecycle events
- FWD-001~002: Request forwarding and failover
- FO-001~005: Failover switch operations
- USG-001~002: Usage logging errors

Log format: [CODE] Chinese message
Frontend/log tools can map codes to any language.

New file: src/proxy/log_codes.rs - centralized code definitions
2026-01-11 00:44:54 +08:00
YoVinchen 1fe16aa388 refactor(proxy): simplify verbose logging output
- Remove response JSON full output logging in response_processor
- Remove per-request INFO logs in provider_router (failover status, provider selection)
- Change model mapping log from INFO to DEBUG
- Change usage logging failure from INFO to WARN
- Remove redundant debug logs for circuit breaker operations

Reduces log noise significantly while preserving important warnings and errors.
2026-01-11 00:41:28 +08:00
YoVinchen f738871ad1 fix: replace unsafe unwrap() calls with proper error handling
- database/dao/mcp.rs: Use map_err for serde_json serialization
- database/dao/providers.rs: Use map_err for settings_config and meta serialization
- commands/misc.rs: Use expect() for compile-time regex pattern
- services/prompt.rs: Use unwrap_or_default() for SystemTime
- deeplink/provider.rs: Replace unwrap() with is_none_or pattern for Option checks

Reduces potential panic points from 26 to 1 (static regex init, safe).
2026-01-10 23:31:35 +08:00
YoVinchen 753190a879 refactor(proxy): simplify logging for better readability
- Delete 17 verbose debug logs from handlers, streaming, and response_processor
- Convert excessive INFO logs to DEBUG level for internal processing details
- Add 2 critical INFO logs in forwarder.rs for failover scenarios:
  - Log when switching to next provider after failure
  - Log when all providers have been exhausted
- Fix clippy uninlined_format_args warning

This reduces log noise while maintaining visibility into key user-facing decisions.
2026-01-10 16:07:53 +08:00
Jason 6021274b82 fix(ci): temporarily remove Flatpak build
Flatpak build has persistent issues with libdbusmenu dependencies.
Removing it for now to allow release. Can be re-added later with
proper libayatana dependency configuration.
2026-01-09 22:01:33 +08:00
Jason b7fd70075c fix(flatpak): remove --enable-tests=no to fix HAVE_VALGRIND error
libdbusmenu's configure.ac has a bug where AM_CONDITIONAL([HAVE_VALGRIND])
is only defined when tests are enabled. Removing --enable-tests=no allows
the conditional to be properly defined.

Ref: https://bugs.launchpad.net/ubuntu/+source/libdbusmenu/+bug/1708938
2026-01-09 21:47:59 +08:00
Jason eeb6afef01 fix(flatpak): bundle intltool for libdbusmenu build
intltool was removed from org.gnome.Sdk in 2019. libdbusmenu's configure
script requires it even with --disable-nls. Using cleanup: ["*"] ensures
intltool is only used at build time.
2026-01-09 21:26:53 +08:00
Jason d3074eadb5 fix(flatpak): disable NLS for libdbusmenu-gtk3
The Flatpak SDK lacks intltool, causing libdbusmenu-gtk3 configure to
fail. Disabling NLS avoids this dependency.
2026-01-09 21:07:38 +08:00
Jason 31d34a4512 fix(ci): add elfutils for Flatpak build
The libayatana modules added in 2923627b require eu-strip to strip
debug symbols during flatpak-builder execution.
2026-01-09 20:44:23 +08:00
Jason 3ef86bdb99 chore: bump version to v3.9.1
- Update version in package.json, tauri.conf.json, Cargo.toml
- Update version badges and current version in README files
- Add v3.9.1 changelog entry with bug fixes and improvements
2026-01-09 20:19:38 +08:00
Jason df3f8a05c4 fix(presets): rename AiGoCode to AIGoCode
Update the display name casing for AIGoCode across all provider presets
and i18n files to match their official branding.
2026-01-09 20:03:24 +08:00
Jason 8e6fad7af2 fix(windows): correct window title and remove extra titlebar spacing
- Add missing "title" field to tauri.windows.conf.json to display
  "CC Switch" instead of default "Tauri app"
- Make DRAG_BAR_HEIGHT platform-aware: 0px on Windows/Linux (native
  titlebar), 28px on macOS (Overlay mode needs traffic light space)
- Apply same fix to FullScreenPanel component for consistency

Fixes the issue where Windows showed wrong title and had ~28px extra
blank space below the native titlebar introduced in v3.9.0.
2026-01-09 19:45:36 +08:00
Jason f22000a4df feat(presets): add AiGoCode icon and partner promotion
- Add AiGoCode colored icon (blue body #5B7FFF, purple star #7C6AEF)
- Add original SVG source file for reference
- Add icon metadata with keywords and default color
- Add iconColor to AiGoCode presets for Claude, Codex, and Gemini
- Add partner promotion messages in zh/en/ja locales
2026-01-09 16:29:13 +08:00
Dex Miller 412906fb09 feat(logging): add crash logging and improve log management (#562)
* feat(logging): add crash logging and improve log management

- Add panic hook to capture crash info to ~/.cc-switch/crash.log
  - Records timestamp, app version, OS/arch, thread info
  - Full stack trace with force_capture for release builds
  - Safe error handling (no nested panics)

- Enable logging for both Debug and Release builds
  - Info level for all builds
  - Output to console and ~/.cc-switch/logs/
  - 5MB max file size with rotation

- Add log cleanup on startup
  - Keep only 2 most recent log files
  - Works on all platforms

- Change panic strategy from "abort" to "unwind"
  - Required for backtrace capture in release builds

* fix(logging): use OnceLock for config dir and add URL redaction

- Use OnceLock to support custom config directory override for crash.log
- Add redact_url_for_log() to protect sensitive URL parameters in logs
- Change verbose deep link logs from info to debug level
- Move Store refresh before panic_hook init to ensure correct path

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-01-09 16:23:59 +08:00
Jason Young a268127f1f Fix/Resolve panic issues in proxy-related code (#560)
* fix(proxy): change default port from 5000 to 15721

Port 5000 conflicts with AirPlay Receiver on macOS 12+.
Also adds error handling for proxy toggle and i18n placeholder updates.

* fix(proxy): replace unwrap/expect with graceful error handling

- Handle HTTP client initialization failure with no_proxy fallback
- Fix potential panic on Unicode slicing in API key preview
- Add proper error handling for response body builder
- Handle edge case where SystemTime is before UNIX_EPOCH

* fix(proxy): handle UTF-8 char boundary when truncating request body log

Rust strings are UTF-8 encoded, slicing at a fixed byte index may cut
in the middle of a multi-byte character (e.g., Chinese, emoji), causing
a panic. Use is_char_boundary() to find the nearest safe cut point.

* fix(proxy): improve robustness and prevent panics

- Add reqwest socks feature to support SOCKS proxy environments
- Fix UTF-8 safety in masked_key/masked_access_token (use chars() instead of byte slicing)
- Fix UTF-8 boundary check in usage_script HTTP response truncation
- Add defensive checks for JSON operations in proxy service
- Remove verbose debug logs that could trigger panic-prone code paths
2026-01-09 13:09:19 +08:00
sada-dev 2923627b76 fix(flatpak): bundle libayatana-appindicator for tray icon support (#556)
The Flatpak was failing to load because libayatana-appindicator3 is not
included in org.gnome.Platform runtime. This adds the required modules:
- libayatana-ido
- libdbusmenu-gtk3
- libayatana-indicator
- libayatana-appindicator

Fixes panic: 'Failed to load ayatana-appindicator3 or appindicator3 dynamic library'

Co-authored-by: Said John <said.john@gmail.com>
2026-01-09 12:20:16 +08:00
Jason 48db113b37 docs: update v3.9.0 release notes with acknowledgments and improved macOS instructions
- Add special thanks section for contributors @xunyu @deijing @su-fen
- Update macOS tip: replace xattr command with System Settings GUI guidance
- Reformat markdown tables with aligned columns
- Move Linux section after Homebrew section
2026-01-08 16:36:45 +08:00
Jason e9fc56525d docs: enhance download section with detailed installation guide for v3.9.0
- Convert system requirements to table format with architecture info
- Add Windows/macOS file descriptions with recommended options
- Replace Linux section with comprehensive distro-based table
- Include complete installation commands for deb/rpm/AppImage/flatpak
- Add helpful tips for macOS Gatekeeper and AppImage usage
2026-01-08 15:37:07 +08:00
苏风 75d512b36c docs: add download options for each system version in 3.9.0 release note (#547)
* docs: add download options for each system version in 3.9.0 release note.

* docs: add download options for each system version in 3.9.0 release note.
2026-01-08 15:04:27 +08:00
Jason 157ebaaad2 fix(ci): add --user flag to flatpak remote-add command 2026-01-08 12:49:28 +08:00
Jason 42d9afa3e2 fix(ci): remove extra '--' from Linux build command 2026-01-08 12:36:32 +08:00
Jason a0b5e3b808 chore(release): prepare v3.9.0 stable release
- Bump version from 3.9.0-3 to 3.9.0 across all config files
- Add comprehensive release notes in English, Chinese, and Japanese
- Update CHANGELOG with v3.9.0 stable and v3.9.0-2 entries
- Update README badges and release note links to v3.9.0
2026-01-08 11:24:07 +08:00
Jason effb931a1b feat(presets): add Cubence as partner provider
- Add Cubence provider presets for Claude, Codex, and Gemini
- Add Cubence icon to icon system
- Add partner promotion text in zh/en/ja
2026-01-08 11:24:07 +08:00
Jason 0456255625 docs: add Cubence as sponsor partner
Add Cubence sponsor information to README in all three languages
(English, Chinese, Japanese).
2026-01-08 11:24:07 +08:00
Dex Miller 847f1c5377 Feat/proxy header improvements (#538)
* fix(proxy): improve header handling for Claude API compatibility

- Streamline header blacklist by removing overly aggressive filtering
  (browser-specific headers like sec-fetch-*, accept-language)
- Ensure anthropic-beta header always includes 'claude-code-20250219'
  marker required by upstream services for request validation
- Centralize anthropic-version header handling in forwarder to prevent
  duplicate headers across different auth strategies
- Add ?beta=true query parameter to /v1/messages endpoint for
  compatibility with certain upstream services (e.g., DuckCoding)
- Remove redundant anthropic-version from ClaudeAdapter auth headers
  as it's now managed exclusively by the forwarder

This improves proxy reliability with various Claude API endpoints
and third-party services that have specific header requirements.

* style(services): use inline format arguments in format strings

Apply Rust 1.58+ format string syntax across provider and skill
services. This replaces format!("msg {}", var) with format!("msg {var}")
for improved readability and consistency with modern Rust idioms.

Changed files:
- services/provider/mod.rs: 1 format string
- services/skill.rs: 10 format strings (error messages, log statements)

No functional changes, purely stylistic improvement.

* fix(proxy): restrict Anthropic headers to Claude adapter only

- Move anthropic-beta and anthropic-version header handling inside
  Claude-specific condition to avoid sending unnecessary headers
  to Codex and Gemini APIs
- Update test cases to reflect ?beta=true query parameter behavior
- Add edge case tests for non-messages endpoints and existing queries
2026-01-08 11:04:42 +08:00
Jason 24a36df140 chore(presets): update model versions for provider presets
Claude presets:
- Zhipu GLM: glm-4.6 → glm-4.7
- Z.ai GLM: glm-4.6 → glm-4.7
- ModelScope: GLM-4.6 → GLM-4.7
- MiniMax: MiniMax-M2 → MiniMax-M2.1

Codex presets:
- Azure, AiHubMix, DMXAPI, PackyCode: gpt-5.1-codex → gpt-5.2
- Custom template: gpt-5-codex → gpt-5.2
2026-01-06 23:05:22 +08:00
Jason 2c4c2ce83d fix(settings): navigate to About tab when clicking update badge
- Add defaultTab prop to SettingsPage for external tab control
- UpdateBadge click now opens settings directly to About tab
- Settings button still opens to General tab (default behavior)
- Change update badge text from version number to "Update available"
2026-01-06 11:27:32 +08:00
Jason 0020889a8f fix(prompts): allow saving prompts with empty content
Remove content validation requirement to allow users to save prompts
with empty content for placeholder or draft purposes.
2026-01-05 23:30:03 +08:00
Jason 671cda60d9 fix(database): show error dialog on initialization failure with retry option
- Add user-friendly dialog when database init or schema migration fails
- Support retry mechanism instead of crashing the app
- Include bilingual (Chinese/English) error messages with troubleshooting tips
- Add comprehensive test for v3.8 → current schema migration path
2026-01-05 22:39:39 +08:00
Jason efa653809b style: format code with Prettier 2026-01-05 21:53:01 +08:00
Jason 5aa35906d8 feat(release): add RPM and Flatpak packaging support for Linux
- Add RPM bundle to Linux build targets in CI workflow
- Add Flatpak manifest, desktop entry, and AppStream metainfo
- Update release workflow to build and publish .rpm and .flatpak artifacts
- Update README docs with new Linux package formats and installation instructions
- Add .gitignore rules for Flatpak build artifacts
2026-01-05 16:35:36 +08:00
Jason 4777c99b38 refactor(settings): reorder advanced tab items for better UX
Move Auto Failover section directly after Proxy section since they are
functionally related (failover depends on proxy service).
2026-01-05 16:35:36 +08:00
duskzhen 8912216cb2 fix(common): improve window dragging area in FullScreenPanel (#525)
Add data-tauri-drag-region and WebkitAppRegion styles to header area to match	App.tsx behavior, ensuring proper window dragging on macOS.
2026-01-05 16:25:38 +08:00
Jason 32149b1eeb chore(proxy): remove unused body filter helper
Remove unused `filter_recursive` function from body_filter.rs to fix
`dead-code` warning from `cargo clippy -- -D warnings`.
2026-01-04 23:00:13 +08:00
Jason 8979d964d6 fix(proxy): clean up model override env vars when switching providers in takeover mode
When proxy takeover is enabled, switching providers no longer writes to
the Live config. However, if model override fields (ANTHROPIC_MODEL,
ANTHROPIC_REASONING_MODEL, etc.) remain in the Live config, Claude Code
continues sending requests with the old model name, causing failures
when the new provider doesn't support that model.

This fix:
- Removes model override env keys from Claude Live config during takeover
- Adds cleanup when switching providers in takeover mode
- Fixes has_mapping() to include reasoning_model in the check
- Adds test coverage for reasoning-only model mapping scenarios
2026-01-04 16:09:37 +08:00
Jason 37396b9c70 fix(common-config): reset initialization flag when preset changes
The common config checkbox was only auto-enabled for the first preset
(custom) because hasInitializedNewMode ref was permanently locked to
true after first initialization.

Add selectedPresetId parameter to all three common config hooks and
reset the initialization flag when preset changes, allowing the
initialization logic to re-run for each preset switch.
2026-01-04 12:27:22 +08:00
Jason 2c35372ca0 fix(codex): remove entire model_providers table from common config extraction
Previously only removed base_url from model_providers.* tables. Now removes
the entire model_providers section since all its fields (name, base_url,
wire_api, requires_openai_auth) are provider-specific configuration.

MCP servers configuration remains preserved as it's provider-agnostic.
2026-01-04 12:27:22 +08:00
Jason 63bb673bf2 refactor(common-config): extract snippet from editor content instead of active provider
- Change extraction source from current active provider to editor's live content
- Add i18n support for JSON parse error messages via invalid_json_format_error()
- Simplify API by removing unused providerId parameter
- Update button labels and error messages in zh/en/ja locales
2026-01-04 12:27:22 +08:00
Jason 058f86aff3 fix(codex): prevent extract_common_config from removing MCP servers' base_url
- Replace regex patterns with toml_edit for precise field removal
- Only remove top-level model/model_provider/base_url fields
- Only remove base_url from [model_providers.*] tables
- Add regression test to ensure [mcp_servers.*] base_url is preserved
2026-01-04 12:27:22 +08:00
Jason 188c94f2e3 feat(common-config): add extract from current provider functionality
- Add backend command to extract common config snippet from current provider
- Automatically extract common config on first run after importing default provider
- Auto-enable common config checkbox in new provider mode when snippet exists
- Refactor Gemini common config to operate on .env instead of config.json
- Add "Extract from Current" button to all three common config modals
- Update i18n translations for new extraction feature
2026-01-04 12:27:22 +08:00
Dex Miller c049c5f2bb feat(proxy): update failover timeout and circuit breaker defaults (#521)
- Double all timeout values (streaming/non-streaming)
- Codex/Gemini: circuit_failure_threshold 5→4, error_rate 0.5→0.6
- Claude: circuit_error_rate_threshold 0.6→0.7
2026-01-04 11:52:12 +08:00
jwaterwater c71b030662 feat: change the default Qwen BaseUrl (#517) 2026-01-04 10:54:36 +08:00
w0x7ce f363bb1dd0 Merge origin/main into main
Resolved conflict in src-tauri/src/commands/misc.rs by combining imports from both sides.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-04 09:12:12 +08:00
Jason 2105f2d05b fix(i18n): remove hardcoded defaultValue and unused placeholder for reasoning model 2026-01-03 12:04:34 +08:00
Jason 0a9de282a3 fix(skills): skip hidden directories when scanning for skills
Filter out directories starting with '.' (e.g., .system) during skill
scanning to avoid exposing internal system directories from Codex.
2026-01-03 11:42:10 +08:00
Jason 0e085aa01a fix(prompts): change toggle color from blue to emerald
Align with the app-wide Switch component color scheme for
visual consistency across all toggle elements.
2026-01-03 10:15:28 +08:00
Jason 2c95f697cd fix(prompts): unify add button style with other panels
Replace circular orange icon button with ghost text button
to match Skills and MCP panel styling.
2026-01-03 10:13:32 +08:00
Jason ba97a5f373 fix(mcp): unify header buttons style with skills panel
Changed MCP panel buttons to match Skills panel styling:
- Replace circular orange icon button with ghost text button
- Update button labels: "Import" → "Import Existing", add "Add MCP"
2026-01-03 10:11:26 +08:00
Jason 86e802bd4b feat(mcp): add import button to import MCP servers from apps
- Add import_mcp_from_apps command that reuses existing import logic
- Add Import button in MCP panel header (consistent with Skills)
- Fix import count to only return truly new servers (not already in DB)
- Update translations for import success/no-import messages (zh/en/ja)
2026-01-03 10:04:46 +08:00
Jason 6460c1d5dd fix(skills): move import button to header for better discoverability
- Add import button next to discover button in skills page header
- Expose openImport method via UnifiedSkillsPanel ref
- Show toast instead of dialog when no unmanaged skills found
- Remove redundant buttons from empty state view
2026-01-03 09:21:15 +08:00
Jason 6b73e55bfe fix(ui): remove hover scale effect from skill cards
The hover:scale-[1.01] effect caused cards to overflow their container
boundaries. Keeping only hover:shadow-lg provides sufficient visual
feedback without the overflow issue.
2026-01-02 23:57:22 +08:00
Jason 47aa4c6bee fix(skills): show loading indicator when refreshing discovery list
Use isFetching instead of just isLoading to show the loading spinner.
isLoading is only true on initial load, while isFetching is true
during any fetch operation including refetch.
2026-01-02 23:51:03 +08:00
Jason 4dc59dff21 fix(skills): remove refresh button from installed skills panel
The refresh button is only needed in the discovery panel (to fetch
latest skills from GitHub). The installed skills panel uses local
database which auto-updates on install/uninstall operations.
2026-01-02 23:41:28 +08:00
Jason c8750f5550 fix(i18n): rename Skills title to be app-agnostic
- Remove "Claude" prefix from Skills management title
- Update descriptions to include Gemini alongside Claude Code/Codex
- Applied to all three locales: zh, en, ja
2026-01-02 23:15:22 +08:00
Jason 22460de976 perf(skills): use infinite cache for discoverable skills
Change staleTime from 5 minutes to Infinity. Cache is still properly
invalidated when repos are added/removed or skills are installed/uninstalled.
2026-01-02 23:04:40 +08:00
Jason e69c1bd8aa fix(ui): align FullScreenPanel header with App.tsx layout
- Use same DRAG_BAR_HEIGHT (28px) and HEADER_HEIGHT (64px) as App.tsx
- Remove border-b divider line from header
- Add rounded-lg class to back button for consistency
2026-01-02 22:35:33 +08:00
Jason a17fa8098b fix(skills): remove redundant navigation buttons in skills pages
- Remove duplicate "Repo Manager" button from installed skills view
  (should only appear in discovery view)
- Remove redundant back button from SkillsPage component
  (header already provides unified navigation)
- Clean up unused openRepoManagerOnDiscovery state and related useEffect
- Remove unused onClose prop and ArrowLeft import from SkillsPage
2026-01-02 22:33:33 +08:00
Jason ff03ca1e63 feat(skills): unified management architecture with SSOT and React Query
- Introduce SSOT (Single Source of Truth) at ~/.cc-switch/skills/
- Add three-app toggle support (Claude/Codex/Gemini) for each skill
- Refactor frontend to use TanStack Query hooks instead of manual state
- Add UnifiedSkillsPanel for managing installed skills with app toggles
- Add useSkills.ts with declarative data fetching hooks
- Extend skills.ts API with unified install/uninstall/toggle methods
- Support importing unmanaged skills from app directories
- Add v2→v3 database migration for new skills table structure
2026-01-02 22:04:02 +08:00
Jason cce6ae86a5 fix: prevent env check card border overflow on hover
Add horizontal padding to the grid container to accommodate the scale
transform effect when hovering over environment check cards.
2025-12-31 22:58:03 +08:00
Dex Miller 5376ea042b Feat/usage improvements (#508)
* i18n: update cache terminology across all languages

- Change 'Cache Read' to 'Cache Hit' in all languages
- Change 'Cache Write' to 'Cache Creation' in all languages
- Update zh: 缓存读取 → 缓存命中, 缓存写入 → 缓存创建
- Update en: Cache Read → Cache Hit, Cache Write → Cache Creation
- Update ja: キャッシュ読取 → キャッシュヒット, キャッシュ書込 → キャッシュ作成

Affected keys: cacheReadTokens, cacheCreationTokens, cacheReadCost,
cacheWriteCost, cacheRead, cacheWrite

* feat(usage): add cache metrics to trend chart

- Add cache creation tokens visualization (orange line)
- Add cache hit tokens visualization (purple line)
- Add gradient definitions for new cache metrics
- Include cache data in hourly aggregation
- Display cache metrics alongside input/output tokens

This provides better visibility into cache usage patterns over time.

* fix(usage): fix timezone handling in datetime picker

- Add timestampToLocalDatetime() to convert Unix timestamp to local datetime
- Add localDatetimeToTimestamp() with validation for incomplete input
- Fix issue where typing hours/minutes would jump to previous day
- Validate datetime format completeness before conversion
- Use local timezone instead of UTC for datetime-local input

This resolves the issue where users couldn't fine-tune time selection
and the input would jump unexpectedly when editing hours or minutes.

* feat(usage): add auto-refresh for usage statistics

- Add 30-second auto-refresh interval for all usage queries
- Disable background refresh to save resources
- Apply to: summary, trends, provider stats, model stats, request logs
- Queries automatically update when tab is active
- Pause refresh when user switches to another tab

This keeps usage data fresh without manual refresh.

* fix(proxy): improve usage logging and cache token parsing

- Log requests even when usage parsing fails (with default values)
- Add detailed debug logging for usage metrics
- Support cache_read_input_tokens field in Codex responses
- Fallback to input_tokens_details.cached_tokens if needed
- Add test case for cached_tokens in input_tokens_details
- Ensure all requests are tracked in database for analytics

This fixes missing request logs when API responses lack usage data
and improves cache token detection across different response formats.

* style(rust): use inline format args in format! macros

- Replace format!("...", var) with format!("...{var}")
- Update universal provider ID formatting
- Update error message formatting
- Update config.toml generation in Codex provider

Fixes clippy::uninlined_format_args warnings.

* feat(proxy): enhance provider router logging

- Add debug logs for failover queue provider count
- Log circuit breaker state for each provider check
- Add logs for missing current provider scenarios
- Log when no current provider is configured
- Use inline format args for better readability

This improves debugging of provider selection and failover behavior.

* feat(database): update model pricing data

- Update Claude models to full version format (e.g. claude-opus-4-5-20251101)
- Add GPT-5.2 series model pricing (10 models)
- Add GPT-5.1 series model pricing (10 models)
- Add GPT-5 series model pricing (12 models)
- Add Gemini 3 series model pricing (2 models)
- Update Gemini 2.5 series model ID format (use dot separator)
- Unify display names by removing thinking level suffixes

* fix(usage): correct Gemini output token calculation

Fix Gemini API output token parsing to use totalTokenCount - promptTokenCount
instead of candidatesTokenCount alone. This ensures thoughtsTokenCount is
included in output statistics.

- Update from_gemini_response to calculate output from total - input
- Update from_gemini_stream_chunks with same logic for consistency
- Fix from_codex_stream_events to use adjusted token calculation
- Add test case for responses with thoughtsTokenCount
- Update existing tests to match new calculation logic

* fix(usage): correct cache token billing and add Codex format auto-detection

- Avoid double-billing cache tokens by subtracting from input before calculation
- Add smart Codex parser that auto-detects OpenAI vs Codex API format
- Extract model name from Codex responses for accurate tracking

* fix(proxy): improve takeover detection with live config check

- Add live config takeover detection for hot-switch decision
- Rebuild takeover when backup is missing or placeholder remains
- Make detect_takeover_in_live_config_for_app public
- Fix is_takeover_active to use actual takeover status

* refactor(usage): simplify model pricing lookup by removing suffix fallback

Replace complex suffix-stripping fallback with direct prefix/suffix cleanup.
Model IDs are now cleaned by removing vendor prefix (before /) and colon
suffix (after :), then matched exactly against pricing table.

* feat(database): add Chinese AI model pricing data

Add pricing for domestic AI models (CNY/1M tokens):
- Doubao-Seed-Code (ByteDance)
- DeepSeek V3/V3.1/V3.2
- Kimi K2/K2-Thinking/K2-Turbo (Moonshot)
- MiniMax M2/M2.1/M2.1-Lightning
- GLM-4.6/4.7 (Zhipu)
- Mimo V2 Flash (Xiaomi)

Also fix test case to use correct model ID and remove invalid currency column.

* refactor(proxy): improve header forwarding with blacklist approach

Change from whitelist to blacklist mode for request header forwarding.
Only skip headers that will be overridden (auth, host, content-length).
This preserves client's original headers and improves compatibility.

* fix(proxy): bypass timeout and retry configs when failover is disabled

When auto_failover_enabled is false, timeout and retry configurations
should not affect normal request flow. This change ensures:

- create_forwarder: passes 0 for all timeout/retry params when failover
  is disabled, effectively bypassing these checks
- streaming_timeout_config: returns 0 for both first_byte_timeout and
  idle_timeout when failover is disabled

This prevents unnecessary timeout errors and retry attempts when users
have explicitly disabled the failover feature.

* fix(proxy): handle zero value input in failover config fields

* refactor(proxy): remove retry logic and add enabled check for failover

* refactor(proxy): distinguish circuit-open from no-provider errors

* Align usage stats to sliding windows

* feat(proxy): add body and header filtering for upstream requests

* feat(proxy): enable transparent passthrough for headers

- Passthrough anthropic-beta header as-is from client
- Passthrough anthropic-version header from client
- Passthrough client IP headers (x-forwarded-for, x-real-ip) by default
- Filter private params (underscore-prefixed fields) from request body
- No database changes required

* feat(proxy): extract session ID from client requests for logging

- Add SessionIdExtractor to parse session ID from Claude/Codex requests
- Support extraction from metadata.user_id, headers, previous_response_id
- Pass session_id through RequestContext to usage logger
- Enable request correlation by session in proxy_request_logs
2025-12-31 22:57:00 +08:00
w0x7ce a2becf0917 refactor(commands): improve terminal launch code structure and fix env vars
重构 open_provider_terminal 相关代码,提升可维护性和可读性。

主要改进:
- 将 launch_terminal_with_env 拆分为多个职责单一的小函数
  * write_claude_config: 写入配置文件
  * escape_shell_path: 转义 shell 路径
  * generate_wrapper_script: 生成包装脚本
  * launch_macos_terminal / launch_linux_terminal / launch_windows_terminal: 平台特定启动逻辑
- 使用 let Some else 提前返回模式,减少嵌套
- 修复 Gemini 环境变量名为 GEMINI_API_KEY(而非 GOOGLE_API_KEY)
- 完善临时文件清理逻辑:
  * macOS/Linux: 使用 trap EXIT 自动清理
  * Windows: 批处理文件自删除
- 代码格式化和 import 排序优化

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-31 09:17:36 +08:00
Kjasn d0431b66ae fix wrong skill repo branch (#505)
Co-authored-by: yrs <yuruosheng@17paipai.cn>
2025-12-30 15:38:19 +08:00
Jason eaddcbedd7 chore: bump version to 3.9.0-3 2025-12-30 09:03:53 +08:00
Jason 83a5597756 fix: resolve test failures and clippy warnings
- tests/App.test.tsx: remove outdated SettingsPage mock, use dynamic import
- database/tests.rs: remove unused field, use struct init syntax
- deeplink/tests.rs: use idiomatic assert!() instead of assert_eq!(true)
- support.rs: add #[allow(dead_code)] for test utilities
- usage_stats.rs: code formatting
2025-12-30 08:54:48 +08:00
tianrking debe4232bc Update misc.rs 2025-12-29 23:47:30 +08:00
Dex Miller bcfc22514c fix: use local timezone and robust DST handling in usage stats (#500)
- Change from UTC to local timezone for daily/hourly trends
- Use SQLite 'localtime' modifier for date grouping
- Replace single().unwrap() with earliest().unwrap_or_else()
  to handle DST transition edge cases gracefully
2025-12-29 23:46:26 +08:00
Jason 443e23c77e fix(windows): wrap npx/npm commands with cmd /c for MCP export
On Windows, npx, npm, yarn, pnpm, node, bun, and deno are actually
.cmd batch files that require cmd /c wrapper to execute properly.
This fixes the Claude Code /doctor warning:
"Windows requires 'cmd /c' wrapper to execute npx"

The transformation is applied when exporting MCP config to ~/.claude.json:
- Before: {"command": "npx", "args": ["-y", "foo"]}
- After:  {"command": "cmd", "args": ["/c", "npx", "-y", "foo"]}

Uses conditional compilation (#[cfg(windows)]) for zero overhead on
other platforms.

Closes #453
2025-12-29 23:20:32 +08:00
Jason f26a01137d fix(windows): prevent terminal windows from appearing during version check
On Windows, opening the Settings > About section would spawn three
terminal windows when checking CLI tool versions (claude, codex, gemini).

Root cause:
- scan_cli_version() directly executed .cmd files, but child processes
  (node.exe) spawned by these batch scripts didn't inherit CREATE_NO_WINDOW
- PATH separator used Unix-style ":" instead of Windows ";"

Fix:
- Wrap command execution with `cmd /C` to ensure all child processes
  run within the same hidden console session
- Use platform-specific PATH separators via conditional compilation
2025-12-29 22:28:59 +08:00
Jason 1be9c56ec5 fix(ui): resolve Dialog/Modal not opening on first click
Two bugs were caused by ref synchronization race condition in commit 7d495aa:
- EditProviderDialog: provider prop was null on first render
- UsageScriptModal: conditional render guard was false on first click

Root cause: useEffect updates ref asynchronously, but render happens before
effect runs. On first click, ref is still null causing components to fail.

Solution: Create useLastValidValue hook that updates ref synchronously during
render phase instead of in useEffect. This ensures ref is always in sync with
state, eliminating the race condition.

Changes:
- Add useLastValidValue hook for preserving last valid value during animations
- Replace manual ref + useEffect pattern with the new hook
- Remove non-null assertions (!) that were needed as workaround
2025-12-29 22:14:34 +08:00
Dex Miller 2651b65b10 fix(schema): add missing base columns migration for proxy_config (#492)
* fix(schema): add missing base columns migration for proxy_config

Add compatibility migration for older databases that may be missing
the basic proxy_config columns (proxy_enabled, listen_address,
listen_port, enable_logging) before adding newer timeout fields.

* fix: add proxy_config base column patches for v3.9.0-2 upgrade

Add base config column patches in create_tables_on_conn():
- proxy_enabled
- listen_address
- listen_port
- enable_logging

Ensures v3.9.0-2 users (user_version=2 but missing columns)
can properly upgrade with all required fields added.

* fix: migrate proxy_config singleton to per-app on startup for v2 databases

Add startup migration for legacy proxy_config tables that still have
singleton structure (no app_type column) even with user_version=2.

This fixes the issue where v3.9.0-2 databases with v2 schema but legacy
proxy_config structure would fail with "no such column: app_type" error.

- Call migrate_proxy_config_to_per_app in create_tables_on_conn
- Add regression test to verify the fix

* style: cargo fmt

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-12-29 17:25:25 +08:00
w0x7ce 49a2e52b20 fix(ui): only show terminal button for Claude Code providers
open_provider_terminal 功能仅支持 Claude Code,因此只在 Claude 应用中显示终端按钮。

修改内容:
- 在 ProviderList 组件调用时,根据 activeApp 条件传递 onOpenTerminal
- 仅当 activeApp === "claude" 时传递 handleOpenTerminal 回调
- Codex 和 Gemini 不会显示终端按钮(onOpenTerminal 为 undefined)

影响范围:
- src/App.tsx

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-29 10:05:52 +08:00
w0x7ce ed9d9b5436 fix(commands): add auto-cleanup for temp config files when terminal closes
在 open_provider_terminal 功能中添加了临时配置文件的自动清理逻辑,
确保在用户关闭终端窗口时自动删除创建的临时配置文件。

修改内容:
- Linux: 使用 bash -c 嵌套包装脚本,通过 trap EXIT 信号在 shell 退出时清理配置文件
- macOS: 同样使用嵌套 bash + trap 机制来处理清理
- Windows: 保持原有的批处理文件自删除逻辑(del 命令)

技术细节:
- 之前使用 sh -c "...; exec $SHELL" 会导致 trap 失效
- 现在使用 bash -c 'trap ... EXIT; ...; exec bash --norc --noprofile'
- exec 会替换进程但保留 trap 信号处理器
- 当用户关闭终端时,EXIT 信号触发清理操作

影响范围:
- src-tauri/src/commands/misc.rs (launch_terminal_with_env 函数)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-29 09:36:55 +08:00
w0x7ce 390839a8d5 Merge upstream/main into main
Resolved conflicts in src-tauri/src/lib.rs:
- Kept both: open_provider_terminal (my feature) and universal provider commands (upstream)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-29 09:14:42 +08:00
TinsFox 7fdaeacb5b feat: open settings via command comma (#436) 2025-12-28 22:52:51 +08:00
TinsFox 9716eb797f chore: 更新 vite 版本 && 使用 code-inspector-plugin 方便从前端定位到代码位置 (#430)
* chore: 更新 vite 版本 && 使用 code-inspector-plugin 方便从前端定位到代码位置

* fix: update tailwind config path and conditionally load code-inspector-plugin

- Update components.json to reference tailwind.config.cjs instead of deleted tailwind.config.js
- Load codeInspectorPlugin only in dev mode to avoid unnecessary code in production builds

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-12-28 21:34:41 +08:00
lif 91deaf094e fix: 移除已废弃的 sync_enabled_to_codex 调用 (#460)
* fix(mcp): 移除同步Codex Provider时的旧MCP同步调用

sync_enabled_to_codex使用旧的config.mcp.codex结构,
在v3.7.0统一结构中该字段为空,导致MCP配置被错误清除。
MCP同步应通过McpService进行。

Fixes #403

* test(mcp): update test to reflect new MCP sync architecture

Remove MCP-related assertions from sync_codex_provider_writes_auth_and_config
test since provider switching no longer triggers MCP sync in v3.7.0+.

MCP synchronization is now handled independently by McpService,
not as part of the provider switch flow.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-12-27 21:12:07 +08:00
lif 3a548152a9 fix: MCP同步时优雅处理无效的Codex config.toml (#461)
* fix(mcp): 移除同步Codex Provider时的旧MCP同步调用

sync_enabled_to_codex使用旧的config.mcp.codex结构,
在v3.7.0统一结构中该字段为空,导致MCP配置被错误清除。
MCP同步应通过McpService进行。

Fixes #403

* fix(mcp): 优雅处理Codex配置文件解析失败的情况

当~/.codex/config.toml存在但内容无效时,MCP同步操作会失败,
导致后续provider切换等操作也失败。

修改sync_single_server_to_codex和remove_server_from_codex函数,
在配置文件解析失败时进行容错处理而不是返回错误。

Fixes #393
2025-12-27 18:05:58 +08:00
lif a8f7cda167 fix(macos): use .app bundle path for autostart to prevent terminal window (#462)
On macOS, the auto-launch library requires the .app bundle path (e.g.,
/Applications/CC Switch.app) rather than the binary path inside the bundle
(e.g., .app/Contents/MacOS/CC Switch). Using the binary path directly
causes AppleScript login items to open a terminal window.

This fix extracts the .app bundle path from current_exe() on macOS,
ensuring proper integration with macOS login items.

Closes #375
2025-12-27 16:45:17 +08:00
Calcium-Ion 8fe5c1041a feat: add Universal Provider feature (#348)
* feat: add Universal Provider feature

- Add Universal Provider data structures and type definitions
- Implement backend CRUD operations and sync functionality
- Add frontend UI components (UniversalProviderPanel, Card, FormModal)
- Add NewAPI icon and preset configuration
- Support cross-app (Claude/Codex/Gemini) configuration sync
- Add website URL field for providers
- Implement real-time refresh via event notifications
- Add i18n support (Chinese/English/Japanese)

* feat: integrate universal provider presets into add provider dialog

- Add universal provider presets (NewAPI, Custom Gateway) to preset selector
- Show universal presets with Layers icon badge in preset selector
- Open UniversalProviderFormModal when universal preset is clicked
- Pass initialPreset to auto-fill form when opened from add dialog
- Add i18n keys for addSuccess/addFailed messages
- Keep separate universal provider panel for management

* refactor: move universal provider management to add dialog

- Remove Layers button from main navigation header
- Add 'Manage' button next to universal provider presets
- Open UniversalProviderPanel from within add provider dialog
- Add i18n keys for 'manage' in all locales

* style: display universal provider presets on separate line

- Move universal provider section to a new row with border separator
- Add label '统一供应商:' to clarify the section

* style: unify universal provider label style with preset label

- Use FormLabel component for consistent styling
- Add background to 'Manage' button matching preset buttons
- Update icon size and button padding for consistency

* feat: add sync functionality and JSON preview for Universal Provider

* fix: add missing in_failover_queue field to Provider structs

After rebasing to main, the Provider struct gained a new
`in_failover_queue` field. This fix adds the missing field
to the three to_*_provider() methods in UniversalProvider.

* refactor: redesign AddProviderDialog with tab-based layout

- Add tabs to separate app-specific providers and universal providers
- Move "Add Universal Provider" button from panel header to footer
- Remove unused handleAdd callback and clean up imports
- Update emptyHint i18n text to reference the footer button

* fix: append /v1 suffix to Codex base_url in Universal Provider

Codex uses OpenAI-compatible API which requires the /v1 endpoint suffix.
The Universal Provider now automatically appends /v1 to base_url when
generating Codex provider config if not already present.

- Handle trailing slashes to avoid double slashes
- Apply fix to both backend (to_codex_provider) and frontend preview

* feat: auto-sync universal provider to apps on creation

Previously, users had to manually click sync after adding a universal
provider. Now it automatically syncs to Claude/Codex/Gemini on creation,
providing a smoother user experience.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-12-26 22:47:24 +08:00
Jason a24753f074 fix(i18n): add missing translations for reasoning model and OpenRouter compat mode
Add missing i18n keys introduced in commit e6f18ba:
- providerForm.anthropicReasoningModel
- providerForm.reasoningModelPlaceholder
- providerForm.openrouterCompatMode
- providerForm.openrouterCompatModeHint
- proxy.failover.proxyRequired
2025-12-26 00:01:59 +08:00
Jason 079ee687a8 docs: make sponsor logos clickable and update sponsor list
- Make all sponsor logos clickable links in README files (EN/ZH/JA)
- Replace ShanDianShuo with DMXAPI sponsor
- Add DMXAPI logo images (dmx-en.jpg, dmx-zh.jpeg)
- Unify sponsor list across all language versions
2025-12-25 23:30:45 +08:00
Jason e08c67b88f chore: update GLM partner banner images 2025-12-25 22:53:32 +08:00
Jason bb2756d0fb fix(ui): improve dark mode text contrast for form labels
Replace hardcoded Tailwind color classes with design system CSS variables
to improve text visibility in dark mode:

- text-gray-900 dark:text-gray-100 → text-foreground
- text-gray-500/600 dark:text-gray-400 → text-muted-foreground
- bg-white dark:bg-gray-800 → bg-background
2025-12-25 17:37:11 +08:00
Weiyi Xu c87bb43aaa feat: add xiaomi mimo icon and claude provider configuration (#470) 2025-12-25 17:30:29 +08:00
Jason 8f58c08d0d fix(database): add backward compatibility check for proxy_config seed insert
Add has_column check before inserting seed data into proxy_config table.
This prevents SQL errors when upgrading from older databases where
proxy_config was a singleton table without the app_type column.

The migration function will handle the table structure upgrade and
insert the three rows after converting to the new schema.
2025-12-25 16:04:34 +08:00
YoVinchen e6f18ba801 Feat/usage model extraction (#455)
* feat(proxy): extract model name from API response for accurate usage tracking

- Add model field extraction in TokenUsage parsing for Claude, OpenAI, and Codex
- Prioritize response model over request model in usage logging
- Update model extractors to use parsed usage.model first
- Add tests for model extraction in stream and non-stream responses

* feat(proxy): implement streaming timeout control with validation

- Add first byte timeout (0 or 1-180s) for streaming requests
- Add idle timeout (0 or 60-600s) for streaming data gaps
- Add non-streaming timeout (0 or 60-1800s) for total request
- Implement timeout logic in response processor
- Add 1800s global timeout fallback when disabled
- Add database schema migration for timeout fields
- Add i18n translations for timeout settings

* feat(proxy): add model mapping module for provider-based model substitution

- Add model_mapper.rs with ModelMapping struct to extract model configs from Provider
- Support ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL, and default models for haiku/sonnet/opus
- Implement thinking mode detection for reasoning model priority
- Include comprehensive unit tests for all mapping scenarios

* fix(proxy): bypass circuit breaker for single provider scenario

When failover is disabled (single provider), circuit breaker open state
would block all requests causing poor UX. Now bypasses circuit breaker
check in this scenario. Also integrates model mapping into request flow.

* feat(ui): add reasoning model field to Claude provider form

Add ANTHROPIC_REASONING_MODEL configuration field for Claude providers,
allowing users to specify a dedicated model for thinking/reasoning tasks.

* feat(proxy): add openrouter_compat_mode for optional format conversion

Add configurable OpenRouter compatibility mode that enables Anthropic to
OpenAI format conversion. When enabled, rewrites endpoint to /v1/chat/completions
and transforms request/response formats. Defaults to enabled for OpenRouter.

* feat(ui): add OpenRouter compatibility mode toggle

Add UI toggle for OpenRouter providers to enable/disable compatibility
mode which uses OpenAI Chat Completions format with SSE conversion.

* feat(stream-check): use provider-configured model for health checks

Extract model from provider's settings_config (ANTHROPIC_MODEL, GEMINI_MODEL,
or Codex config.toml) instead of always using default test models.

* refactor(ui): remove timeout settings from AutoFailoverConfigPanel

Remove streaming/non-streaming timeout configuration from failover panel
as these settings have been moved to a dedicated location.

* refactor(database): migrate proxy_config to per-app three-row structure

Replace singleton proxy_config table with app_type primary key structure,
allowing independent proxy settings for Claude, Codex, and Gemini.
Add GlobalProxyConfig queries and per-app config management in DAO layer.

* feat(proxy): add GlobalProxyConfig and AppProxyConfig types

Add new type definitions for the refactored proxy configuration:
- GlobalProxyConfig: shared settings (enabled, address, port, logging)
- AppProxyConfig: per-app settings (failover, timeouts, circuit breaker)

* refactor(proxy): update service layer for per-app config structure

Adapt proxy service, handler context, and provider router to use
the new per-app configuration model. Read enabled/timeout settings
from proxy_config table instead of settings table.

* feat(commands): add global and per-app proxy config commands

Add new Tauri commands for the refactored proxy configuration:
- get_global_proxy_config / update_global_proxy_config
- get_proxy_config_for_app / update_proxy_config_for_app
Update startup restore logic to read from proxy_config table.

* feat(api): add frontend API and Query hooks for proxy config

Add TypeScript wrappers and TanStack Query hooks for:
- Global proxy config (address, port, logging)
- Per-app proxy config (failover, timeouts, circuit breaker)
- Proxy takeover status management

* refactor(ui): redesign proxy panel with inline config controls

Replace ProxySettingsDialog with inline controls in ProxyPanel.
Add per-app takeover switches and global address/port settings.
Simplify AutoFailoverConfigPanel by removing timeout settings.

* feat(i18n): add proxy takeover translations and update types

Add i18n strings for proxy takeover status in zh/en/ja.
Update TypeScript types for GlobalProxyConfig and AppProxyConfig.

* refactor(proxy): load circuit breaker config per-app instead of globally

Extract app_type from router key and read circuit breaker settings
from the corresponding proxy_config row for each application.
2025-12-25 10:40:11 +08:00
w0x7ce 3dcbe313be feat: add provider-specific terminal button
Add a terminal button next to each provider card that opens a new terminal
window with that provider's specific API configuration. This allows using
different providers independently without changing the global setting.

Changes:
- Backend: Add `open_provider_terminal` command that extracts provider
  config and creates a temporary claude settings file
- Frontend: Add terminal button to provider cards with proper callback
  propagation through component hierarchy
- Support macOS (Terminal.app), Linux (gnome-terminal, konsole, etc.),
  and Windows (cmd)

Each provider gets a unique config file named `claude_<providerId>_<pid>.json`
in the temp directory, containing the provider's API configuration.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 17:11:20 +08:00
Jason 7d495aa772 feat(ui): add exit animation to FullScreenPanel dialogs
- Wrap FullScreenPanel content with AnimatePresence for exit animation
- Add exit={{ opacity: 0 }} to enable fade-out on close
- Use useRef + useEffect to preserve provider data during exit animation
- Follow React best practices by updating refs in useEffect instead of render

Affected components:
- EditProviderDialog
- UsageScriptModal
- AddProviderDialog
- McpFormModal
- ProxySettingsDialog
2025-12-23 16:18:28 +08:00
Jason db8180aa31 fix(ui): reduce header spacing and fix layout shift on view switch
- Change right-side button container from min-h-[40px] to h-[32px]
  for more compact header layout
- Remove conditional padding (pt-6/pt-4) from main content area
  to eliminate layout jump during view transitions
2025-12-23 16:18:28 +08:00
YoVinchen 1586451862 Feat/auto failover switch (#440)
* feat(failover): add auto-failover master switch with proxy integration

- Add persistent auto_failover_enabled setting in database
- Add get/set_auto_failover_enabled commands
- Provider router respects master switch state
- Proxy shutdown automatically disables failover
- Enabling failover auto-starts proxy server
- Optimistic updates for failover queue toggle

* feat(proxy): persist proxy takeover state across app restarts

- Add proxy_takeover_{app_type} settings for per-app state tracking
- Restore proxy takeover state automatically on app startup
- Preserve state on normal exit, clear on manual stop
- Add stop_with_restore_keep_state method for graceful shutdown

* fix(proxy): set takeover state for all apps in start_with_takeover

* fix(windows): hide console window when checking CLI versions

Add CREATE_NO_WINDOW flag to prevent command prompt from flashing
when detecting claude/codex/gemini CLI versions on Windows.

* refactor(failover): make auto-failover toggle per-app independent

- Change setting key from 'auto_failover_enabled' to 'auto_failover_enabled_{app_type}'
- Update provider_router to check per-app failover setting
- When failover disabled, use current provider only; when enabled, use queue order
- Add unit tests for failover enabled/disabled behavior

* feat(failover): auto-switch to higher priority provider on recovery

- After circuit breaker reset, check if recovered provider has higher priority
- Automatically switch back if queue_order is lower (higher priority)
- Stream health check now resets circuit breaker on success/degraded

* chore: remove unused start_proxy_with_takeover command

- Remove command registration from lib.rs
- Add comment clarifying failover queue is preserved on proxy stop

* feat(ui): integrate failover controls into provider cards

- Add failover toggle button to provider card actions
- Show priority badge (P1, P2, ...) for queued providers
- Highlight active provider with green border in failover mode
- Sync drag-drop order with failover queue
- Move per-app failover toggle to FailoverQueueManager
- Simplify SettingsPage failover section

* test(providers): add mocks for failover hooks in ProviderList tests

* refactor(failover): merge failover_queue table into providers

- Add in_failover_queue field to providers table
- Remove standalone failover_queue table and related indexes
- Simplify queue ordering by reusing sort_index field
- Remove reorder_failover_queue and set_failover_item_enabled commands
- Update frontend to use simplified FailoverQueueItem type

* fix(database): ensure in_failover_queue column exists for v2 databases

Add column check in create_tables to handle existing v2 databases
that were created before the failover queue refactor.

* fix(ui): differentiate active provider border color by proxy mode

- Use green border/gradient when proxy takeover is active
- Use blue border/gradient in normal mode (no proxy)
- Improves visual distinction between proxy and non-proxy states

* fix(database): clear provider health record when removing from failover queue

When a provider is removed from the failover queue, its health monitoring
is no longer needed. This change ensures the health record is also deleted
from the database to prevent stale data.

* fix(failover): improve cache cleanup for provider health and circuit breaker

- Use removeQueries instead of invalidateQueries when stopping proxy to
  completely clear health and circuit breaker caches
- Clear provider health and circuit breaker caches when removing from
  failover queue
- Refresh failover queue after drag-sort since queue order depends on
  sort_index
- Only show health badge when provider is in failover queue

* style: apply prettier formatting to App.tsx and ProviderList.tsx

* fix(proxy): handle missing health records and clear health on proxy stop

- Return default healthy state when provider health record not found
- Add clear_provider_health_for_app to clear health for specific app
- Clear app health records when stopping proxy takeover

* fix(proxy): track actual provider used in forwarding for accurate logging

Introduce ForwardResult and ForwardError structs to return the actual
provider that handled the request. This ensures usage statistics and
error logs reflect the correct provider after failover.
2025-12-23 12:37:36 +08:00
Jason bf570b6d2a fix(ui): prevent header layout shift when switching views
Add min-height to right-side button container and ml-auto to add buttons
in MCP/Prompts views to maintain consistent header height and button
position across all views.
2025-12-22 16:54:49 +08:00
Jason 26c3f05daf feat(ui): add fade transition for view and panel switching
Add smooth fade animations when navigating between views (Settings,
MCP, Skills, Prompts) and opening full-screen panels (Add/Edit Provider).
2025-12-22 16:54:49 +08:00
TinsFox d303706d51 feat: add provider search filter (#435)
* feat: add provider search filter

* feat: add provider search overlay
2025-12-22 15:46:11 +08:00
TinsFox 97495d1550 Remove macOS titlebar tint and align custom header (#438) 2025-12-22 15:43:28 +08:00
YoVinchen a1537807eb feat(failover): add auto-failover master switch with proxy integration (#427)
* feat(failover): add auto-failover master switch with proxy integration

- Add persistent auto_failover_enabled setting in database
- Add get/set_auto_failover_enabled commands
- Provider router respects master switch state
- Proxy shutdown automatically disables failover
- Enabling failover auto-starts proxy server
- Optimistic updates for failover queue toggle

* feat(proxy): persist proxy takeover state across app restarts

- Add proxy_takeover_{app_type} settings for per-app state tracking
- Restore proxy takeover state automatically on app startup
- Preserve state on normal exit, clear on manual stop
- Add stop_with_restore_keep_state method for graceful shutdown

* fix(proxy): set takeover state for all apps in start_with_takeover
2025-12-21 22:39:50 +08:00
TinsFox f047960a33 Use macOS tray template icon (#434) 2025-12-21 21:10:00 +08:00
TinsFox ace9b38cee style: remove shadow (#431) 2025-12-21 21:09:37 +08:00
Jason b67cdbb18c feat(ui): add fade transition for app switching
- Add AnimatePresence + motion.div wrapper for provider list
- Use key={activeApp} to trigger enter/exit animations on app switch
- Remove redundant animate-slide-up from ProviderList to prevent
  animation conflicts and visual jitter
2025-12-21 10:27:56 +08:00
Jason c4f1e90893 style(settings): unify tab transition animations
Add framer-motion fade-in and slide-up animations to General and
Advanced tabs, matching the existing Usage and About tab animations.
2025-12-21 09:29:14 +08:00
Jason ddbff070d5 feat(settings): add option to skip Claude Code first-run confirmation
Add a new setting to automatically skip Claude Code's onboarding screen
by writing hasCompletedOnboarding=true to ~/.claude.json. The setting
defaults to enabled for better user experience.

- Add set/clear_has_completed_onboarding functions in claude_mcp.rs
- Add Tauri commands and frontend API integration
- Add toggle in WindowSettings with i18n support (en/zh/ja)
- Fix hardcoded Chinese text in tests to use i18n keys
2025-12-20 23:55:10 +08:00
Jason ca7cb398c2 i18n: complete usage panel and settings internationalization
- Add missing i18n keys for usage statistics panel (trends, cost, perMillion, etc.)
- Add i18n keys for settings advanced section (configDir, proxy, modelTest, etc.)
- Add streamCheck i18n keys for health check configuration
- Remove hardcoded Chinese fallback values from t() calls
- Add common keys (all, search, reset, actions, deleting)
2025-12-20 22:29:39 +08:00
Jason 2fb3b5405a i18n: complete internationalization for v3.8+ features
- Add health status translations (operational, degraded, failed, circuitOpen)
- Add proxy panel translations (serviceAddress, stats, stopped state)
- Add usage filter translations (appType, statusCode, searchPlaceholder)
- Add providerIcon click hints (clickToChange, clickToSelect)
- Add config load error translations for main.tsx
- Complete Japanese proxy section (failoverQueue, autoFailover)
- Fix date/time locale in usage charts and tables
- Use t() function in all hardcoded UI strings
2025-12-20 21:38:37 +08:00
Jason ec649e7718 style(switch): improve dark mode appearance
- Track (unchecked): lighten in light mode (gray-300 → gray-200),
  darken in dark mode (gray-700 → gray-900) to blend with background
- Thumb: soften in dark mode (white → gray-400) to reduce glare
2025-12-20 19:18:29 +08:00
Jason 3da5525c79 fix(ui): improve text visibility in dark mode
Replace hardcoded gray color classes with semantic color classes
to fix poor text contrast in dark mode:

- MCP panel: server names, descriptions, tags, app labels
- Prompt panel: prompt names, descriptions, empty states
- Usage footer: timestamps, refresh buttons
- Update badge: close button icon
- API key input: disabled state text
- Env warning banner: source info text

Changes:
- `text-gray-400 dark:text-gray-500` → `text-muted-foreground`
  (fixes reversed dark mode logic)
- `text-gray-500 dark:text-gray-400` → `text-muted-foreground`
- `bg-gray-100 dark:bg-gray-800` → `bg-muted`
2025-12-20 18:57:36 +08:00
Jason 44ca688253 chore: bump version to 3.9.0-2 for second test release
- Update version in package.json, Cargo.toml, tauri.conf.json
- Fix clippy too_many_arguments warning in forwarder.rs
2025-12-20 18:10:45 +08:00
Jason 5fe5ed98be style(header): unify height and styling of header toolbar sections
- Use consistent h-8 fixed height for all inner elements
- Standardize border-radius to rounded-xl across all sections
- Remove background from ProxyToggle for cleaner appearance
- Simplify ProxyToggle structure with nested container
2025-12-20 16:24:25 +08:00
Jason b2a9e91d70 feat(providers): add DMXAPI as official partner
Mark DMXAPI as partner in both Claude and Codex presets with promotion
message for their Claude Code exclusive model 66% OFF offer.
2025-12-20 13:23:15 +08:00
Jason 4a1a997935 feat(icons): add provider icons for OpenRouter, LongCat, ModelScope, AiHubMix
- Add SVG icons for OpenRouter, LongCat, ModelScope, and AiHubMix
- Register icons in index.ts and metadata.ts with search keywords
- Link icons to corresponding provider presets in claudeProviderPresets.ts
2025-12-20 12:43:59 +08:00
Jason c4535c894a refactor(proxy): switch OpenRouter to passthrough mode for native Claude API
OpenRouter now supports Claude Code compatible endpoint (/v1/messages),
eliminating the need for Anthropic ↔ OpenAI format conversion.

- Disable format transformation for OpenRouter (keep old logic as fallback)
- Pass through original endpoint instead of redirecting to /v1/chat/completions
- Add anthropic-version header for ClaudeAuth and Bearer strategies
- Update tests to reflect new passthrough behavior
2025-12-20 12:13:39 +08:00
Jason 64e0cabaa7 fix(window): add minWidth/minHeight to Windows platform config
Tauri 2.0 platform config merging is shallow, not deep. The Windows
config only specified titleBarStyle, causing minWidth/minHeight to
be missing on Windows. This allowed users to resize the window below
900px, causing header elements to misalign.
2025-12-20 11:19:26 +08:00
Jason 8ecb41d25e fix(proxy): respect existing token field when syncing Claude config
- Add support for ANTHROPIC_API_KEY in Claude auth extraction
- Only update existing token fields during sync, avoid adding fields
  that weren't originally configured by the user
- Add tests for both scenarios
2025-12-20 11:04:07 +08:00
Jason 3e8f84481d fix(proxy): add fallback recovery for orphaned takeover state
- Detect takeover residue in Live configs even when proxy is not running
- Implement 3-tier fallback: backup → SSOT → cleanup placeholders
- Only delete backup after successful restore to prevent data loss
- Fix EditProviderDialog to check current app's takeover status only
2025-12-20 10:07:04 +08:00
Jason ba59483b33 refactor(proxy): remove global auto-start flag
- Remove global proxy auto-start flag from config and UI.
- Simplify per-app takeover start/stop and stop server when the last takeover is disabled.
- Restore live takeover detection used for crash recovery.
- Keep proxy_config.enabled column but always write 0 for compatibility.
- Tests: not run (not requested).
2025-12-20 08:48:59 +08:00
Jason b6ff721d67 fix(import): refresh all providers immediately after SQL import
- Remove setTimeout delay that could be cancelled on component unmount
- Invalidate all providers cache (not just current app) since import affects all apps
- Call onImportSuccess before sync to ensure UI refresh even if sync fails
- Update i18n: "Data refreshed" (past tense, reflecting immediate action)
2025-12-19 20:48:15 +08:00
Jason 1706c9a26f fix(backup): restrict SQL import to CC Switch exported backups only
- Add validation to reject SQL files without CC Switch export header
- Remove redundant sanitize_import_sql (sqlite_* objects already excluded at export time)
- Fix backup filename collision by appending counter suffix
- Update i18n hints to clarify import restriction
2025-12-19 20:48:15 +08:00
YoVinchen 5bce6d6020 Fix/about section UI (#419)
* 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

* 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 20:40:11 +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
286 changed files with 39043 additions and 2961 deletions
+16 -3
View File
@@ -53,7 +53,11 @@ jobs:
wget \
file \
patchelf \
libssl-dev
libssl-dev \
rpm \
flatpak \
flatpak-builder \
elfutils
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev \
@@ -153,7 +157,7 @@ jobs:
- name: Build Tauri App (Linux)
if: runner.os == 'Linux'
run: pnpm tauri build
run: pnpm tauri build --bundles appimage,deb,rpm
- name: Prepare macOS Assets
if: runner.os == 'macOS'
@@ -271,6 +275,15 @@ jobs:
else
echo "No .deb found (optional)"
fi
# 额外上传 .rpm(用于 Fedora/RHEL/openSUSE 等,不参与 Updater
RPM=$(find src-tauri/target/release/bundle -name "*.rpm" | head -1 || true)
if [ -n "$RPM" ]; then
NEW_RPM="CC-Switch-${VERSION}-Linux.rpm"
cp "$RPM" "release-assets/$NEW_RPM"
echo "RPM package copied: $NEW_RPM"
else
echo "No .rpm found (optional)"
fi
- name: List prepared assets
shell: bash
@@ -299,7 +312,7 @@ jobs:
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`Homebrew
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`Debian/Ubuntu
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`Debian/Ubuntu或 `CC-Switch-${{ github.ref_name }}-Linux.rpm`Fedora/RHEL/openSUSE
---
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
+6
View File
@@ -17,3 +17,9 @@ GEMINI.md
/.idea
/.vscode
vitest-report.json
nul
# Flatpak build artifacts
flatpak/cc-switch.deb
flatpak-build/
flatpak-repo/
+1 -1
View File
@@ -1 +1 @@
v22.4.1
22.12.0
+267 -39
View File
@@ -5,6 +5,271 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
---
## [3.9.1] - 2026-01-09
### Bug Fix Release
This release focuses on stability improvements and crash prevention.
### Added
- **Crash Logging** - Panic hook captures crash info to `~/.cc-switch/crash.log` with full stack traces (#562)
- **Release Logging** - Enable logging for release builds with automatic rotation (keeps 2 most recent files)
- **AIGoCode Icon** - Added colored icon for AIGoCode provider preset
### Fixed
- **Proxy Panic Prevention** - Graceful degradation when HTTP client initialization fails due to invalid proxy settings; falls back to no_proxy mode (#560)
- **UTF-8 Safety** - Fix potential panic when masking API keys or truncating logs containing multi-byte characters (Chinese, emoji, etc.) (#560)
- **Default Proxy Port** - Change default port from 5000 to 15721 to avoid conflict with macOS AirPlay Receiver (#560)
- **Windows Title** - Display "CC Switch" instead of default "Tauri app" in window title
- **Windows/Linux Spacing** - Remove extra 28px blank space below native titlebar introduced in v3.9.0
- **Flatpak Tray Icon** - Bundle libayatana-appindicator for tray icon support on Flatpak (#556)
- **Provider Preset** - Correct casing from "AiGoCode" to "AIGoCode" to match official branding
---
## [3.9.0] - 2026-01-07
### Stable Release
This stable release includes all changes from `3.9.0-1`, `3.9.0-2`, and `3.9.0-3`.
### Added
- **Local API Proxy** - High-performance local HTTP proxy for Claude Code, Codex, and Gemini CLI (Axum-based)
- **Per-App Takeover** - Independently route each app through the proxy with automatic live-config backup/redirect
- **Auto Failover** - Circuit breaker + smart failover with independent queues and health tracking per app
- **Universal Provider** - Shared provider configurations that can sync to Claude/Codex/Gemini (ideal for API gateways like NewAPI)
- **Provider Search Filter** - Quick filter to find providers by name (#435)
- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436)
- **Deeplink Usage Config** - Import usage query config via deeplink (#400)
- **Provider Icon Colors** - Customize provider icon colors (#385)
- **Skills Multi-App Support** - Skills now support both Claude Code and Codex (#365)
- **Closable Toasts** - Close button for switch toast and all success toasts (#350)
- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog
- **MCP Import** - Import MCP servers from installed apps
- **Common Config Snippet Extraction** - Extract reusable common config snippets from the current provider or editor content (Claude/Codex/Gemini)
- **Usage Enhancements** - Model extraction, request logging improvements, cache hit/creation metrics, and auto-refresh (#455, #508)
- **Error Request Logging** - Detailed logging for proxy requests (#401)
- **Linux Packaging** - Added RPM and Flatpak packaging targets
- **Provider Presets & Icons** - Added/updated partner presets and icons (e.g., MiMo, DMXAPI, Cubence)
### Changed
- **Usage Terminology** - Rename "Cache Read/Write" to "Cache Hit/Creation" across all languages (#508)
- **Model Pricing Data** - Refresh built-in model pricing table (Claude full version IDs, GPT-5 series, Gemini ID formats, and Chinese models) (#508)
- **Proxy Header Forwarding** - Switch to a blacklist approach and improve header passthrough compatibility (#508)
- **Failover Behavior** - Bypass timeout/retry configs when failover is disabled; update default failover timeout and circuit breaker values (#508, #521)
- **Provider Presets** - Update default model versions and change the default Qwen base URL (#517)
- **Skills Management** - Unify Skills management architecture with SSOT + React Query; improve caching for discoverable skills
- **Settings UX** - Reorder items in the Advanced tab for better discoverability
- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active
### Fixed
- **Security** - Security fixes for JavaScript executor and usage script (#151)
- **Usage Timezone & Parsing** - Fix datetime picker timezone handling; improve token parsing/billing for Gemini and Codex formats (#508)
- **Windows Compatibility** - Improve MCP export and version check behavior to avoid terminal popups
- **Windows Startup** - Use system titlebar to prevent black screen on startup
- **WebView Compatibility** - Add fallback for crypto.randomUUID() on older WebViews
- **macOS Autostart** - Use `.app` bundle path to prevent terminal window popups
- **Database** - Add missing schema migrations; show an error dialog on initialization failure with a retry option
- **Import/Export** - Restrict SQL import to CC Switch exported backups only; refresh providers immediately after import
- **Prompts** - Allow saving prompts with empty content
- **MCP Sync** - Skip sync when the target CLI app is not installed
- **Common Config (Codex)** - Preserve MCP server `base_url` during extraction and remove provider-specific `model_providers` blocks
- **Proxy** - Improve takeover detection and stability; clean up model override env vars when switching providers in takeover mode (#508)
- **Skills** - Skip hidden directories during discovery; fix wrong skill repo branch
- **Settings Navigation** - Navigate to About tab when clicking update badge
- **UI** - Fix dialogs not opening on first click and improve window dragging area in `FullScreenPanel`
---
## [3.9.0-3] - 2025-12-29
### Beta Release
Third beta release with important bug fixes for Windows compatibility, UI improvements, and new features.
### Added
- **Universal Provider** - Support for universal provider configurations (#348)
- **Provider Search Filter** - Quick filter to find providers by name (#435)
- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436)
- **Xiaomi MiMo Icon** - Added MiMo icon and Claude provider configuration (#470)
- **Usage Model Extraction** - Extract model info from usage statistics (#455)
- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog
- **Exit Animations** - Added exit animation to FullScreenPanel dialogs
- **Fade Transitions** - Smooth fade transitions for app/view/panel switching
### Fixed
#### Windows
- Wrap npx/npm commands with `cmd /c` for MCP export
- Prevent terminal windows from appearing during version check
#### macOS
- Use .app bundle path for autostart to prevent terminal window popup
#### UI
- Resolve Dialog/Modal not opening on first click (#492)
- Improve dark mode text contrast for form labels
- Reduce header spacing and fix layout shift on view switch
- Prevent header layout shift when switching views
#### Database & Schema
- Add missing base columns migration for proxy_config
- Add backward compatibility check for proxy_config seed insert
#### Other
- Use local timezone and robust DST handling in usage stats (#500)
- Remove deprecated `sync_enabled_to_codex` call
- Gracefully handle invalid Codex config.toml during MCP sync
- Add missing translations for reasoning model and OpenRouter compat mode
### Improved
- **macOS Tray** - Use macOS tray template icon
- **Header Alignment** - Remove macOS titlebar tint, align custom header
- **Shadow Removal** - Cleaner UI by removing shadow styles
- **Code Inspector** - Added code-inspector-plugin for development
- **i18n** - Complete internationalization for usage panel and settings
- **Sponsor Logos** - Made sponsor logos clickable
### Stats
- 35 commits since v3.9.0-2
- 5 files changed in test/lint fixes
---
## [3.9.0-2] - 2025-12-20
### Beta Release
Second beta release focusing on proxy stability, import safety, and provider preset polish.
### Added
- **DMXAPI Partner** - Added DMXAPI as an official partner provider preset
- **Provider Icons** - Added provider icons for OpenRouter, LongCat, ModelScope, and AiHubMix
### Changed
- **Proxy (OpenRouter)** - Switched OpenRouter to passthrough mode for native Claude API
### Fixed
- **Import/Export** - Restrict SQL import to CC Switch exported backups only; refresh providers immediately after import
- **Proxy** - Respect existing Claude token when syncing; add fallback recovery for orphaned takeover state; remove global auto-start flag
- **Windows** - Add minimum window size to Windows platform config
- **UI** - Improve About section UI (#419) and unify header toolbar styling
### Stats
- 13 commits since v3.9.0-1
---
## [3.9.0-1] - 2025-12-18
### Beta Release
This beta release introduces the **Local API Proxy** feature, along with Skills multi-app support, UI improvements, and numerous bug fixes.
### Major Features
#### Local Proxy Server
- **Local HTTP Proxy** - High-performance proxy server built on Axum framework
- **Multi-app Support** - Unified proxy for Claude Code, Codex, and Gemini CLI API requests
- **Per-app Takeover** - Independent control over which apps route through the proxy
- **Live Config Takeover** - Automatically backs up and redirects CLI configurations to local proxy
#### Auto Failover
- **Circuit Breaker** - Automatically detects provider failures and triggers protection
- **Smart Failover** - Automatically switches to backup provider when current one is unavailable
- **Health Tracking** - Real-time monitoring of provider availability
- **Independent Failover Queues** - Each app maintains its own failover queue
#### Monitoring
- **Request Logging** - Detailed logging of all proxy requests
- **Usage Statistics** - Token consumption, latency, success rate metrics
- **Real-time Status** - Frontend displays proxy status and statistics
#### Skills Multi-App Support
- **Multi-app Support** - Skills now support both Claude and Codex (#365)
- **Multi-app Migration** - Existing Skills auto-migrate to multi-app structure (#378)
- **Installation Path Fix** - Use directory basename for skill installation path (#358)
### Added
- **Provider Icon Colors** - Customize provider icon colors (#385)
- **Deeplink Usage Config** - Import usage query config via deeplink (#400)
- **Error Request Logging** - Detailed logging for proxy requests (#401)
- **Closable Toast** - Added close button to switch notification toast (#350)
- **Icon Color Component** - ProviderIcon component supports color prop (#384)
### Fixed
#### Proxy Related
- Takeover Codex base_url via model_provider
- Harden crash recovery with fallback detection
- Sync UI when active provider differs from current setting
- Resolve circuit breaker race condition and error classification
- Stabilize live takeover and provider editing
- Reset health badges when proxy stops
- Retry failover for all HTTP errors including 4xx
- Fix HalfOpen counter underflow and config field inconsistencies
- Resolve circuit breaker state persistence and HalfOpen deadlock
- Auto-recover live config after abnormal exit
- Update live backup when hot-switching provider in proxy mode
- Wait for server shutdown before exiting app
- Disable auto-start on app launch by resetting enabled flag on stop
- Sync live config tokens to database before takeover
- Resolve 404 error and auto-setup proxy targets
#### MCP Related
- Skip sync when target CLI app is not installed
- Improve upsert and import robustness
- Use browser-compatible platform detection for MCP presets
#### UI Related
- Restore fade transition for Skills button
- Add close button to all success toasts
- Prevent card jitter when health badge appears
- Update SettingsPage tab styles (#342)
#### Other
- Fix Azure website link (#407)
- Add fallback to provider config for usage credentials (#360)
- Fix Windows black screen on startup (use system titlebar)
- Add fallback for crypto.randomUUID() on older WebViews
- Use correct npm package for Codex CLI version check
- Security fixes for JavaScript executor and usage script (#151)
### Improved
- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active
- **Card Animation** - Improved provider card hover animation
- **Remove Restart Prompt** - No longer prompts restart when switching providers
### Technical
- Implement per-app takeover mode
- Proxy module contains 20+ Rust files with complete layered architecture
- Add 5 new database tables for proxy functionality
- Modularize handlers.rs to reduce code duplication
- Remove is_proxy_target in favor of failover_queue
### Stats
- 55 commits since v3.8.2
- 164 files changed
- +22,164 / -570 lines
---
## [3.8.0] - 2025-11-28
### Major Updates
@@ -403,8 +668,8 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
### ⚠ Breaking Changes
- Tauri 命令仅接受参数 `app`(取值:`claude`/`codex`);移除对 `app_type`/`appType` 的兼容。
- 前端类型命名统一为 `AppId`(移除 `AppType` 导出),变量命名统一为 `appId`
- Tauri commands only accept the `app` parameter (`claude`/`codex`); removed `app_type`/`appType` compatibility.
- Frontend types are standardized to `AppId` (removed `AppType` export); variable naming is standardized to `appId`.
### ✨ New Features
@@ -647,40 +912,3 @@ For users upgrading from v2.x (Electron version):
- Basic provider management
- Claude Code integration
- Configuration file handling
## [Unreleased]
### ⚠️ Breaking Changes
- **Runtime auto-migration from v1 to v2 config format has been removed**
- `MultiAppConfig::load()` no longer automatically migrates v1 configs
- When a v1 config is detected, the app now returns a clear error with migration instructions
- **Migration path**: Install v3.2.x to perform one-time auto-migration, OR manually edit `~/.cc-switch/config.json` to v2 format
- **Rationale**: Separates concerns (load() should be read-only), fail-fast principle, simplifies maintenance
- Related: `app_config.rs` (v1 detection improved with structural analysis), `app_config_load.rs` (comprehensive test coverage added)
- **Legacy v1 copy file migration logic has been removed**
- Removed entire `migration.rs` module (435 lines) that handled one-time migration from v3.1.0 to v3.2.0
- No longer scans/merges legacy copy files (`settings-*.json`, `auth-*.json`, `config-*.toml`)
- No longer archives copy files or performs automatic deduplication
- **Migration path**: Users upgrading from v3.1.0 must first upgrade to v3.2.x to automatically migrate their configurations
- **Benefits**: Improved startup performance (no file scanning), reduced code complexity, cleaner codebase
- **Tauri commands now only accept `app` parameter**
- Removed legacy `app_type`/`appType` compatibility paths
- Explicit error with available values when unknown `app` is provided
### 🔧 Improvements
- Unified `AppType` parsing: centralized to `FromStr` implementation, command layer no longer implements separate `parse_app()`, reducing code duplication and drift
- Localized and user-friendly error messages: returns bilingual (Chinese/English) hints for unsupported `app` values with a list of available options
- Simplified startup logic: Only ensures config structure exists, no migration overhead
### 🧪 Tests
- Added unit tests covering `AppType::from_str`: case sensitivity, whitespace trimming, unknown value error messages
- Added comprehensive config loading tests:
- `load_v1_config_returns_error_and_does_not_write`
- `load_v1_with_extra_version_still_treated_as_v1`
- `load_invalid_json_returns_parse_error_and_does_not_write`
- `load_valid_v2_config_succeeds`
+30 -17
View File
@@ -2,8 +2,7 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Version](https://img.shields.io/badge/version-3.9.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -12,33 +11,35 @@
English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md)
**From Provider Switcher to All-in-One AI CLI Management Platform**
Unified management for Claude Code, Codex & Gemini CLI provider configurations, MCP servers, Skills extensions, and system prompts.
</div>
## ❤️Sponsor
![Zhipu GLM](assets/partners/banners/glm-en.jpg)
[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB)
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
---
<table>
<tr>
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
<td>Thanks to ShanDianShuo for sponsoring this project! ShanDianShuo is a local-first AI voice input: Millisecond latency, data stays on device, 4x faster than typing, AI-powered correction, Privacy-first, completely free. Doubles your coding efficiency with Claude Code! <a href="https://www.shandianshuo.cn">Free download</a> for Mac/Win</td>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href="https://www.dmxapi.cn/register?aff=bUHu">Register here</a></td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more with flexible billing options including pay-as-you-go and monthly plans. Cubence provides special discounts for CC Switch users: register using <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">this link</a> and enter the "CCSWITCH" promo code during recharge to get 10% off every top-up!</td>
</tr>
</table>
@@ -51,7 +52,7 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
## Features
### Current Version: v3.8.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
### Current Version: v3.9.1 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
**v3.8.0 Major Update (2025-11-28)**
@@ -190,7 +191,19 @@ paru -S cc-switch-bin
### Linux Users
Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{version}-Linux.AppImage` from the [Releases](../../releases) page.
Download the latest Linux build from the [Releases](../../releases) page:
- `CC-Switch-v{version}-Linux.deb` (Debian/Ubuntu)
- `CC-Switch-v{version}-Linux.rpm` (Fedora/RHEL/openSUSE)
- `CC-Switch-v{version}-Linux.AppImage` (Universal)
- `CC-Switch-v{version}-Linux.flatpak` (Flatpak)
Flatpak install & run:
```bash
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
flatpak run com.ccswitch.desktop
```
## Quick Start
+31 -18
View File
@@ -2,43 +2,44 @@
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Version](https://img.shields.io/badge/version-3.9.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.8.0 リリースノート](docs/release-note-v3.8.0-en.md)
**プロバイダスイッチャーから AI CLI 一体型管理プラットフォームへ**
Claude Code・Codex・Gemini CLI のプロバイダ設定、MCP サーバー、Skills 拡張、システムプロンプトを統合管理。
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.9.0 リリースノート](docs/release-note-v3.9.0-ja.md)
</div>
## ❤️スポンサー
![Zhipu GLM](assets/partners/banners/glm-en.jpg)
[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB)
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。
GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。
[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
---
<table>
<tr>
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
<td>ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。<a href="https://www.shandianshuo.cn">Mac/Win 版を無料ダウンロード</a></td>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:<a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">このリンク</a>で登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります!</td>
</tr>
</table>
@@ -51,7 +52,7 @@ GLM CODING PLAN は AI コーディング向けのサブスクリプションで
## 特長
### 現在のバージョン:v3.8.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
### 現在のバージョン:v3.9.1 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
**v3.8.0 メジャーアップデート (2025-11-28)**
@@ -190,7 +191,19 @@ paru -S cc-switch-bin
### Linux ユーザー
[Releases](../../releases) から最新版の `CC-Switch-v{version}-Linux.deb` または `CC-Switch-v{version}-Linux.AppImage` をダウンロード
[Releases](../../releases) から最新版の Linux ビルドをダウンロード
- `CC-Switch-v{version}-Linux.deb`Debian/Ubuntu
- `CC-Switch-v{version}-Linux.rpm`Fedora/RHEL/openSUSE
- `CC-Switch-v{version}-Linux.AppImage`(汎用)
- `CC-Switch-v{version}-Linux.flatpak`Flatpak
Flatpak のインストールと起動:
```bash
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
flatpak run com.ccswitch.desktop
```
## クイックスタート
+32 -19
View File
@@ -2,43 +2,44 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Version](https://img.shields.io/badge/version-3.9.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md)
**从供应商切换器到 AI CLI 一体化管理平台**
统一管理 Claude Code、Codex 与 Gemini CLI 的供应商配置、MCP 服务器、Skills 扩展和系统提示词。
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.9.0 发布说明](docs/release-note-v3.9.0-zh.md)
</div>
## ❤️赞助商
![智谱 GLM](assets/partners/banners/glm-zh.jpg)
[![智谱 GLM](assets/partners/banners/glm-zh.jpg)](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!
GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。
CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
---
<table>
<tr>
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠</td>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠</td>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/sds-zh.png" alt="ShanDianShuo" width="150"></td>
<td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍,AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-zh.jpeg" alt="DMXAPI" width="150"></a></td>
<td>感谢 DMXAPI(大模型API)赞助了本项目! DMXAPI,一个Key用全球大模型。
为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!<a href="https://www.dmxapi.cn/register?aff=bUHu">点击这里注册</a></td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中继服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">此链接</a> 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠!</td>
</tr>
</table>
@@ -51,7 +52,7 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
## 功能特性
### 当前版本:v3.8.2 | [完整更新日志](CHANGELOG.md)
### 当前版本:v3.9.1 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
**v3.8.0 重大更新(2025-11-28**
@@ -190,7 +191,19 @@ paru -S cc-switch-bin
### Linux 用户
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包
从 [Releases](../../releases) 页面下载最新版本的 Linux 安装包
- `CC-Switch-v{版本号}-Linux.deb`Debian/Ubuntu
- `CC-Switch-v{版本号}-Linux.rpm`Fedora/RHEL/openSUSE
- `CC-Switch-v{版本号}-Linux.AppImage`(通用)
- `CC-Switch-v{版本号}-Linux.flatpak`Flatpak
Flatpak 安装与运行:
```bash
flatpak install --user ./CC-Switch-v{版本号}-Linux.flatpak
flatpak run com.ccswitch.desktop
```
## 快速开始
Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+1 -1
View File
@@ -4,7 +4,7 @@
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"config": "tailwind.config.cjs",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
+239 -776
View File
File diff suppressed because it is too large Load Diff
+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 配置备份
+188
View File
@@ -0,0 +1,188 @@
# CC Switch v3.9.0
> Local API Proxy, Auto Failover, Universal Provider, and a more complete multi-app workflow
**[中文版 →](release-note-v3.9.0-zh.md) | [日本語版 →](release-note-v3.9.0-ja.md)**
---
## Overview
CC Switch v3.9.0 is the stable release of the v3.9 beta series (`3.9.0-1`, `3.9.0-2`, `3.9.0-3`).
It introduces a local API proxy with per-app takeover, automatic failover, universal providers, and many stability and UX improvements across Claude Code, Codex, and Gemini CLI.
**Release Date**: 2026-01-07
---
## Highlights
- Local API Proxy for Claude Code / Codex / Gemini CLI
- Auto Failover with circuit breaker and per-app failover queues
- Universal Provider: one shared config synced across apps (ideal for API gateways like NewAPI)
- Skills improvements: multi-app support, unified management with SSOT + React Query
- Common config snippets: extract reusable snippets from the editor or the current provider
- MCP import: import MCP servers from installed apps
- Usage improvements: auto-refresh, cache hit/creation metrics, and timezone fixes
- Linux packaging: RPM and Flatpak artifacts
---
## Major Features
### Local API Proxy
- Runs a local high-performance HTTP proxy server (Axum-based)
- Supports Claude Code, Codex, and Gemini CLI with a unified proxy
- Per-app takeover: you can independently decide which app routes through the proxy
- Live config takeover: backs up and redirects the CLI live config to the local proxy when takeover is enabled
- Monitoring: request logging and usage statistics for easier debugging and cost tracking
- Error request logging: keep detailed logs for failed proxy requests to simplify debugging (#401, thanks @yovinchen)
### Auto Failover (Circuit Breaker)
- Automatically detects provider failures and triggers protection (circuit breaker)
- Automatically switches to a backup provider when the current one is unhealthy
- Tracks provider health in real time, and keeps independent failover queues per app
- When failover is disabled, timeout/retry related settings no longer affect normal request flow
### Skills Management
- Multi-app Skills support for Claude Code and Codex, with smoother migration from older skill layouts (#365, #378, thanks @yovinchen)
- Unified Skills management architecture (SSOT + React Query) for more consistent state and refresh behavior
- Better discovery UX and performance:
- Skip hidden directories during discovery
- Faster discovery with long-lived caching for discoverable skills
- Clear loading indicators and more discoverable header actions (import/refresh)
- Fix wrong skill repo branch (#505, thanks @kjasn)
### Universal Provider
- Add a shared provider configuration that can sync to Claude/Codex/Gemini (#348, thanks @Calcium-Ion)
- Designed for API gateways that support multiple protocols (e.g., NewAPI)
- Allows per-app default model mapping under a single provider
### Common Config Snippets (Claude/Codex/Gemini)
- Maintain a reusable "common config" snippet and merge/append it into providers that enable it
- New extraction workflow:
- Extract from the editor content (what you are currently editing)
- Or extract from the current active provider when the editor content is not provided
- Codex extraction is safer:
- Removes provider-specific sections like `model_provider`, `model`, and the entire `model_providers` table
- Preserves `base_url` under `[mcp_servers.*]` so MCP configs are not accidentally broken
### MCP Management
- Import MCP servers from installed apps
- Improve robustness: skip sync when the target CLI app is not installed; handle invalid Codex `config.toml` gracefully (#461, thanks @majiayu000)
- Windows compatibility: wrap npx/npm commands with `cmd /c` for MCP export
### Usage & Pricing
- Usage & pricing improvements: auto-refresh, cache hit/creation metrics, timezone handling fixes, and refreshed built-in pricing table (#508, thanks @yovinchen)
- DeepLink support: import usage query configuration via deeplink (#400, thanks @qyinter)
- Model extraction for usage statistics (#455, thanks @yovinchen)
- Usage query credentials can fall back to provider config (#360, thanks @Sirhexs)
---
## UX Improvements
- Provider search filter: quickly find providers by name (#435, thanks @TinsFox)
- Provider icon colors: customize provider icon colors for quicker visual identification (#385, thanks @yovinchen)
- Keyboard shortcut: `Cmd/Ctrl + ,` opens Settings (#436, thanks @TinsFox)
- Skip Claude Code first-run confirmation dialog (optional)
- Closable toasts: close buttons for switch toast and all success toasts (#350, thanks @ForteScarlet)
- Update badge navigation: clicking the update badge opens the About tab
- Settings page tab style improvements (#342, thanks @wenyuanw)
- Smoother transitions: fade transitions for app/view switching and exit animations for panels
- Proxy takeover active theme: apply an emerald theme while takeover is active
- Dark mode readability improvements for forms and labels
- Better window dragging area for full-screen panels (#525, thanks @zerob13)
---
## Platform Notes
### Windows
- Prevent terminal windows from appearing during version checks
- Improve window sizing defaults (minimum width/height)
- Fix black screen on startup by using the system titlebar
- Add a fallback for `crypto.randomUUID()` on older WebViews
### macOS
- Use `.app` bundle path for autostart to avoid terminal window popups (#462, thanks @majiayu000)
- Improve tray/icon behavior and header alignment
---
## Packaging
- Linux: RPM and Flatpak packaging targets are now available for building release artifacts
---
## Notes
- Security improvements for the JavaScript executor and usage script execution (#151, thanks @luojiyin1987).
- SQL import is restricted to CC Switch exported backups to reduce the risk of importing unsafe or incompatible SQL dumps.
- Proxy takeover modifies CLI live configs; CC Switch will back up the live config before redirecting it to the local proxy. If you want to revert, disable takeover/stop the proxy and restore from the backup when needed.
## Special Thanks
Special thanks to @xunyu @deijing @su-fen for their support and contributions. This release wouldn't be possible without you!
## Download & Installation
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
### System Requirements
| System | Minimum Version | Architecture |
| ------- | ------------------------------- | ----------------------------------- |
| Windows | Windows 10 or later | x64 |
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 |
### Windows
| File | Description |
| --------------------------------------- | -------------------------------------------------- |
| `CC-Switch-v3.9.0-Windows.msi` | **Recommended** - MSI installer with auto-update support |
| `CC-Switch-v3.9.0-Windows-Portable.zip` | Portable version, no installation required |
### macOS
| File | Description |
| ------------------------------- | ----------------------------------------------------------------- |
| `CC-Switch-v3.9.0-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
| `CC-Switch-v3.9.0-macOS.tar.gz` | For Homebrew installation and auto-update |
> **Note**: Since the author does not have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Close the app, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and it will open normally afterwards.
### Homebrew (MacOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
### Linux
| Distribution | Recommended Format | Installation |
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
| Other distros / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
| Sandboxed installation | `.flatpak` | `flatpak install CC-Switch-*.flatpak` |
+188
View File
@@ -0,0 +1,188 @@
# CC Switch v3.9.0
> ローカル API プロキシ、自動フェイルオーバー、Universal Provider、多アプリ対応の強化
**[English →](release-note-v3.9.0-en.md) | [中文版 →](release-note-v3.9.0-zh.md)**
---
## 概要
CC Switch v3.9.0 は v3.9 ベータ(`3.9.0-1``3.9.0-2``3.9.0-3`)の安定版です。
ローカル API プロキシ(アプリ別テイクオーバー対応)、自動フェイルオーバー、Universal Provider を追加し、Claude Code / Codex / Gemini CLI の安定性と操作性を大きく改善しました。
**リリース日**2026-01-07
---
## ハイライト
- ローカル API プロキシ:Claude Code / Codex / Gemini CLI を統一的にプロキシ
- 自動フェイルオーバー:サーキットブレーカーとアプリ別のフェイルオーバーキュー
- Universal Provider1つの設定を複数アプリへ同期(NewAPI などのゲートウェイ向け)
- Skills の改善:マルチアプリ対応、SSOT + React Query による管理の統一
- 共通設定スニペット:エディタ内容または現在のプロバイダから抽出
- MCP インポート:インストール済みアプリから MCP servers を取り込み
- 使用量の改善:自動更新、キャッシュ指標、タイムゾーン修正
- Linux パッケージ:RPM / Flatpak の成果物を追加
---
## 主要機能
### ローカル API プロキシ(Local API Proxy
- ローカルで高性能な HTTP プロキシサーバーを起動(Axum ベース)
- Claude Code / Codex / Gemini CLI の API リクエストを統一的に扱う
- アプリ別テイクオーバー:アプリごとにプロキシ経由にするかを個別に切り替え可能
- Live 設定テイクオーバー:有効化時に CLI の live 設定をバックアップし、ローカルプロキシへリダイレクト
- 監視:リクエストログと使用量統計でデバッグとコスト把握を支援
- エラーリクエストのログ:失敗したプロキシリクエストも詳細に記録してデバッグを容易に(#401@yovinchen に感謝)
### 自動フェイルオーバー(Auto Failover / サーキットブレーカー)
- 障害を検知して保護(サーキットブレーカー)を自動で発動
- 現在のプロバイダが不調な場合、バックアッププロバイダへ自動切り替え
- アプリごとに独立したフェイルオーバーキューとヘルス状態を管理
- フェイルオーバーを無効化している場合、タイムアウト/リトライ関連の設定は通常フローに影響しません
### Skills 管理
- Claude Code と Codex の Skills をマルチアプリで利用可能にし、旧レイアウトからの移行もよりスムーズに(#365#378@yovinchen に感謝)
- SSOT + React Query による Skills 管理の統一で、状態の一貫性と更新挙動を改善
- Discovery の体験と性能を改善:
- スキャン時に隠しディレクトリをスキップ
- Discoverable skills に長寿命キャッシュを適用して高速化
- ローディング表示の改善と、インポート/更新などの操作導線を整理
- Skills リポジトリのブランチ設定を修正(#505@kjasn に感謝)
### Universal Provider
- 複数アプリで共有できるプロバイダ設定を追加(Claude/Codex/Gemini へ同期)(#348@Calcium-Ion に感謝)
- NewAPI のような複数プロトコル対応の API ゲートウェイを想定
- 1つのプロバイダ内でアプリ別にデフォルトモデルを割り当て可能
### 共通設定スニペット(Claude/Codex/Gemini
- 「共通設定スニペット」を保持し、有効化したプロバイダへマージ/追記
- 新しい抽出フロー:
- エディタの現在内容から抽出(編集している内容)
- エディタ内容がない場合は、現在アクティブなプロバイダから抽出
- Codex の抽出はより安全:
- `model_provider``model`、および `model_providers` テーブル全体など、プロバイダ固有の設定を除去
- `[mcp_servers.*]` 配下の `base_url` は保持し、MCP 設定を壊しにくくしています
### MCP 管理
- インストール済みアプリから MCP servers をインポート
- 安定性向上:対象 CLI が未インストールなら同期をスキップし、無効な Codex `config.toml` も適切に扱います(#461@majiayu000 に感謝)
- Windows 互換性:MCP エクスポート時の npx/npm 呼び出しを `cmd /c` でラップ
### 使用量と価格データ
- 使用量/価格の改善:自動更新、キャッシュ指標、タイムゾーン修正、内蔵価格テーブル更新(#508@yovinchen に感謝)
- DeepLink 対応:deeplink から使用量クエリ設定をインポート(#400@qyinter に感謝)
- 使用量統計からモデル情報を抽出(#455@yovinchen に感謝)
- 使用量クエリ資格情報はプロバイダ設定へフォールバック可能(#360@Sirhexs に感謝)
---
## 使い勝手の改善
- プロバイダ検索フィルター(名前で素早く検索)(#435@TinsFox に感謝)
- プロバイダのアイコン色:アイコンに任意の色を設定して見分けやすく(#385@yovinchen に感謝)
- ショートカット:`Cmd/Ctrl + ,` で設定を開く(#436@TinsFox に感謝)
- Claude Code の初回確認ダイアログをスキップ可能(任意)
- トースト通知のクローズボタン:切り替え通知と成功通知を閉じられるように(#350@ForteScarlet に感謝)
- 更新バッジをクリックすると About タブへ移動
- 設定ページのタブスタイル改善(#342@wenyuanw に感謝)
- アプリ/ビュー切り替えのフェードとパネル終了アニメーション
- プロキシテイクオーバー中はエメラルド系テーマを適用して状態を分かりやすく
- ダークモードの視認性改善
- FullScreenPanel のウィンドウドラッグ領域を改善(#525@zerob13 に感謝)
---
## プラットフォーム別メモ
### Windows
- バージョンチェック時にターミナルが表示されないよう改善
- ウィンドウ最小サイズのデフォルトを調整
- 起動時の黒画面を避けるため、システムタイトルバー方式を採用
- 古い WebView 向けに `crypto.randomUUID()` のフォールバックを追加
### macOS
- 自動起動で `.app` バンドルパスを使用し、ターミナル表示を回避(#462@majiayu000 に感謝)
- トレイとヘッダー周りの体験を改善
---
## パッケージ
- LinuxRPM と Flatpak のパッケージングを追加し、リリース成果物の生成に対応
---
## 注意事項
- セキュリティ強化:JavaScript 実行器と使用量スクリプト実行に関するセキュリティ問題を修正(#151@luojiyin1987 に感謝)。
- SQL インポートは CC Switch がエクスポートしたバックアップのみに制限されます(安全性のため)。
- プロキシのテイクオーバーは CLI の live 設定を変更します。CC Switch はリダイレクト前に live 設定をバックアップします。元に戻す場合はテイクオーバー無効化/プロキシ停止を行い、必要に応じてバックアップから復元してください。
## 特別な謝辞
@xunyu @deijing @su-fen の皆様のサポートと貢献に特別な感謝を申し上げます。皆様なしではこのリリースは実現しませんでした!
## ダウンロード & インストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から該当するバージョンをダウンロードしてください。
### システム要件
| システム | 最低バージョン | アーキテクチャ |
| -------- | ----------------------------- | ----------------------------------- |
| Windows | Windows 10 以降 | x64 |
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表参照 | x64 |
### Windows
| ファイル | 説明 |
| --------------------------------------- | -------------------------------------------- |
| `CC-Switch-v3.9.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
| `CC-Switch-v3.9.0-Windows-Portable.zip` | ポータブル版、インストール不要 |
### macOS
| ファイル | 説明 |
| ------------------------------- | ----------------------------------------------------------------- |
| `CC-Switch-v3.9.0-macOS.zip` | **推奨** - 解凍して Applications へドラッグ、Universal Binary |
| `CC-Switch-v3.9.0-macOS.tar.gz` | Homebrew インストールおよび自動更新用 |
> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元が未確認」という警告が表示される場合があります。アプリを閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、正常に開けるようになります。
### Homebrew (MacOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
アップデート:
```bash
brew upgrade --cask cc-switch
```
### Linux
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------ |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接実行、または AUR を使用 |
| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
| サンドボックスで実行したい場合 | `.flatpak` | `flatpak install CC-Switch-*.flatpak` |
+188
View File
@@ -0,0 +1,188 @@
# CC Switch v3.9.0
> 本地 API 代理、自动故障切换、统一供应商与多应用工作流增强
**[English →](release-note-v3.9.0-en.md) | [日本語版 →](release-note-v3.9.0-ja.md)**
---
## 概览
CC Switch v3.9.0 是 v3.9 测试版序列(`3.9.0-1``3.9.0-2``3.9.0-3`)的稳定版。
本次更新带来本地 API 代理(支持按应用接管)、自动故障切换、统一供应商(Universal Provider),并对 Claude Code / Codex / Gemini CLI 的稳定性与使用体验做了大量改进。
**发布日期**2026-01-07
---
## 重点内容
- 本地 API 代理:Claude Code / Codex / Gemini CLI 统一接入
- 自动故障切换:熔断保护 + 每个应用独立的 failover 队列
- 统一供应商:一份配置可同步到多个应用(适合 NewAPI 等网关)
- Skills 相关增强:支持多应用、管理架构统一(SSOT + React Query
- 通用配置片段:支持从编辑器内容或当前供应商提取可复用片段
- MCP 导入:支持从已安装应用导入 MCP servers
- 用量增强:自动刷新、缓存命中/创建指标、时区修复
- Linux 打包:新增 RPM 与 Flatpak 制品
---
## 主要功能
### 本地 API 代理(Local API Proxy
- 运行一个本地高性能 HTTP 代理服务(基于 Axum)
- 统一代理 Claude Code、Codex、Gemini CLI 的 API 请求
- 按应用接管:你可以分别控制每个应用是否走本地代理
- Live 配置接管:启用接管时,会备份并重定向 CLI 的 live 配置到本地代理
- 监控能力:记录请求日志与用量统计,便于排错与成本分析
- 错误请求日志:代理会记录失败请求的详细信息,便于定位问题(#401,感谢 @yovinchen
### 自动故障切换(Auto Failover / 熔断)
- 自动检测供应商异常并触发熔断保护
- 当前供应商不可用时自动切换到备用供应商
- 每个应用维护独立的 failover 队列,并实时追踪健康状态
- 当关闭故障切换时,超时/重试相关配置不会影响正常请求流程
### Skills 管理
- Skills 支持 Claude Code 与 Codex 多应用使用,并提供旧结构到新结构的平滑迁移(#365#378,感谢 @yovinchen
- Skills 管理架构统一(SSOT + React Query),状态刷新与数据一致性更稳定
- 发现(Discovery)体验与性能改进:
- 扫描时跳过隐藏目录
- Discoverable skills 使用长生命周期缓存提升性能
- 增加加载状态提示,导入/刷新等操作入口更显眼
- 修复 Skills 仓库分支配置错误(#505,感谢 @kjasn
### 统一供应商(Universal Provider
- 新增“跨应用共享”的供应商配置,可同步到 Claude/Codex/Gemini#348,感谢 @Calcium-Ion
- 适配支持多协议的 API 网关(例如 NewAPI
- 同一个供应商下可按应用分别设置默认模型映射
### 通用配置片段(Claude/Codex/Gemini
- 维护一段“通用配置片段”,并将其合并/追加到启用该功能的供应商配置中
- 新增“提取通用配置片段”工作流:
- 优先从编辑器当前内容提取(你正在编辑的内容)
- 若未提供编辑器内容,则从当前激活的供应商提取
- Codex 场景提取更安全:
- 自动移除 `model_provider``model` 以及整个 `model_providers` 表等供应商相关内容
- 会保留 `[mcp_servers.*]` 下的 `base_url`,避免误伤 MCP 配置
### MCP 管理
- 支持从已安装应用导入 MCP servers
- 同步更稳健:目标 CLI 未安装则跳过;无效的 Codex `config.toml` 可更优雅处理(#461,感谢 @majiayu000
- Windows 兼容性:MCP 导出相关的 npx/npm 调用使用 `cmd /c` 包裹
### 用量与计费数据
- 用量与计费增强:自动刷新、缓存命中/创建指标、时区修复,以及内置价格表更新(#508,感谢 @yovinchen
- 深链支持:可通过 deeplink 导入用量查询配置(#400,感谢 @qyinter
- 用量统计支持提取模型信息(#455,感谢 @yovinchen
- 用量查询凭证支持从供应商配置回退(#360,感谢 @Sirhexs
---
## 体验优化
- 供应商搜索过滤:按名称快速查找(#435,感谢 @TinsFox
- 供应商图标颜色:支持为供应商图标设置自定义颜色,便于快速区分(#385,感谢 @yovinchen
- 快捷键:`Cmd/Ctrl + ,` 打开设置(#436,感谢 @TinsFox
- 可跳过 Claude Code 首次确认弹窗(可选)
- Toast 通知可关闭:切换提示与成功提示都支持关闭按钮(#350,感谢 @ForteScarlet
- 点击更新徽章会自动跳转到 About 标签页
- 设置页 Tab 样式改进(#342,感谢 @wenyuanw
- 更顺滑的切换动效:应用/视图淡入淡出与面板退出动画
- 代理接管激活时应用翡翠绿主题,便于一眼识别当前状态
- 深色模式可读性增强(表单与标签对比度等)
- FullScreenPanel 的窗口拖拽区域优化(#525,感谢 @zerob13
---
## 平台说明
### Windows
- 版本检查不再弹出终端窗口
- 改进窗口尺寸默认值(最小宽高)
- 修复部分设备启动黑屏问题(使用系统标题栏方案)
- 兼容旧 WebView:为 `crypto.randomUUID()` 增加降级方案
### macOS
- 自启动使用 `.app bundle` 路径,避免弹出终端窗口(#462,感谢 @majiayu000
- 托盘与标题栏相关体验优化
---
## 打包
- Linux:新增 RPM 与 Flatpak 打包目标,用于生成发布制品
---
## 说明与注意事项
- 安全增强:修复 JavaScript 执行器与用量脚本相关的安全问题(#151,感谢 @luojiyin1987)。
- 为降低导入风险,SQL 导入被限制为仅允许导入 CC Switch 自己导出的备份。
- Proxy 接管会修改 CLI 的 live 配置;CC Switch 会在重定向前自动备份 live 配置。如需回退,可关闭接管/停止代理,并在必要时从备份恢复。
## 特别感谢
特别感谢 @xunyu @deijing @su-fen 做出的支持和贡献,没有你们就没有这个版本!
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | ----------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 |
### Windows
| 文件 | 说明 |
| --------------------------------------- | ----------------------------------- |
| `CC-Switch-v3.9.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.9.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| ------------------------------- | --------------------------------------------------------- |
| `CC-Switch-v3.9.0-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
| `CC-Switch-v3.9.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### HomebrewMacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| 发行版 | 推荐格式 | 安装方式 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb``sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm``sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
| 沙箱隔离需求 | `.flatpak` | `flatpak install CC-Switch-*.flatpak` |
+63
View File
@@ -0,0 +1,63 @@
# Flatpak Build Guide
This directory contains the Flatpak manifest (`com.ccswitch.desktop`) for CC Switch, used to convert the generated `.deb` artifact into an installable `.flatpak` package via CI or local builds.
## Dependencies
- `flatpak`
- `flatpak-builder`
- Flathub remote (for installing `org.gnome.Platform//46` runtime)
For Ubuntu/Debian:
```bash
sudo apt install flatpak flatpak-builder
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install -y --user flathub org.gnome.Platform//46 org.gnome.Sdk//46
```
## Local Build (Generate .flatpak from .deb)
1) Build the deb on Linux first:
```bash
pnpm tauri build -- --bundles deb
```
2) Copy the generated deb to this directory:
```bash
cp "$(find src-tauri/target/release/bundle -name '*.deb' | head -n 1)" flatpak/cc-switch.deb
```
3) Build the local Flatpak repository and export the `.flatpak`:
```bash
flatpak-builder --force-clean --user --disable-cache --repo flatpak-repo flatpak-build flatpak/com.ccswitch.desktop.yml
flatpak build-bundle --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo flatpak-repo CC-Switch-Linux.flatpak com.ccswitch.desktop
```
4) Install and run:
```bash
flatpak install --user ./CC-Switch-Linux.flatpak
flatpak run com.ccswitch.desktop
```
## Permissions Note
The current manifest uses `--filesystem=home` by default for "download and run" convenience, allowing the app to directly read/write CLI configuration files and app data on the host (and supporting the "directory override" feature).
If you prefer minimal permissions (e.g., for Flathub submission or security concerns), you can replace `--filesystem=home` in `flatpak/com.ccswitch.desktop.yml` with more precise grants:
```yaml
- --filesystem=~/.cc-switch:create
- --filesystem=~/.claude:create
- --filesystem=~/.claude.json
- --filesystem=~/.codex:create
- --filesystem=~/.gemini:create
```
Note: Flatpak's `:create` modifier only works with directories, not files. Therefore, `~/.claude.json` cannot use `:create`. If this file doesn't exist on the user's machine, the app may not be able to create it with restricted permissions. Users should either run Claude Code once to generate it, or manually create an empty JSON file (content: `{}`).
If you plan to publish on Flathub or want stricter permission control, adjust the `finish-args` in `flatpak/com.ccswitch.desktop.yml` accordingly.
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=CC Switch
Comment=All-in-One Assistant for Claude Code, Codex & Gemini CLI
Exec=cc-switch
Icon=com.ccswitch.desktop
Terminal=false
Categories=Utility;Development;
StartupNotify=true
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.ccswitch.desktop</id>
<name>CC Switch</name>
<summary>All-in-One Assistant for Claude Code, Codex &amp; Gemini CLI</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<description>
<p>CC Switch is a cross-platform desktop app for managing and switching provider configurations for Claude Code, Codex, and Gemini CLI.</p>
<ul>
<li>Manage multiple provider configurations and endpoints</li>
<li>One-click switch and sync to client live configurations</li>
<li>MCP servers and Prompt/Skills management</li>
</ul>
</description>
<launchable type="desktop-id">com.ccswitch.desktop.desktop</launchable>
<provides>
<binary>cc-switch</binary>
</provides>
<url type="homepage">https://github.com/farion1231/cc-switch</url>
<url type="bugtracker">https://github.com/farion1231/cc-switch/issues</url>
</component>
+98
View File
@@ -0,0 +1,98 @@
id: com.ccswitch.desktop
runtime: org.gnome.Platform
runtime-version: '46'
sdk: org.gnome.Sdk
command: cc-switch
finish-args:
- --share=ipc
- --share=network
- --socket=wayland
- --socket=fallback-x11
- --device=dri
# Tray icon permissions (required by Tauri tray-icon)
- --talk-name=org.kde.StatusNotifierWatcher
- --filesystem=xdg-run/tray-icon:create
# GitHub Releases scenario: Users download and install manually.
# For "download and run" convenience (needs read/write access to ~/.cc-switch, ~/.claude, ~/.claude.json, ~/.codex, ~/.gemini,
# and supports custom directory overrides), we grant full Home access by default.
# If you plan to publish on Flathub or prefer minimal permissions, replace this with more precise directory grants (see flatpak/README.md).
- --filesystem=home
modules:
# Required for libdbusmenu build (intltool was removed from GNOME SDK since 2019)
- name: intltool
cleanup:
- "*"
sources:
- type: archive
url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz
sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd
# Required for tray icon support
- name: libayatana-ido
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=NO
sources:
- type: git
url: https://github.com/AyatanaIndicators/ayatana-ido.git
tag: 0.10.4
- name: libdbusmenu-gtk3
buildsystem: autotools
build-options:
cflags: -Wno-error
config-opts:
- --with-gtk=3
- --disable-dumper
- --disable-static
- --disable-nls
sources:
- type: archive
url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz
sha256: b9cc4a2acd74509435892823607d966d424bd9ad5d0b00938f27240a1bfa878a
- name: libayatana-indicator
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=NO
- -DENABLE_IDO=YES
sources:
- type: git
url: https://github.com/AyatanaIndicators/libayatana-indicator.git
tag: 0.9.4
- name: libayatana-appindicator
buildsystem: cmake-ninja
config-opts:
- -DENABLE_BINDINGS_MONO=NO
- -DENABLE_BINDINGS_VALA=NO
sources:
- type: git
url: https://github.com/AyatanaIndicators/libayatana-appindicator.git
tag: 0.5.93
- name: cc-switch
buildsystem: simple
sources:
# Placed in flatpak/ directory by CI or local build script
- type: file
path: cc-switch.deb
- type: file
path: com.ccswitch.desktop.desktop
- type: file
path: com.ccswitch.desktop.metainfo.xml
- type: file
path: ../src-tauri/icons/128x128.png
build-commands:
- ar -x *.deb
- tar -xf data.tar.*
- cp -a usr/* /app/
# Use our own desktop/metainfo/icon to align with Flatpak app id
- rm -f /app/share/applications/*.desktop
- install -Dm644 com.ccswitch.desktop.desktop /app/share/applications/com.ccswitch.desktop.desktop
- install -Dm644 com.ccswitch.desktop.metainfo.xml /app/share/metainfo/com.ccswitch.desktop.metainfo.xml
- install -Dm644 128x128.png /app/share/icons/hicolor/128x128/apps/com.ccswitch.desktop.png
+13 -7
View File
@@ -1,7 +1,8 @@
{
"name": "cc-switch",
"version": "3.8.2",
"version": "3.9.1",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
"dev": "pnpm tauri dev",
"build": "pnpm tauri build",
@@ -26,16 +27,17 @@
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.20",
"code-inspector-plugin": "^1.3.3",
"cross-fetch": "^4.1.0",
"jsdom": "^25.0.0",
"msw": "^2.11.6",
"prettier": "^3.6.2",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^2.0.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17"
"prettier": "^3.6.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.3.0",
"vite": "^7.3.0",
"vitest": "^2.0.5"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
@@ -50,6 +52,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@lobehub/icons-static-svg": "^1.73.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -67,7 +70,9 @@
"@tauri-apps/plugin-updater": "^2.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"framer-motion": "^12.23.25",
"i18next": "^25.5.2",
"jsonc-parser": "^3.2.1",
"lucide-react": "^0.542.0",
@@ -75,6 +80,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",
+940 -25
View File
File diff suppressed because it is too large Load Diff
+407 -15
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,6 +586,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.10.1"
@@ -618,14 +701,18 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.8.2"
version = "3.9.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",
@@ -635,6 +722,7 @@ dependencies = [
"reqwest",
"rquickjs",
"rusqlite",
"rust_decimal",
"serde",
"serde_json",
"serde_yaml",
@@ -650,11 +738,14 @@ dependencies = [
"tauri-plugin-store",
"tauri-plugin-updater",
"tempfile",
"thiserror 1.0.69",
"thiserror 2.0.17",
"tokio",
"toml 0.8.2",
"toml_edit 0.22.27",
"tower 0.4.13",
"tower-http 0.5.2",
"url",
"uuid",
"winreg 0.52.0",
"zip 2.4.2",
]
@@ -783,6 +874,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -806,9 +907,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.9.4",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -819,7 +920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.9.4",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -1204,6 +1305,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.0"
@@ -1369,6 +1479,15 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1376,7 +1495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -1390,6 +1509,12 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1833,6 +1958,25 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "h2"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.11.4",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1957,6 +2101,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.7.0"
@@ -1967,9 +2117,11 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@@ -1995,6 +2147,22 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.17"
@@ -2014,9 +2182,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2050,7 +2220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
dependencies = [
"byteorder",
"png",
"png 0.17.16",
]
[[package]]
@@ -2166,6 +2336,19 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.0",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2541,6 +2724,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.7.6"
@@ -2589,6 +2778,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "moxcms"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -2604,12 +2803,29 @@ dependencies = [
"objc2-core-foundation",
"objc2-foundation 0.3.1",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.17",
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -3027,6 +3243,50 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.9.4",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -3332,6 +3592,19 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "png"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
dependencies = [
"bitflags 2.9.4",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -3463,6 +3736,15 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "pxfm"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84"
dependencies = [
"num-traits",
]
[[package]]
name = "quick-xml"
version = "0.37.5"
@@ -3770,16 +4052,21 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -3790,10 +4077,11 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower 0.5.2",
"tower-http 0.6.6",
"tower-service",
"url",
"wasm-bindgen",
@@ -4037,6 +4325,15 @@ dependencies = [
"sdd",
]
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.1",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -4112,6 +4409,29 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -4206,6 +4526,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -4440,7 +4771,7 @@ dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics",
"foreign-types",
"foreign-types 0.5.0",
"js-sys",
"log",
"objc2 0.5.2",
@@ -4581,6 +4912,27 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -4602,7 +4954,7 @@ checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7"
dependencies = [
"bitflags 2.9.4",
"block2 0.6.1",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dispatch",
@@ -4686,6 +5038,7 @@ dependencies = [
"heck 0.5.0",
"http",
"http-range",
"image",
"jni",
"libc",
"log",
@@ -4754,7 +5107,7 @@ dependencies = [
"ico",
"json-patch",
"plist",
"png",
"png 0.17.16",
"proc-macro2",
"quote",
"semver",
@@ -5241,6 +5594,16 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -5378,6 +5741,17 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109"
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower"
version = "0.5.2"
@@ -5391,6 +5765,23 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags 2.9.4",
"bytes",
"http",
"http-body",
"http-body-util",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]]
@@ -5406,7 +5797,7 @@ dependencies = [
"http-body",
"iri-string",
"pin-project-lite",
"tower",
"tower 0.5.2",
"tower-layer",
"tower-service",
]
@@ -5429,6 +5820,7 @@ version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -5470,7 +5862,7 @@ dependencies = [
"objc2-core-graphics",
"objc2-foundation 0.3.1",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.17",
"windows-sys 0.59.0",
+15 -6
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.8.2"
version = "3.9.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"] }
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset", "image-png"] }
tauri-plugin-log = "2"
tauri-plugin-opener = "2"
tauri-plugin-process = "2"
@@ -37,12 +37,18 @@ tauri-plugin-deep-link = "2"
dirs = "5.0"
toml = "0.8"
toml_edit = "0.22"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
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"
@@ -69,7 +77,8 @@ objc2-app-kit = { version = "0.2", features = ["NSColor"] }
codegen-units = 1
lto = "thin"
opt-level = "s"
panic = "abort"
# 使用 unwind 以便 panic hook 能捕获 backtraceabort 会直接终止无法捕获)
panic = "unwind"
strip = "symbols"
[dev-dependencies]
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+104
View File
@@ -55,6 +55,110 @@ impl McpApps {
}
}
/// Skill 应用启用状态(标记 Skill 应用到哪些客户端)
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SkillApps {
#[serde(default)]
pub claude: bool,
#[serde(default)]
pub codex: bool,
#[serde(default)]
pub gemini: bool,
}
impl SkillApps {
/// 检查指定应用是否启用
pub fn is_enabled_for(&self, app: &AppType) -> bool {
match app {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
}
}
/// 设置指定应用的启用状态
pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
match app {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
}
}
/// 获取所有启用的应用列表
pub fn enabled_apps(&self) -> Vec<AppType> {
let mut apps = Vec::new();
if self.claude {
apps.push(AppType::Claude);
}
if self.codex {
apps.push(AppType::Codex);
}
if self.gemini {
apps.push(AppType::Gemini);
}
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini
}
/// 仅启用指定应用(其他应用设为禁用)
pub fn only(app: &AppType) -> Self {
let mut apps = Self::default();
apps.set_enabled_for(app, true);
apps
}
}
/// 已安装的 Skillv3.10.0+ 统一结构)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstalledSkill {
/// 唯一标识符(格式:"owner/repo:directory" 或 "local:directory"
pub id: String,
/// 显示名称
pub name: String,
/// 描述
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// 安装目录名(在 SSOT 目录中的子目录名)
pub directory: String,
/// 仓库所有者(GitHub 用户/组织)
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_owner: Option<String>,
/// 仓库名称
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_name: Option<String>,
/// 仓库分支
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_branch: Option<String>,
/// README URL
#[serde(skip_serializing_if = "Option::is_none")]
pub readme_url: Option<String>,
/// 应用启用状态
pub apps: SkillApps,
/// 安装时间(Unix 时间戳)
pub installed_at: i64,
}
/// 未管理的 Skill(在应用目录中发现但未被 CC Switch 管理)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UnmanagedSkill {
/// 目录名
pub directory: String,
/// 显示名称(从 SKILL.md 解析)
pub name: String,
/// 描述
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// 在哪些应用目录中发现(如 ["claude", "codex"]
pub found_in: Vec<String>,
}
/// MCP 服务器定义(v3.7.0 统一结构)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServer {
+71 -4
View File
@@ -1,16 +1,36 @@
use crate::error::AppError;
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
/// 获取 macOS 上的 .app bundle 路径
/// 将 `/path/to/CC Switch.app/Contents/MacOS/CC Switch` 转换为 `/path/to/CC Switch.app`
#[cfg(target_os = "macos")]
fn get_macos_app_bundle_path(exe_path: &std::path::Path) -> Option<std::path::PathBuf> {
let path_str = exe_path.to_string_lossy();
// 查找 .app/Contents/MacOS/ 模式
if let Some(app_pos) = path_str.find(".app/Contents/MacOS/") {
let app_bundle_end = app_pos + 4; // ".app" 的结束位置
Some(std::path::PathBuf::from(&path_str[..app_bundle_end]))
} else {
None
}
}
/// 初始化 AutoLaunch 实例
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
let app_name = "CC Switch";
let app_path =
let exe_path =
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
// macOS 需要使用 .app bundle 路径,否则 AppleScript login item 会打开终端
#[cfg(target_os = "macos")]
let app_path = get_macos_app_bundle_path(&exe_path).unwrap_or(exe_path);
#[cfg(not(target_os = "macos"))]
let app_path = exe_path;
// 使用 AutoLaunchBuilder 消除平台差异
// Windows/Linux: new() 接受 3 参数
// macOS: new() 接受 4 参数(含 hidden 参数)
// Builder 模式自动处理这些差异
// macOS: 使用 AppleScript 方式(默认),需要 .app bundle 路径
// Windows/Linux: 使用注册表/XDG autostart
let auto_launch = AutoLaunchBuilder::new()
.set_app_name(app_name)
.set_app_path(&app_path.to_string_lossy())
@@ -47,3 +67,50 @@ pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
.is_enabled()
.map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_valid() {
let exe_path = std::path::Path::new("/Applications/CC Switch.app/Contents/MacOS/CC Switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(
result,
Some(std::path::PathBuf::from("/Applications/CC Switch.app"))
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_with_spaces() {
let exe_path =
std::path::Path::new("/Users/test/My Apps/CC Switch.app/Contents/MacOS/CC Switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(
result,
Some(std::path::PathBuf::from(
"/Users/test/My Apps/CC Switch.app"
))
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_not_in_bundle() {
let exe_path = std::path::Path::new("/usr/local/bin/cc-switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(result, None);
}
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_dev_build() {
// 开发环境下的路径通常不在 .app bundle 内
let exe_path = std::path::Path::new("/Users/dev/project/target/debug/cc-switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(result, None);
}
}
+338
View File
@@ -7,6 +7,88 @@ use std::path::{Path, PathBuf};
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
use crate::error::AppError;
/// 需要在 Windows 上用 cmd /c 包装的命令
/// 这些命令在 Windows 上实际是 .cmd 批处理文件,需要通过 cmd /c 来执行
#[cfg(windows)]
const WINDOWS_WRAP_COMMANDS: &[&str] = &["npx", "npm", "yarn", "pnpm", "node", "bun", "deno"];
/// Windows 平台:将 `npx args...` 转换为 `cmd /c npx args...`
/// 解决 Claude Code /doctor 报告的 "Windows requires 'cmd /c' wrapper to execute npx" 警告
#[cfg(windows)]
fn wrap_command_for_windows(obj: &mut Map<String, Value>) {
// 只处理 stdio 类型(默认或显式)
let server_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
if server_type != "stdio" {
return;
}
let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) else {
return;
};
// 已经是 cmd 的不重复包装
if cmd.eq_ignore_ascii_case("cmd") || cmd.eq_ignore_ascii_case("cmd.exe") {
return;
}
// 提取命令名(去掉 .cmd 后缀和路径)
let cmd_name = Path::new(cmd)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(cmd);
let needs_wrap = WINDOWS_WRAP_COMMANDS
.iter()
.any(|&c| cmd_name.eq_ignore_ascii_case(c));
if !needs_wrap {
return;
}
// 构建新的 args: ["/c", "原命令", ...原args]
let original_args = obj
.get("args")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut new_args = vec![Value::String("/c".into()), Value::String(cmd.into())];
new_args.extend(original_args);
obj.insert("command".into(), Value::String("cmd".into()));
obj.insert("args".into(), Value::Array(new_args));
}
/// 非 Windows 平台无需处理
#[cfg(not(windows))]
fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
// 非 Windows 平台不做任何处理
}
/// 检测路径是否为 WSL 网络路径(如 \\wsl$\Ubuntu\... 或 \\wsl.localhost\Ubuntu\...
/// WSL 环境运行的是 Linux,不需要 cmd /c 包装
/// 注意:仅检测直接 UNC 路径,映射磁盘符(如 Z: -> \\wsl$\...)无法检测
#[cfg(windows)]
fn is_wsl_path(path: &Path) -> bool {
use std::path::{Component, Prefix};
if let Some(Component::Prefix(prefix)) = path.components().next() {
match prefix.kind() {
Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => {
let s = server.to_string_lossy();
s.eq_ignore_ascii_case("wsl$") || s.eq_ignore_ascii_case("wsl.localhost")
}
_ => false,
}
} else {
false
}
}
#[cfg(not(windows))]
fn is_wsl_path(_path: &Path) -> bool {
false
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpStatus {
@@ -105,6 +187,55 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
Ok(Some(content))
}
/// 在 ~/.claude.json 根对象写入 hasCompletedOnboarding=true(用于跳过 Claude Code 初次安装确认)
/// 仅增量写入该字段,其他字段保持不变
pub fn set_has_completed_onboarding() -> Result<bool, AppError> {
let path = user_config_path();
let mut root = if path.exists() {
read_json_value(&path)?
} else {
serde_json::json!({})
};
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
let already = obj
.get("hasCompletedOnboarding")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if already {
return Ok(false);
}
obj.insert("hasCompletedOnboarding".into(), Value::Bool(true));
write_json_value(&path, &root)?;
Ok(true)
}
/// 删除 ~/.claude.json 根对象的 hasCompletedOnboarding 字段(恢复 Claude Code 初次安装确认)
/// 仅增量删除该字段,其他字段保持不变
pub fn clear_has_completed_onboarding() -> Result<bool, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(false);
}
let mut root = read_json_value(&path)?;
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
let existed = obj.remove("hasCompletedOnboarding").is_some();
if !existed {
return Ok(false);
}
write_json_value(&path, &root)?;
Ok(true)
}
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
@@ -264,6 +395,11 @@ pub fn set_mcp_servers_map(
};
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
// 检测目标路径是否为 WSL,若是则跳过 cmd /c 包装
let is_wsl_target = is_wsl_path(&path);
if is_wsl_target {
log::info!("检测到 WSL 路径,跳过 cmd /c 包装: {}", path.display());
}
let mut out: Map<String, Value> = Map::new();
for (id, spec) in servers.iter() {
let mut obj = if let Some(map) = spec.as_object() {
@@ -290,6 +426,11 @@ pub fn set_mcp_servers_map(
obj.remove("homepage");
obj.remove("docs");
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式(WSL 路径除外)
if !is_wsl_target {
wrap_command_for_windows(&mut obj);
}
out.insert(id.clone(), Value::Object(obj));
}
@@ -303,3 +444,200 @@ pub fn set_mcp_servers_map(
write_json_value(&path, &root)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// 测试 Windows 命令包装功能
/// 由于使用条件编译,在非 Windows 平台上测试的是空函数
#[test]
fn test_wrap_command_for_windows_npx() {
let mut obj = json!({"command": "npx", "args": ["-y", "@upstash/context7-mcp"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(
obj["args"],
json!(["/c", "npx", "-y", "@upstash/context7-mcp"])
);
}
#[cfg(not(windows))]
{
// 非 Windows 平台不做任何处理
assert_eq!(obj["command"], "npx");
}
}
#[test]
fn test_wrap_command_for_windows_npm() {
let mut obj = json!({"command": "npm", "args": ["run", "start"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "npm", "run", "start"]));
}
}
#[test]
fn test_wrap_command_for_windows_already_cmd() {
// 已经是 cmd 的不应该重复包装
let mut obj = json!({"command": "cmd", "args": ["/c", "npx", "-y", "foo"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
assert_eq!(obj["command"], "cmd");
// args 应该保持不变,不会变成 ["/c", "cmd", "/c", "npx", ...]
assert_eq!(obj["args"], json!(["/c", "npx", "-y", "foo"]));
}
#[test]
fn test_wrap_command_for_windows_http_type_skipped() {
// http 类型不应该被处理
let mut obj = json!({"type": "http", "url": "https://example.com/mcp"})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
assert!(!obj.contains_key("command"));
assert_eq!(obj["url"], "https://example.com/mcp");
}
#[test]
fn test_wrap_command_for_windows_other_command_skipped() {
// 非目标命令(如 python)不应该被包装
let mut obj = json!({"command": "python", "args": ["server.py"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
// python 不在 WINDOWS_WRAP_COMMANDS 列表中,不应该被包装
assert_eq!(obj["command"], "python");
assert_eq!(obj["args"], json!(["server.py"]));
}
#[test]
fn test_wrap_command_for_windows_no_args() {
// 没有 args 的情况
let mut obj = json!({"command": "npx"}).as_object().unwrap().clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "npx"]));
}
}
#[test]
fn test_wrap_command_for_windows_with_cmd_suffix() {
// 处理 npx.cmd 格式
let mut obj = json!({"command": "npx.cmd", "args": ["-y", "foo"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "npx.cmd", "-y", "foo"]));
}
}
#[test]
fn test_wrap_command_for_windows_case_insensitive() {
// 大小写不敏感
let mut obj = json!({"command": "NPX", "args": ["-y", "foo"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
}
}
/// 测试 WSL 路径检测功能
#[test]
fn test_is_wsl_path_wsl_dollar() {
// wsl$ 格式 - 各种发行版
#[cfg(windows)]
{
assert!(is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Debian\home\user\.claude")));
assert!(is_wsl_path(Path::new(
r"\\wsl$\openSUSE-Leap-15.2\home\user"
)));
assert!(is_wsl_path(Path::new(r"\\wsl$\kali-linux\home\user")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Arch\home\user")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Alpine\home\user")));
assert!(is_wsl_path(Path::new(r"\\wsl$\Fedora\home\user")));
}
#[cfg(not(windows))]
{
// 非 Windows 平台始终返回 false
assert!(!is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
}
}
#[test]
fn test_is_wsl_path_wsl_localhost() {
// wsl.localhost 格式
#[cfg(windows)]
{
assert!(is_wsl_path(Path::new(
r"\\wsl.localhost\Ubuntu\home\user\.claude"
)));
assert!(is_wsl_path(Path::new(r"\\wsl.localhost\Debian\home\user")));
assert!(is_wsl_path(Path::new(
r"\\wsl.localhost\openSUSE-Leap-15.2\home\user"
)));
}
}
#[test]
fn test_is_wsl_path_case_insensitive() {
// 大小写不敏感
#[cfg(windows)]
{
assert!(is_wsl_path(Path::new(r"\\WSL$\Ubuntu\home\user")));
assert!(is_wsl_path(Path::new(r"\\Wsl$\Ubuntu\home\user")));
assert!(is_wsl_path(Path::new(r"\\WSL.LOCALHOST\Ubuntu\home\user")));
assert!(is_wsl_path(Path::new(r"\\Wsl.Localhost\Ubuntu\home\user")));
}
}
#[test]
fn test_is_wsl_path_non_wsl() {
// 非 WSL 路径
assert!(!is_wsl_path(Path::new(r"C:\Users\user\.claude")));
assert!(!is_wsl_path(Path::new(r"D:\Workspace\project")));
#[cfg(windows)]
{
assert!(!is_wsl_path(Path::new(r"\\server\share\path")));
assert!(!is_wsl_path(Path::new(r"\\localhost\c$\Users")));
assert!(!is_wsl_path(Path::new(r"\\192.168.1.1\share")));
}
}
}
+9 -1
View File
@@ -9,13 +9,21 @@ use serde_json::Value;
use std::fs;
use std::path::Path;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
return custom;
}
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
get_home_dir().join(".codex")
}
/// 获取 Codex auth.json 路径
+43 -3
View File
@@ -7,6 +7,7 @@ use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::settings;
/// 获取 Claude Code 配置状态
#[tauri::command]
@@ -16,6 +17,18 @@ pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
use std::str::FromStr;
fn invalid_json_format_error(error: serde_json::Error) -> String {
let lang = settings::get_settings()
.language
.unwrap_or_else(|| "zh".to_string());
match lang.as_str() {
"en" => format!("Invalid JSON format: {error}"),
"ja" => format!("JSON形式が無効です: {error}"),
_ => format!("无效的 JSON 格式: {error}"),
}
}
#[tauri::command]
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
match AppType::from_str(&app).map_err(|e| e.to_string())? {
@@ -155,8 +168,7 @@ pub async fn set_claude_common_config_snippet(
) -> Result<(), String> {
// 验证是否为有效的 JSON(如果不为空)
if !snippet.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
}
let value = if snippet.trim().is_empty() {
@@ -197,7 +209,7 @@ pub async fn set_common_config_snippet(
"claude" | "gemini" => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
.map_err(invalid_json_format_error)?;
}
"codex" => {
// TOML 格式暂不验证(或可使用 toml crate)
@@ -219,3 +231,31 @@ pub async fn set_common_config_snippet(
.map_err(|e| e.to_string())?;
Ok(())
}
/// 提取通用配置片段
///
/// 优先从 `settingsConfig`(编辑器当前内容)提取;若未提供,则从当前激活供应商提取。
///
/// 提取时会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。
#[tauri::command]
pub async fn extract_common_config_snippet(
appType: String,
settingsConfig: Option<String>,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<String, String> {
let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;
if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {
let settings: serde_json::Value =
serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;
return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
app,
&settings,
)
.map_err(|e| e.to_string());
}
crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)
.map_err(|e| e.to_string())
}
+102
View File
@@ -0,0 +1,102 @@
//! 故障转移队列命令
//!
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
use crate::database::FailoverQueueItem;
use crate::provider::Provider;
use crate::store::AppState;
/// 获取故障转移队列
#[tauri::command]
pub async fn get_failover_queue(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<Vec<FailoverQueueItem>, String> {
state
.db
.get_failover_queue(&app_type)
.map_err(|e| e.to_string())
}
/// 获取可添加到故障转移队列的供应商(不在队列中的)
#[tauri::command]
pub async fn get_available_providers_for_failover(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<Vec<Provider>, String> {
state
.db
.get_available_providers_for_failover(&app_type)
.map_err(|e| e.to_string())
}
/// 添加供应商到故障转移队列
#[tauri::command]
pub async fn add_to_failover_queue(
state: tauri::State<'_, AppState>,
app_type: String,
provider_id: String,
) -> Result<(), String> {
state
.db
.add_to_failover_queue(&app_type, &provider_id)
.map_err(|e| e.to_string())
}
/// 从故障转移队列移除供应商
#[tauri::command]
pub async fn remove_from_failover_queue(
state: tauri::State<'_, AppState>,
app_type: String,
provider_id: String,
) -> Result<(), String> {
state
.db
.remove_from_failover_queue(&app_type, &provider_id)
.map_err(|e| e.to_string())
}
/// 获取指定应用的自动故障转移开关状态(从 proxy_config 表读取)
#[tauri::command]
pub async fn get_auto_failover_enabled(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<bool, String> {
state
.db
.get_proxy_config_for_app(&app_type)
.await
.map(|config| config.auto_failover_enabled)
.map_err(|e| e.to_string())
}
/// 设置指定应用的自动故障转移开关状态(写入 proxy_config 表)
///
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
#[tauri::command]
pub async fn set_auto_failover_enabled(
state: tauri::State<'_, AppState>,
app_type: String,
enabled: bool,
) -> Result<(), String> {
log::info!(
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
);
// 读取当前配置
let mut config = state
.db
.get_proxy_config_for_app(&app_type)
.await
.map_err(|e| e.to_string())?;
// 更新 auto_failover_enabled 字段
config.auto_failover_enabled = enabled;
// 写回数据库
state
.db
.update_proxy_config_for_app(config)
.await
.map_err(|e| e.to_string())
}
+247
View File
@@ -0,0 +1,247 @@
//! 全局出站代理相关命令
//!
//! 提供获取、设置和测试全局代理的 Tauri 命令。
use crate::proxy::http_client;
use crate::store::AppState;
use serde::Serialize;
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream};
use std::time::{Duration, Instant};
/// 获取全局代理 URL
///
/// 返回当前配置的代理 URL,null 表示直连。
#[tauri::command]
pub fn get_global_proxy_url(state: tauri::State<'_, AppState>) -> Result<Option<String>, String> {
let result = state.db.get_global_proxy_url().map_err(|e| e.to_string())?;
log::debug!(
"[GlobalProxy] [GP-010] Read from database: {}",
result
.as_ref()
.map(|u| http_client::mask_url(u))
.unwrap_or_else(|| "None".to_string())
);
Ok(result)
}
/// 设置全局代理 URL
///
/// - 传入非空字符串:启用代理
/// - 传入空字符串:清除代理(直连)
///
/// 执行顺序:先验证 → 写 DB → 再应用
/// 这样确保 DB 写失败时不会出现运行态与持久化不一致的问题
#[tauri::command]
pub fn set_global_proxy_url(state: tauri::State<'_, AppState>, url: String) -> Result<(), String> {
// 调试:显示接收到的 URL 信息(不包含敏感内容)
let has_auth = url.contains('@') && (url.starts_with("http://") || url.starts_with("socks"));
log::debug!(
"[GlobalProxy] [GP-011] Received URL: length={}, has_auth={}",
url.len(),
has_auth
);
let url_opt = if url.trim().is_empty() {
None
} else {
Some(url.as_str())
};
// 1. 先验证代理配置是否有效(不应用)
http_client::validate_proxy(url_opt)?;
// 2. 验证成功后保存到数据库
state
.db
.set_global_proxy_url(url_opt)
.map_err(|e| e.to_string())?;
// 3. DB 写入成功后再应用到运行态
http_client::apply_proxy(url_opt)?;
log::info!(
"[GlobalProxy] [GP-009] Configuration updated: {}",
url_opt
.map(http_client::mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 代理测试结果
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyTestResult {
/// 是否连接成功
pub success: bool,
/// 延迟(毫秒)
pub latency_ms: u64,
/// 错误信息
pub error: Option<String>,
}
/// 测试代理连接
///
/// 通过指定的代理 URL 发送测试请求,返回连接结果和延迟。
/// 使用多个测试目标,任一成功即认为代理可用。
#[tauri::command]
pub async fn test_proxy_url(url: String) -> Result<ProxyTestResult, String> {
if url.trim().is_empty() {
return Err("Proxy URL is empty".to_string());
}
let start = Instant::now();
// 构建带代理的临时客户端
let proxy = reqwest::Proxy::all(&url).map_err(|e| format!("Invalid proxy URL: {e}"))?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to build client: {e}"))?;
// 使用多个测试目标,提高兼容性
// 优先使用 httpbin(专门用于 HTTP 测试),回退到其他公共端点
let test_urls = [
"https://httpbin.org/get",
"https://www.google.com",
"https://api.anthropic.com",
];
let mut last_error = None;
for test_url in test_urls {
match client.head(test_url).send().await {
Ok(resp) => {
let latency = start.elapsed().as_millis() as u64;
log::debug!(
"[GlobalProxy] Test successful: {} -> {} via {} ({}ms)",
http_client::mask_url(&url),
test_url,
resp.status(),
latency
);
return Ok(ProxyTestResult {
success: true,
latency_ms: latency,
error: None,
});
}
Err(e) => {
log::debug!("[GlobalProxy] Test to {test_url} failed: {e}");
last_error = Some(e);
}
}
}
// 所有测试目标都失败
let latency = start.elapsed().as_millis() as u64;
let error_msg = last_error
.map(|e| e.to_string())
.unwrap_or_else(|| "All test targets failed".to_string());
log::debug!(
"[GlobalProxy] Test failed: {} -> {} ({}ms)",
http_client::mask_url(&url),
error_msg,
latency
);
Ok(ProxyTestResult {
success: false,
latency_ms: latency,
error: Some(error_msg),
})
}
/// 获取当前出站代理状态
///
/// 返回当前是否启用了出站代理以及代理 URL。
#[tauri::command]
pub fn get_upstream_proxy_status() -> UpstreamProxyStatus {
let url = http_client::get_current_proxy_url();
UpstreamProxyStatus {
enabled: url.is_some(),
proxy_url: url,
}
}
/// 出站代理状态信息
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpstreamProxyStatus {
/// 是否启用代理
pub enabled: bool,
/// 代理 URL
pub proxy_url: Option<String>,
}
/// 检测到的代理信息
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DetectedProxy {
/// 代理 URL
pub url: String,
/// 代理类型 (http/socks5)
pub proxy_type: String,
/// 端口
pub port: u16,
}
/// 常见代理端口配置
/// 格式:(端口, 主要类型, 是否同时支持 http 和 socks5)
/// 对于 mixed 端口,会同时返回两种协议供用户选择
const PROXY_PORTS: &[(u16, &str, bool)] = &[
(7890, "http", true), // Clash (mixed mode)
(7891, "socks5", false), // Clash SOCKS only
(1080, "socks5", false), // 通用 SOCKS5
(8080, "http", false), // 通用 HTTP
(8888, "http", false), // Charles/Fiddler
(3128, "http", false), // Squid
(10808, "socks5", false), // V2Ray SOCKS
(10809, "http", false), // V2Ray HTTP
];
/// 扫描本地代理
///
/// 检测常见端口是否有代理服务在运行。
/// 使用异步任务避免阻塞 UI 线程。
#[tauri::command]
pub async fn scan_local_proxies() -> Vec<DetectedProxy> {
// 使用 spawn_blocking 避免阻塞主线程
tokio::task::spawn_blocking(|| {
let mut found = Vec::new();
for &(port, primary_type, is_mixed) in PROXY_PORTS {
let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port);
if TcpStream::connect_timeout(&addr.into(), Duration::from_millis(100)).is_ok() {
// 添加主要类型
found.push(DetectedProxy {
url: format!("{primary_type}://127.0.0.1:{port}"),
proxy_type: primary_type.to_string(),
port,
});
// 对于 mixed 端口,同时添加另一种协议
if is_mixed {
let alt_type = if primary_type == "http" {
"socks5"
} else {
"http"
};
found.push(DetectedProxy {
url: format!("{alt_type}://127.0.0.1:{port}"),
proxy_type: alt_type.to_string(),
port,
});
}
}
}
found
})
.await
.unwrap_or_default()
}
+10
View File
@@ -192,3 +192,13 @@ pub async fn toggle_mcp_app(
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())
}
/// 从所有应用导入 MCP 服务器(复用已有的导入逻辑)
#[tauri::command]
pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, String> {
let mut total = 0;
total += McpService::import_from_claude(&state).unwrap_or(0);
total += McpService::import_from_codex(&state).unwrap_or(0);
total += McpService::import_from_gemini(&state).unwrap_or(0);
Ok(total)
}
+526 -1
View File
@@ -1,9 +1,21 @@
#![allow(non_snake_case)]
use crate::init_status::InitErrorPayload;
use crate::app_config::AppType;
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
use crate::services::ProviderService;
use once_cell::sync::Lazy;
use regex::Regex;
use std::str::FromStr;
use tauri::AppHandle;
use tauri::State;
use tauri_plugin_opener::OpenerExt;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
/// 打开外部链接
#[tauri::command]
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
@@ -58,3 +70,516 @@ pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
pub async fn get_migration_result() -> Result<bool, String> {
Ok(crate::init_status::take_migration_success())
}
/// 获取 Skills 自动导入(SSOT)迁移结果(若有)。
/// 只返回一次 Some({count}),之后返回 None,用于前端显示一次性 Toast 通知。
#[tauri::command]
pub async fn get_skills_migration_result() -> Result<Option<SkillsMigrationPayload>, String> {
Ok(crate::init_status::take_skills_migration_result())
}
#[derive(serde::Serialize)]
pub struct ToolVersion {
name: String,
version: Option<String>,
latest_version: Option<String>, // 新增字段:最新版本
error: Option<String>,
}
#[tauri::command]
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
let tools = vec!["claude", "codex", "gemini"];
let mut results = Vec::new();
// 使用全局 HTTP 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
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,
}
}
/// 预编译的版本号正则表达式
static VERSION_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
/// 从版本输出中提取纯版本号
fn extract_version(raw: &str) -> String {
VERSION_RE
.find(raw)
.map(|m| m.as_str().to_string())
.unwrap_or_else(|| raw.to_string())
}
/// 尝试直接执行命令获取版本
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
use std::process::Command;
#[cfg(target_os = "windows")]
let output = {
Command::new("cmd")
.args(["/C", &format!("{tool} --version")])
.creation_flags(CREATE_NO_WINDOW)
.output()
};
#[cfg(not(target_os = "windows"))]
let output = {
Command::new("sh")
.arg("-c")
.arg(format!("{tool} --version"))
.output()
};
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() {
(None, Some("未安装或无法执行".to_string()))
} else {
(Some(extract_version(raw)), None)
}
} else {
let err = if stderr.is_empty() { stdout } else { stderr };
(
None,
Some(if err.is_empty() {
"未安装或无法执行".to_string()
} else {
err
}),
)
}
}
Err(e) => (None, Some(e.to_string())),
}
}
/// 扫描常见路径查找 CLI
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
use std::process::Command;
let home = dirs::home_dir().unwrap_or_default();
// 常见的 npm 全局安装路径
let mut search_paths: Vec<std::path::PathBuf> = vec![
home.join(".npm-global/bin"),
home.join(".local/bin"),
home.join("n/bin"), // n version manager
];
#[cfg(target_os = "macos")]
{
search_paths.push(std::path::PathBuf::from("/opt/homebrew/bin"));
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
}
#[cfg(target_os = "linux")]
{
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
search_paths.push(std::path::PathBuf::from("/usr/bin"));
}
#[cfg(target_os = "windows")]
{
if let Some(appdata) = dirs::data_dir() {
search_paths.push(appdata.join("npm"));
}
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
}
// 扫描 nvm 目录下的所有 node 版本
let nvm_base = home.join(".nvm/versions/node");
if nvm_base.exists() {
if let Ok(entries) = std::fs::read_dir(&nvm_base) {
for entry in entries.flatten() {
let bin_path = entry.path().join("bin");
if bin_path.exists() {
search_paths.push(bin_path);
}
}
}
}
// 在每个路径中查找工具
for path in &search_paths {
let tool_path = if cfg!(target_os = "windows") {
path.join(format!("{tool}.cmd"))
} else {
path.join(tool)
};
if tool_path.exists() {
// 构建 PATH 环境变量,确保 node 可被找到
let current_path = std::env::var("PATH").unwrap_or_default();
#[cfg(target_os = "windows")]
let new_path = format!("{};{}", path.display(), current_path);
#[cfg(not(target_os = "windows"))]
let new_path = format!("{}:{}", path.display(), current_path);
#[cfg(target_os = "windows")]
let output = {
// 使用 cmd /C 包装执行,确保子进程也在隐藏的控制台中运行
Command::new("cmd")
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
.env("PATH", &new_path)
.creation_flags(CREATE_NO_WINDOW)
.output()
};
#[cfg(not(target_os = "windows"))]
let output = {
Command::new(&tool_path)
.arg("--version")
.env("PATH", &new_path)
.output()
};
if let Ok(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if !raw.is_empty() {
return (Some(extract_version(raw)), None);
}
}
}
}
}
(None, Some("未安装或无法执行".to_string()))
}
/// 打开指定提供商的终端
///
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
#[allow(non_snake_case)]
#[tauri::command]
pub async fn open_provider_terminal(
state: State<'_, crate::store::AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
// 获取提供商配置
let providers = ProviderService::list(state.inner(), app_type.clone())
.map_err(|e| format!("获取提供商列表失败: {e}"))?;
let provider = providers
.get(&providerId)
.ok_or_else(|| format!("提供商 {providerId} 不存在"))?;
// 从提供商配置中提取环境变量
let config = &provider.settings_config;
let env_vars = extract_env_vars_from_config(config, &app_type);
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
Ok(true)
}
/// 从提供商配置中提取环境变量
fn extract_env_vars_from_config(
config: &serde_json::Value,
app_type: &AppType,
) -> Vec<(String, String)> {
let mut env_vars = Vec::new();
let Some(obj) = config.as_object() else {
return env_vars;
};
// 处理 env 字段(Claude/Gemini 通用)
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
for (key, value) in env {
if let Some(str_val) = value.as_str() {
env_vars.push((key.clone(), str_val.to_string()));
}
}
// 处理 base_url: 根据应用类型添加对应的环境变量
let base_url_key = match app_type {
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
_ => None,
};
if let Some(key) = base_url_key {
if let Some(url_str) = env.get(key).and_then(|v| v.as_str()) {
env_vars.push((key.to_string(), url_str.to_string()));
}
}
}
// Codex 使用 auth 字段转换为 OPENAI_API_KEY
if *app_type == AppType::Codex {
if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) {
env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string()));
}
}
// Gemini 使用 api_key 字段转换为 GEMINI_API_KEY
if *app_type == AppType::Gemini {
if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) {
env_vars.push(("GEMINI_API_KEY".to_string(), api_key.to_string()));
}
}
env_vars
}
/// 创建临时配置文件并启动 claude 终端
/// 使用 --settings 参数传入提供商特定的 API 配置
fn launch_terminal_with_env(
env_vars: Vec<(String, String)>,
provider_id: &str,
) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let config_file = temp_dir.join(format!(
"claude_{}_{}.json",
provider_id,
std::process::id()
));
// 创建并写入配置文件
write_claude_config(&config_file, &env_vars)?;
// 转义配置文件路径用于 shell
let config_path_escaped = escape_shell_path(&config_file);
#[cfg(target_os = "macos")]
{
launch_macos_terminal(&config_file, &config_path_escaped)?;
return Ok(());
}
#[cfg(target_os = "linux")]
{
launch_linux_terminal(&config_file, &config_path_escaped)?;
Ok(())
}
#[cfg(target_os = "windows")]
{
launch_windows_terminal(&temp_dir, &config_file)?;
return Ok(());
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("不支持的操作系统".to_string())
}
/// 写入 claude 配置文件
fn write_claude_config(
config_file: &std::path::Path,
env_vars: &[(String, String)],
) -> Result<(), String> {
let mut config_obj = serde_json::Map::new();
let mut env_obj = serde_json::Map::new();
for (key, value) in env_vars {
env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));
}
config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
let config_json =
serde_json::to_string_pretty(&config_obj).map_err(|e| format!("序列化配置失败: {e}"))?;
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
}
/// 转义 shell 路径
fn escape_shell_path(path: &std::path::Path) -> String {
path.to_string_lossy()
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace(' ', "\\ ")
}
/// 生成 bash 包装脚本,用于清理临时文件
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
format!(
"bash -c 'trap \"rm -f \\\"{}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{}\"; claude --settings \"{}\"; exec bash --norc --noprofile'",
config_path, escaped_path, escaped_path
)
}
/// macOS: 使用 Terminal.app 启动
#[cfg(target_os = "macos")]
fn launch_macos_terminal(
config_file: &std::path::Path,
config_path_escaped: &str,
) -> Result<(), String> {
use std::process::Command;
let config_path_for_script = config_file.to_string_lossy().replace('\"', "\\\"");
let shell_script = generate_wrapper_script(&config_path_for_script, config_path_escaped);
let script = format!(
r#"tell application "Terminal"
activate
do script "{}"
end tell"#,
shell_script.replace('\"', "\\\"")
);
Command::new("osascript")
.arg("-e")
.arg(&script)
.spawn()
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;
Ok(())
}
/// Linux: 尝试使用常见终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(
config_file: &std::path::Path,
config_path_escaped: &str,
) -> Result<(), String> {
use std::process::Command;
let terminals = [
"gnome-terminal",
"konsole",
"xfce4-terminal",
"mate-terminal",
"lxterminal",
"alacritty",
"kitty",
];
let config_path_for_bash = config_file.to_string_lossy();
let shell_cmd = generate_wrapper_script(&config_path_for_bash, config_path_escaped);
let mut last_error = String::from("未找到可用的终端");
for terminal in terminals {
// 检查终端是否存在
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
{
let result = match terminal {
"gnome-terminal" | "mate-terminal" => Command::new(terminal)
.arg("--")
.arg("bash")
.arg("-c")
.arg(&shell_cmd)
.spawn(),
_ => Command::new(terminal)
.arg("-e")
.arg("bash")
.arg("-c")
.arg(&shell_cmd)
.spawn(),
};
match result {
Ok(_) => return Ok(()),
Err(e) => {
last_error = format!("启动 {} 失败: {}", terminal, e);
}
}
}
}
// 清理配置文件
let _ = std::fs::remove_file(config_file);
Err(last_error)
}
/// Windows: 创建临时批处理文件启动
#[cfg(target_os = "windows")]
fn launch_windows_terminal(
temp_dir: &std::path::Path,
config_file: &std::path::Path,
) -> Result<(), String> {
use std::process::Command;
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
let content = format!(
"@echo off
echo Using provider-specific claude config:
echo {}
claude --settings \"{}\"
del \"{}\" >nul 2>&1
del \"%~f0\" >nul 2>&1
if errorlevel 1 (
echo.
echo Press any key to close...
pause >nul
)",
config_path_for_batch, config_path_for_batch, config_path_for_batch
);
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
Command::new("cmd")
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy()])
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;
Ok(())
}
+10
View File
@@ -3,23 +3,33 @@
mod config;
mod deeplink;
mod env;
mod failover;
mod global_proxy;
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 global_proxy::*;
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,3 +34,15 @@ pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String>
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())
}
/// Claude Code:跳过初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding=true
#[tauri::command]
pub async fn apply_claude_onboarding_skip() -> Result<bool, String> {
crate::claude_mcp::set_has_completed_onboarding().map_err(|e| e.to_string())
}
/// Claude Code:恢复初次安装确认(删除 ~/.claude.json 的 hasCompletedOnboarding 字段)
#[tauri::command]
pub async fn clear_claude_onboarding_skip() -> Result<bool, String> {
crate::claude_mcp::clear_has_completed_onboarding().map_err(|e| e.to_string())
}
+94
View File
@@ -229,3 +229,97 @@ pub fn update_providers_sort_order(
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
}
// ============================================================================
// 统一供应商(Universal Provider)命令
// ============================================================================
use crate::provider::UniversalProvider;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
/// 统一供应商同步完成事件的 payload
#[derive(Clone, serde::Serialize)]
pub struct UniversalProviderSyncedEvent {
/// 操作类型: "upsert" | "delete" | "sync"
pub action: String,
/// 统一供应商 ID
pub id: String,
}
/// 发送统一供应商同步事件,通知前端刷新供应商列表
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
let _ = app.emit(
"universal-provider-synced",
UniversalProviderSyncedEvent {
action: action.to_string(),
id: id.to_string(),
},
);
}
/// 获取所有统一供应商
#[tauri::command]
pub fn get_universal_providers(
state: State<'_, AppState>,
) -> Result<HashMap<String, UniversalProvider>, String> {
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
}
/// 获取单个统一供应商
#[tauri::command]
pub fn get_universal_provider(
state: State<'_, AppState>,
id: String,
) -> Result<Option<UniversalProvider>, String> {
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
}
/// 添加或更新统一供应商
#[tauri::command]
pub fn upsert_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
provider: UniversalProvider,
) -> Result<bool, String> {
let id = provider.id.clone();
let result =
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "upsert", &id);
Ok(result)
}
/// 删除统一供应商
#[tauri::command]
pub fn delete_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let result =
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "delete", &id);
Ok(result)
}
/// 同步统一供应商到各应用(手动触发)
#[tauri::command]
pub fn sync_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let result =
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "sync", &id);
Ok(result)
}
+294
View File
@@ -0,0 +1,294 @@
//! 代理服务相关的 Tauri 命令
//!
//! 提供前端调用的 API 接口
use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState;
/// 启动代理服务器(仅启动服务,不接管 Live 配置)
#[tauri::command]
pub async fn start_proxy_server(
state: tauri::State<'_, AppState>,
) -> Result<ProxyServerInfo, String> {
state.proxy_service.start().await
}
/// 停止代理服务器(恢复 Live 配置)
#[tauri::command]
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
state.proxy_service.stop_with_restore().await
}
/// 获取各应用接管状态
#[tauri::command]
pub async fn get_proxy_takeover_status(
state: tauri::State<'_, AppState>,
) -> Result<ProxyTakeoverStatus, String> {
state.proxy_service.get_takeover_status().await
}
/// 为指定应用开启/关闭接管
#[tauri::command]
pub async fn set_proxy_takeover_for_app(
state: tauri::State<'_, AppState>,
app_type: String,
enabled: bool,
) -> Result<(), String> {
state
.proxy_service
.set_takeover_for_app(&app_type, enabled)
.await
}
/// 获取代理服务器状态
#[tauri::command]
pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result<ProxyStatus, String> {
state.proxy_service.get_status().await
}
/// 获取代理配置
#[tauri::command]
pub async fn get_proxy_config(state: tauri::State<'_, AppState>) -> Result<ProxyConfig, String> {
state.proxy_service.get_config().await
}
/// 更新代理配置
#[tauri::command]
pub async fn update_proxy_config(
state: tauri::State<'_, AppState>,
config: ProxyConfig,
) -> Result<(), String> {
state.proxy_service.update_config(&config).await
}
// ==================== Global & Per-App Config ====================
/// 获取全局代理配置
///
/// 返回统一的全局配置字段(代理开关、监听地址、端口、日志开关)
#[tauri::command]
pub async fn get_global_proxy_config(
state: tauri::State<'_, AppState>,
) -> Result<GlobalProxyConfig, String> {
let db = &state.db;
db.get_global_proxy_config()
.await
.map_err(|e| e.to_string())
}
/// 更新全局代理配置
///
/// 更新统一的全局配置字段,会同时更新三行(claude/codex/gemini
#[tauri::command]
pub async fn update_global_proxy_config(
state: tauri::State<'_, AppState>,
config: GlobalProxyConfig,
) -> Result<(), String> {
let db = &state.db;
db.update_global_proxy_config(config)
.await
.map_err(|e| e.to_string())
}
/// 获取指定应用的代理配置
///
/// 返回应用级配置(enabled、auto_failover、超时、熔断器等)
#[tauri::command]
pub async fn get_proxy_config_for_app(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<AppProxyConfig, String> {
let db = &state.db;
db.get_proxy_config_for_app(&app_type)
.await
.map_err(|e| e.to_string())
}
/// 更新指定应用的代理配置
///
/// 更新应用级配置(enabled、auto_failover、超时、熔断器等)
#[tauri::command]
pub async fn update_proxy_config_for_app(
state: tauri::State<'_, AppState>,
config: AppProxyConfig,
) -> Result<(), String> {
let db = &state.db;
db.update_proxy_config_for_app(config)
.await
.map_err(|e| e.to_string())
}
/// 检查代理服务器是否正在运行
#[tauri::command]
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
Ok(state.proxy_service.is_running().await)
}
/// 检查是否处于 Live 接管模式
#[tauri::command]
pub async fn is_live_takeover_active(state: tauri::State<'_, AppState>) -> Result<bool, String> {
state.proxy_service.is_takeover_active().await
}
/// 代理模式下切换供应商(热切换)
#[tauri::command]
pub async fn switch_proxy_provider(
state: tauri::State<'_, AppState>,
app_type: String,
provider_id: String,
) -> Result<(), String> {
state
.proxy_service
.switch_proxy_target(&app_type, &provider_id)
.await
}
// ==================== 故障转移相关命令 ====================
/// 获取供应商健康状态
#[tauri::command]
pub async fn get_provider_health(
state: tauri::State<'_, AppState>,
provider_id: String,
app_type: String,
) -> Result<ProviderHealth, String> {
let db = &state.db;
db.get_provider_health(&provider_id, &app_type)
.await
.map_err(|e| e.to_string())
}
/// 重置熔断器
///
/// 重置后会检查是否应该切回队列中优先级更高的供应商:
/// 1. 检查自动故障转移是否开启
/// 2. 如果恢复的供应商在队列中优先级更高(queue_order 更小),则自动切换
#[tauri::command]
pub async fn reset_circuit_breaker(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
provider_id: String,
app_type: String,
) -> Result<(), String> {
// 1. 重置数据库健康状态
let db = &state.db;
db.update_provider_health(&provider_id, &app_type, true, None)
.await
.map_err(|e| e.to_string())?;
// 2. 如果代理正在运行,重置内存中的熔断器状态
state
.proxy_service
.reset_provider_circuit_breaker(&provider_id, &app_type)
.await?;
// 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取)
// 只有当该应用已被代理接管(enabled=true)且开启了自动故障转移时才执行
let (app_enabled, auto_failover_enabled) = match db.get_proxy_config_for_app(&app_type).await {
Ok(config) => (config.enabled, config.auto_failover_enabled),
Err(e) => {
log::error!("[{app_type}] Failed to read proxy_config: {e}, defaulting to disabled");
(false, false)
}
};
if app_enabled && auto_failover_enabled && state.proxy_service.is_running().await {
// 获取当前供应商 ID
let current_id = db
.get_current_provider(&app_type)
.map_err(|e| e.to_string())?;
if let Some(current_id) = current_id {
// 获取故障转移队列
let queue = db
.get_failover_queue(&app_type)
.map_err(|e| e.to_string())?;
// 找到恢复的供应商和当前供应商在队列中的位置(使用 sort_index
let restored_order = queue
.iter()
.find(|item| item.provider_id == provider_id)
.and_then(|item| item.sort_index);
let current_order = queue
.iter()
.find(|item| item.provider_id == current_id)
.and_then(|item| item.sort_index);
// 如果恢复的供应商优先级更高(sort_index 更小),则切换
if let (Some(restored), Some(current)) = (restored_order, current_order) {
if restored < current {
log::info!(
"[Recovery] 供应商 {provider_id} 已恢复且优先级更高 (P{restored} vs P{current}),自动切换"
);
// 获取供应商名称用于日志和事件
let provider_name = db
.get_all_providers(&app_type)
.ok()
.and_then(|providers| providers.get(&provider_id).map(|p| p.name.clone()))
.unwrap_or_else(|| provider_id.clone());
// 创建故障转移切换管理器并执行切换
let switch_manager =
crate::proxy::failover_switch::FailoverSwitchManager::new(db.clone());
if let Err(e) = switch_manager
.try_switch(Some(&app_handle), &app_type, &provider_id, &provider_name)
.await
{
log::error!("[Recovery] 自动切换失败: {e}");
}
}
}
}
}
Ok(())
}
/// 获取熔断器配置
#[tauri::command]
pub async fn get_circuit_breaker_config(
state: tauri::State<'_, AppState>,
) -> Result<CircuitBreakerConfig, String> {
let db = &state.db;
db.get_circuit_breaker_config()
.await
.map_err(|e| e.to_string())
}
/// 更新熔断器配置
#[tauri::command]
pub async fn update_circuit_breaker_config(
state: tauri::State<'_, AppState>,
config: CircuitBreakerConfig,
) -> Result<(), String> {
let db = &state.db;
// 1. 更新数据库配置
db.update_circuit_breaker_config(&config)
.await
.map_err(|e| e.to_string())?;
// 2. 如果代理正在运行,热更新内存中的熔断器配置
state
.proxy_service
.update_circuit_breaker_configs(config)
.await?;
Ok(())
}
/// 获取熔断器统计信息(仅当代理服务器运行时)
#[tauri::command]
pub async fn get_circuit_breaker_stats(
state: tauri::State<'_, AppState>,
provider_id: String,
app_type: String,
) -> Result<Option<CircuitBreakerStats>, String> {
// 这个功能需要访问运行中的代理服务器的内存状态
// 目前先返回 None,后续可以通过 ProxyService 暴露接口来实现
let _ = (state, provider_id, app_type);
Ok(None)
}
+180 -105
View File
@@ -1,66 +1,177 @@
//! Skills 命令层
//!
//! v3.10.0+ 统一管理架构:
//! - 支持三应用开关(Claude/Codex/Gemini
//! - SSOT 存储在 ~/.cc-switch/skills/
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
use crate::error::format_skill_error;
use crate::services::skill::SkillState;
use crate::services::{Skill, SkillRepo, SkillService};
use crate::services::skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
use crate::store::AppState;
use chrono::Utc;
use std::sync::Arc;
use tauri::State;
/// SkillService 状态包装
pub struct SkillServiceState(pub Arc<SkillService>);
/// 解析 app 参数为 AppType
fn parse_app_type(app: &str) -> Result<AppType, String> {
match app.to_lowercase().as_str() {
"claude" => Ok(AppType::Claude),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini),
_ => Err(format!("不支持的 app 类型: {app}")),
}
}
// ========== 统一管理命令 ==========
/// 获取所有已安装的 Skills
#[tauri::command]
pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<InstalledSkill>, String> {
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
}
/// 安装 Skill(新版统一安装)
///
/// 参数:
/// - skill: 从发现列表获取的技能信息
/// - current_app: 当前选中的应用,安装后默认启用该应用
#[tauri::command]
pub async fn install_skill_unified(
skill: DiscoverableSkill,
current_app: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<InstalledSkill, String> {
let app_type = parse_app_type(&current_app)?;
service
.0
.install(&app_state.db, &skill, &app_type)
.await
.map_err(|e| e.to_string())
}
/// 卸载 Skill(新版统一卸载)
#[tauri::command]
pub fn uninstall_skill_unified(id: String, app_state: State<'_, AppState>) -> Result<bool, String> {
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())?;
Ok(true)
}
/// 切换 Skill 的应用启用状态
#[tauri::command]
pub fn toggle_skill_app(
id: String,
app: String,
enabled: bool,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
let app_type = parse_app_type(&app)?;
SkillService::toggle_app(&app_state.db, &id, &app_type, enabled).map_err(|e| e.to_string())?;
Ok(true)
}
/// 扫描未管理的 Skills
#[tauri::command]
pub fn scan_unmanaged_skills(
app_state: State<'_, AppState>,
) -> Result<Vec<UnmanagedSkill>, String> {
SkillService::scan_unmanaged(&app_state.db).map_err(|e| e.to_string())
}
/// 从应用目录导入 Skills
#[tauri::command]
pub fn import_skills_from_apps(
directories: Vec<String>,
app_state: State<'_, AppState>,
) -> Result<Vec<InstalledSkill>, String> {
SkillService::import_from_apps(&app_state.db, directories).map_err(|e| e.to_string())
}
// ========== 发现功能命令 ==========
/// 发现可安装的 Skills(从仓库获取)
#[tauri::command]
pub async fn discover_available_skills(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<DiscoverableSkill>, String> {
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
service
.0
.discover_available(repos)
.await
.map_err(|e| e.to_string())
}
// ========== 兼容旧 API 的命令 ==========
/// 获取技能列表(兼容旧 API)
#[tauri::command]
pub async fn get_skills(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> {
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
let skills = service
service
.0
.list_skills(repos)
.list_skills(repos, &app_state.db)
.await
.map_err(|e| e.to_string())?;
// 自动同步本地已安装的 skills 到数据库
// 这样用户在首次运行时,已有的 skills 会被自动记录
let existing_states = app_state.db.get_skills().unwrap_or_default();
for skill in &skills {
if skill.installed && !existing_states.contains_key(&skill.directory) {
// 本地有该 skill,但数据库中没有记录,自动添加
if let Err(e) = app_state.db.update_skill_state(
&skill.directory,
&SkillState {
installed: true,
installed_at: Utc::now(),
},
) {
log::warn!("同步本地 skill {} 状态到数据库失败: {}", skill.directory, e);
}
}
}
Ok(skills)
.map_err(|e| e.to_string())
}
/// 获取指定应用的技能列表(兼容旧 API)
#[tauri::command]
pub async fn get_skills_for_app(
app: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> {
// 新版本不再区分应用,统一返回所有技能
let _ = parse_app_type(&app)?; // 验证 app 参数有效
get_skills(service, app_state).await
}
/// 安装技能(兼容旧 API
#[tauri::command]
pub async fn install_skill(
directory: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
// 先在不持有写锁的情况下收集仓库与技能信息
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
install_skill_for_app("claude".to_string(), directory, service, app_state).await
}
/// 安装指定应用的技能(兼容旧 API)
#[tauri::command]
pub async fn install_skill_for_app(
app: String,
directory: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
let app_type = parse_app_type(&app)?;
// 先获取技能信息
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
let skills = service
.0
.list_skills(repos)
.discover_available(repos)
.await
.map_err(|e| e.to_string())?;
let skill = skills
.iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.into_iter()
.find(|s| {
let install_name = std::path::Path::new(&s.directory)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| s.directory.clone());
install_name.eq_ignore_ascii_case(&directory)
|| s.directory.eq_ignore_ascii_case(&directory)
})
.ok_or_else(|| {
format_skill_error(
"SKILL_NOT_FOUND",
@@ -69,90 +180,54 @@ pub async fn install_skill(
)
})?;
if !skill.installed {
let repo = SkillRepo {
owner: skill.repo_owner.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "owner")],
None,
)
})?,
name: skill.repo_name.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "name")],
None,
)
})?,
branch: skill
.repo_branch
.clone()
.unwrap_or_else(|| "main".to_string()),
enabled: true,
};
service
.0
.install_skill(directory.clone(), repo)
.await
.map_err(|e| e.to_string())?;
}
app_state
.db
.update_skill_state(
&directory,
&SkillState {
installed: true,
installed_at: Utc::now(),
},
)
.map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command]
pub fn uninstall_skill(
directory: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
service
.0
.uninstall_skill(directory.clone())
.map_err(|e| e.to_string())?;
// Remove from database by setting installed = false
app_state
.db
.update_skill_state(
&directory,
&SkillState {
installed: false,
installed_at: Utc::now(),
},
)
.install(&app_state.db, &skill, &app_type)
.await
.map_err(|e| e.to_string())?;
Ok(true)
}
/// 卸载技能(兼容旧 API
#[tauri::command]
pub fn get_skill_repos(
_service: State<'_, SkillServiceState>,
pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result<bool, String> {
uninstall_skill_for_app("claude".to_string(), directory, app_state)
}
/// 卸载指定应用的技能(兼容旧 API)
#[tauri::command]
pub fn uninstall_skill_for_app(
app: String,
directory: String,
app_state: State<'_, AppState>,
) -> Result<Vec<SkillRepo>, String> {
) -> Result<bool, String> {
let _ = parse_app_type(&app)?; // 验证参数
// 通过 directory 找到对应的 skill id
let skills = SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())?;
let skill = skills
.into_iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.ok_or_else(|| format!("未找到已安装的 Skill: {directory}"))?;
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())?;
Ok(true)
}
// ========== 仓库管理命令 ==========
/// 获取技能仓库列表
#[tauri::command]
pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result<Vec<SkillRepo>, String> {
app_state.db.get_skill_repos().map_err(|e| e.to_string())
}
/// 添加技能仓库
#[tauri::command]
pub fn add_skill_repo(
repo: SkillRepo,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
app_state
.db
.save_skill_repo(&repo)
@@ -160,11 +235,11 @@ pub fn add_skill_repo(
Ok(true)
}
/// 删除技能仓库
#[tauri::command]
pub fn remove_skill_repo(
owner: String,
name: String,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
app_state
+106
View File
@@ -0,0 +1,106 @@
//! 流式健康检查命令
use crate::app_config::AppType;
use crate::error::AppError;
use crate::services::stream_check::{
HealthStatus, StreamCheckConfig, StreamCheckResult, StreamCheckService,
};
use crate::store::AppState;
use std::collections::HashSet;
use tauri::State;
/// 流式健康检查(单个供应商)
#[tauri::command]
pub async fn stream_check_provider(
state: State<'_, AppState>,
app_type: AppType,
provider_id: String,
) -> Result<StreamCheckResult, AppError> {
let config = state.db.get_stream_check_config()?;
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers
.get(&provider_id)
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
let result = StreamCheckService::check_with_retry(&app_type, provider, &config).await?;
// 记录日志
let _ =
state
.db
.save_stream_check_log(&provider_id, &provider.name, app_type.as_str(), &result);
Ok(result)
}
/// 批量流式健康检查
#[tauri::command]
pub async fn stream_check_all_providers(
state: State<'_, AppState>,
app_type: AppType,
proxy_targets_only: bool,
) -> Result<Vec<(String, StreamCheckResult)>, AppError> {
let config = state.db.get_stream_check_config()?;
let providers = state.db.get_all_providers(app_type.as_str())?;
let mut results = Vec::new();
let allowed_ids: Option<HashSet<String>> = if proxy_targets_only {
let mut ids = HashSet::new();
if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) {
ids.insert(current_id);
}
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
for item in queue {
ids.insert(item.provider_id);
}
}
Some(ids)
} else {
None
};
for (id, provider) in providers {
if let Some(ids) = &allowed_ids {
if !ids.contains(&id) {
continue;
}
}
let result = StreamCheckService::check_with_retry(&app_type, &provider, &config)
.await
.unwrap_or_else(|e| StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: e.to_string(),
response_time_ms: None,
http_status: None,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
retry_count: 0,
});
let _ = state
.db
.save_stream_check_log(&id, &provider.name, app_type.as_str(), &result);
results.push((id, result));
}
Ok(results)
}
/// 获取流式检查配置
#[tauri::command]
pub fn get_stream_check_config(state: State<'_, AppState>) -> Result<StreamCheckConfig, AppError> {
state.db.get_stream_check_config()
}
/// 保存流式检查配置
#[tauri::command]
pub fn save_stream_check_config(
state: State<'_, AppState>,
config: StreamCheckConfig,
) -> Result<(), AppError> {
state.db.save_stream_check_config(&config)
}
+179
View File
@@ -0,0 +1,179 @@
//! 使用统计相关命令
use crate::error::AppError;
use crate::services::usage_stats::*;
use crate::store::AppState;
use tauri::State;
/// 获取使用量汇总
#[tauri::command]
pub fn get_usage_summary(
state: State<'_, AppState>,
start_date: Option<i64>,
end_date: Option<i64>,
) -> Result<UsageSummary, AppError> {
state.db.get_usage_summary(start_date, end_date)
}
/// 获取每日趋势
#[tauri::command]
pub fn get_usage_trends(
state: State<'_, AppState>,
start_date: Option<i64>,
end_date: Option<i64>,
) -> Result<Vec<DailyStats>, AppError> {
state.db.get_daily_trends(start_date, end_date)
}
/// 获取 Provider 统计
#[tauri::command]
pub fn get_provider_stats(state: State<'_, AppState>) -> Result<Vec<ProviderStats>, AppError> {
state.db.get_provider_stats()
}
/// 获取模型统计
#[tauri::command]
pub fn get_model_stats(state: State<'_, AppState>) -> Result<Vec<ModelStats>, AppError> {
state.db.get_model_stats()
}
/// 获取请求日志列表
#[tauri::command]
pub fn get_request_logs(
state: State<'_, AppState>,
filters: LogFilters,
page: u32,
page_size: u32,
) -> Result<PaginatedLogs, AppError> {
state.db.get_request_logs(&filters, page, page_size)
}
/// 获取单个请求详情
#[tauri::command]
pub fn get_request_detail(
state: State<'_, AppState>,
request_id: String,
) -> Result<Option<RequestLogDetail>, AppError> {
state.db.get_request_detail(&request_id)
}
/// 获取模型定价列表
#[tauri::command]
pub fn get_model_pricing(state: State<'_, AppState>) -> Result<Vec<ModelPricingInfo>, AppError> {
log::info!("获取模型定价列表");
state.db.ensure_model_pricing_seeded()?;
let db = state.db.clone();
let conn = crate::database::lock_conn!(db.conn);
// 检查表是否存在
let table_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='model_pricing'",
[],
|row| row.get::<_, i64>(0).map(|count| count > 0),
)
.unwrap_or(false);
if !table_exists {
log::error!("model_pricing 表不存在,可能需要重启应用以触发数据库迁移");
return Ok(Vec::new());
}
let mut stmt = conn.prepare(
"SELECT model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing
ORDER BY display_name",
)?;
let rows = stmt.query_map([], |row| {
Ok(ModelPricingInfo {
model_id: row.get(0)?,
display_name: row.get(1)?,
input_cost_per_million: row.get(2)?,
output_cost_per_million: row.get(3)?,
cache_read_cost_per_million: row.get(4)?,
cache_creation_cost_per_million: row.get(5)?,
})
})?;
let mut pricing = Vec::new();
for row in rows {
pricing.push(row?);
}
log::info!("成功获取 {} 条模型定价数据", pricing.len());
Ok(pricing)
}
/// 更新模型定价
#[tauri::command]
pub fn update_model_pricing(
state: State<'_, AppState>,
model_id: String,
display_name: String,
input_cost: String,
output_cost: String,
cache_read_cost: String,
cache_creation_cost: String,
) -> Result<(), AppError> {
let db = state.db.clone();
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
model_id,
display_name,
input_cost,
output_cost,
cache_read_cost,
cache_creation_cost
],
)
.map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?;
Ok(())
}
/// 检查 Provider 使用限额
#[tauri::command]
pub fn check_provider_limits(
state: State<'_, AppState>,
provider_id: String,
app_type: String,
) -> Result<crate::services::usage_stats::ProviderLimitStatus, AppError> {
state.db.check_provider_limits(&provider_id, &app_type)
}
/// 删除模型定价
#[tauri::command]
pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Result<(), AppError> {
let db = state.db.clone();
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"DELETE FROM model_pricing WHERE model_id = ?1",
rusqlite::params![model_id],
)
.map_err(|e| AppError::Database(format!("删除模型定价失败: {e}")))?;
log::info!("已删除模型定价: {model_id}");
Ok(())
}
/// 模型定价信息
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelPricingInfo {
pub model_id: String,
pub display_name: String,
pub input_cost_per_million: String,
pub output_cost_per_million: String,
pub cache_read_cost_per_million: String,
pub cache_creation_cost_per_million: String,
}
+10 -6
View File
@@ -5,22 +5,26 @@ use std::path::{Path, PathBuf};
use crate::error::AppError;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Claude Code 配置目录路径
pub fn get_claude_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_claude_override_dir() {
return custom;
}
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".claude")
get_home_dir().join(".claude")
}
/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
pub fn get_default_claude_mcp_path() -> PathBuf {
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".claude.json")
get_home_dir().join(".claude.json")
}
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
+23 -22
View File
@@ -13,6 +13,8 @@ use std::fs;
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
impl Database {
/// 导出为 SQLite 兼容的 SQL 文本
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
@@ -36,7 +38,8 @@ impl Database {
}
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
let sql_content = Self::sanitize_import_sql(&sql_raw);
let sql_content = sql_raw.trim_start_matches('\u{feff}');
Self::validate_cc_switch_sql_export(sql_content)?;
// 导入前备份现有数据库
let backup_path = self.backup_database_file()?;
@@ -51,7 +54,7 @@ impl Database {
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
temp_conn
.execute_batch(&sql_content)
.execute_batch(sql_content)
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
// 补齐缺失表/索引并进行基础校验
@@ -93,26 +96,17 @@ impl Database {
Ok(snapshot)
}
/// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错
fn sanitize_import_sql(sql: &str) -> String {
let mut cleaned = String::new();
let lower_keyword = "sqlite_sequence";
for stmt in sql.split(';') {
let trimmed = stmt.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.to_ascii_lowercase().contains(lower_keyword) {
continue;
}
cleaned.push_str(trimmed);
cleaned.push_str(";\n");
fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> {
let trimmed = sql.trim_start();
if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) {
return Ok(());
}
cleaned
Err(AppError::localized(
"backup.sql.invalid_format",
"仅支持导入由 CC Switch 导出的 SQL 备份文件。",
"Only SQL backups exported by CC Switch are supported.",
))
}
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
@@ -129,8 +123,15 @@ impl Database {
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
let backup_path = backup_dir.join(format!("{backup_id}.db"));
let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
let mut backup_id = base_id.clone();
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
let mut counter = 1;
while backup_path.exists() {
backup_id = format!("{base_id}_{counter}");
backup_path = backup_dir.join(format!("{backup_id}.db"));
counter += 1;
}
{
let conn = lock_conn!(self.conn);
+146
View File
@@ -0,0 +1,146 @@
//! 故障转移队列 DAO
//!
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::provider::Provider;
use serde::{Deserialize, Serialize};
/// 故障转移队列条目(简化版,用于前端展示)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FailoverQueueItem {
pub provider_id: String,
pub provider_name: String,
pub sort_index: Option<usize>,
}
impl Database {
/// 获取故障转移队列(按 sort_index 排序)
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, name, sort_index
FROM providers
WHERE app_type = ?1 AND in_failover_queue = 1
ORDER BY COALESCE(sort_index, 999999), id ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let items = stmt
.query_map([app_type], |row| {
Ok(FailoverQueueItem {
provider_id: row.get(0)?,
provider_name: row.get(1)?,
sort_index: row.get(2)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(items)
}
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
let all_providers = self.get_all_providers(app_type)?;
let result: Vec<Provider> = all_providers
.into_values()
.filter(|p| p.in_failover_queue)
.collect();
Ok(result)
}
/// 添加供应商到故障转移队列
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE providers SET in_failover_queue = 1 WHERE id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 从故障转移队列中移除供应商
pub fn remove_from_failover_queue(
&self,
app_type: &str,
provider_id: &str,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 1. 从队列中移除
conn.execute(
"UPDATE providers SET in_failover_queue = 0 WHERE id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 2. 清除该供应商的健康状态(退出队列后不再需要健康监控)
conn.execute(
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::info!("已从故障转移队列移除供应商 {provider_id} ({app_type}), 并清除其健康状态");
Ok(())
}
/// 清空故障转移队列
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE providers SET in_failover_queue = 0 WHERE app_type = ?1",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 检查供应商是否在故障转移队列中
pub fn is_in_failover_queue(
&self,
app_type: &str,
provider_id: &str,
) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let in_queue: bool = conn
.query_row(
"SELECT in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
|row| row.get(0),
)
.unwrap_or(false);
Ok(in_queue)
}
/// 获取可添加到故障转移队列的供应商(不在队列中的)
pub fn get_available_providers_for_failover(
&self,
app_type: &str,
) -> Result<Vec<Provider>, AppError> {
let all_providers = self.get_all_providers(app_type)?;
let available: Vec<Provider> = all_providers
.into_values()
.filter(|p| !p.in_failover_queue)
.collect();
Ok(available)
}
}
+5 -2
View File
@@ -73,11 +73,14 @@ impl Database {
params![
server.id,
server.name,
serde_json::to_string(&server.server).unwrap(),
serde_json::to_string(&server.server).map_err(|e| AppError::Database(format!(
"Failed to serialize server config: {e}"
)))?,
server.description,
server.homepage,
server.docs,
serde_json::to_string(&server.tags).unwrap(),
serde_json::to_string(&server.tags)
.map_err(|e| AppError::Database(format!("Failed to serialize tags: {e}")))?,
server.apps.claude,
server.apps.codex,
server.apps.gemini,
+13 -7
View File
@@ -1,11 +1,17 @@
//! 数据访问对象 (DAO) 模块
//! Data Access Object layer
//!
//! 提供各类数据的 CRUD 操作。
//! Database access operations for each domain
mod mcp;
mod prompts;
mod providers;
mod settings;
mod skills;
pub mod failover;
pub mod mcp;
pub mod prompts;
pub mod providers;
pub mod proxy;
pub mod settings;
pub mod skills;
pub mod stream_check;
pub mod universal_providers;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
// 导出 FailoverQueueItem 供外部使用
pub use failover::FailoverQueueItem;
+99 -14
View File
@@ -17,7 +17,7 @@ impl Database {
) -> Result<IndexMap<String, Provider>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
FROM providers WHERE app_type = ?1
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -35,6 +35,7 @@ impl Database {
let icon: Option<String> = row.get(8)?;
let icon_color: Option<String> = row.get(9)?;
let meta_str: String = row.get(10)?;
let in_failover_queue: bool = row.get(11)?;
let settings_config =
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
@@ -54,6 +55,7 @@ impl Database {
meta: Some(meta),
icon,
icon_color,
in_failover_queue,
},
))
})
@@ -121,6 +123,57 @@ impl Database {
}
}
/// 根据 ID 获取单个供应商
pub fn get_provider_by_id(
&self,
id: &str,
app_type: &str,
) -> Result<Option<Provider>, AppError> {
let conn = lock_conn!(self.conn);
let result = conn.query_row(
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
FROM providers WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
|row| {
let name: String = row.get(0)?;
let settings_config_str: String = row.get(1)?;
let website_url: Option<String> = row.get(2)?;
let category: Option<String> = row.get(3)?;
let created_at: Option<i64> = row.get(4)?;
let sort_index: Option<usize> = row.get(5)?;
let notes: Option<String> = row.get(6)?;
let icon: Option<String> = row.get(7)?;
let icon_color: Option<String> = row.get(8)?;
let meta_str: String = row.get(9)?;
let in_failover_queue: bool = row.get(10)?;
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
Ok(Provider {
id: id.to_string(),
name,
settings_config,
website_url,
category,
created_at,
sort_index,
notes,
meta: Some(meta),
icon,
icon_color,
in_failover_queue,
})
},
);
match result {
Ok(provider) => Ok(Some(provider)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 保存供应商(新增或更新)
///
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
@@ -135,17 +188,18 @@ impl Database {
let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
// 检查是否存在(用于判断新增/更新,以及保留 is_current
let existing: Option<bool> = tx
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue
let existing: Option<(bool, bool)> = tx
.query_row(
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
params![provider.id, app_type],
|row| row.get(0),
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
let is_update = existing.is_some();
let is_current = existing.unwrap_or(false);
let (is_current, in_failover_queue) =
existing.unwrap_or((false, provider.in_failover_queue));
if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
@@ -161,11 +215,14 @@ impl Database {
icon = ?8,
icon_color = ?9,
meta = ?10,
is_current = ?11
WHERE id = ?12 AND app_type = ?13",
is_current = ?11,
in_failover_queue = ?12
WHERE id = ?13 AND app_type = ?14",
params![
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
serde_json::to_string(&provider.settings_config).map_err(|e| {
AppError::Database(format!("Failed to serialize settings_config: {e}"))
})?,
provider.website_url,
provider.category,
provider.created_at,
@@ -173,8 +230,11 @@ impl Database {
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
serde_json::to_string(&meta_clone).map_err(|e| AppError::Database(format!(
"Failed to serialize meta: {e}"
)))?,
is_current,
in_failover_queue,
provider.id,
app_type,
],
@@ -185,13 +245,14 @@ impl Database {
tx.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta, is_current
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
created_at, sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![
provider.id,
app_type,
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
serde_json::to_string(&provider.settings_config)
.map_err(|e| AppError::Database(format!("Failed to serialize settings_config: {e}")))?,
provider.website_url,
provider.category,
provider.created_at,
@@ -199,8 +260,10 @@ impl Database {
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
serde_json::to_string(&meta_clone)
.map_err(|e| AppError::Database(format!("Failed to serialize meta: {e}")))?,
is_current,
in_failover_queue,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -256,6 +319,28 @@ 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).map_err(|e| AppError::Database(format!(
"Failed to serialize settings_config: {e}"
)))?,
provider_id,
app_type
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 添加自定义端点
pub fn add_custom_endpoint(
&self,
+617
View File
@@ -0,0 +1,617 @@
//! 代理功能数据访问层
//!
//! 处理代理配置、Provider健康状态和使用统计的数据库操作
use crate::error::AppError;
use crate::proxy::types::*;
use super::super::{lock_conn, Database};
impl Database {
// ==================== Global Proxy Config ====================
/// 获取全局代理配置(统一字段)
///
/// 从 claude 行读取(三行镜像一致)
pub async fn get_global_proxy_config(&self) -> Result<GlobalProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT proxy_enabled, listen_address, listen_port, enable_logging
FROM proxy_config WHERE app_type = 'claude'",
[],
|row| {
Ok(GlobalProxyConfig {
proxy_enabled: row.get::<_, i32>(0)? != 0,
listen_address: row.get(1)?,
listen_port: row.get::<_, i32>(2)? as u16,
enable_logging: row.get::<_, i32>(3)? != 0,
})
},
)
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,创建默认配置
self.init_proxy_config_rows().await?;
Ok(GlobalProxyConfig {
proxy_enabled: false,
listen_address: "127.0.0.1".to_string(),
listen_port: 15721,
enable_logging: true,
})
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新全局代理配置(镜像写三行)
pub async fn update_global_proxy_config(
&self,
config: GlobalProxyConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE proxy_config SET
proxy_enabled = ?1,
listen_address = ?2,
listen_port = ?3,
enable_logging = ?4,
updated_at = datetime('now')",
rusqlite::params![
if config.proxy_enabled { 1 } else { 0 },
config.listen_address,
config.listen_port as i32,
if config.enable_logging { 1 } else { 0 },
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 获取应用级代理配置
pub async fn get_proxy_config_for_app(
&self,
app_type: &str,
) -> Result<AppProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let app_type_owned = app_type.to_string();
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT app_type, enabled, auto_failover_enabled,
max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
FROM proxy_config WHERE app_type = ?1",
[app_type],
|row| {
Ok(AppProxyConfig {
app_type: row.get(0)?,
enabled: row.get::<_, i32>(1)? != 0,
auto_failover_enabled: row.get::<_, i32>(2)? != 0,
max_retries: row.get::<_, i32>(3)? as u32,
streaming_first_byte_timeout: row.get::<_, i32>(4)? as u32,
streaming_idle_timeout: row.get::<_, i32>(5)? as u32,
non_streaming_timeout: row.get::<_, i32>(6)? as u32,
circuit_failure_threshold: row.get::<_, i32>(7)? as u32,
circuit_success_threshold: row.get::<_, i32>(8)? as u32,
circuit_timeout_seconds: row.get::<_, i32>(9)? as u32,
circuit_error_rate_threshold: row.get(10)?,
circuit_min_requests: row.get::<_, i32>(11)? as u32,
})
},
)
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,创建默认配置
self.init_proxy_config_rows().await?;
Ok(AppProxyConfig {
app_type: app_type_owned,
enabled: false,
auto_failover_enabled: false,
max_retries: 3,
streaming_first_byte_timeout: 60,
streaming_idle_timeout: 120,
non_streaming_timeout: 600,
circuit_failure_threshold: 4,
circuit_success_threshold: 2,
circuit_timeout_seconds: 60,
circuit_error_rate_threshold: 0.6,
circuit_min_requests: 10,
})
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新应用级代理配置
pub async fn update_proxy_config_for_app(
&self,
config: AppProxyConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE proxy_config SET
enabled = ?2,
auto_failover_enabled = ?3,
max_retries = ?4,
streaming_first_byte_timeout = ?5,
streaming_idle_timeout = ?6,
non_streaming_timeout = ?7,
circuit_failure_threshold = ?8,
circuit_success_threshold = ?9,
circuit_timeout_seconds = ?10,
circuit_error_rate_threshold = ?11,
circuit_min_requests = ?12,
updated_at = datetime('now')
WHERE app_type = ?1",
rusqlite::params![
config.app_type,
if config.enabled { 1 } else { 0 },
if config.auto_failover_enabled { 1 } else { 0 },
config.max_retries as i32,
config.streaming_first_byte_timeout as i32,
config.streaming_idle_timeout as i32,
config.non_streaming_timeout as i32,
config.circuit_failure_threshold as i32,
config.circuit_success_threshold as i32,
config.circuit_timeout_seconds as i32,
config.circuit_error_rate_threshold,
config.circuit_min_requests as i32,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 初始化 proxy_config 表的三行数据
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
for app_type in &["claude", "codex", "gemini"] {
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
}
// ==================== Legacy Proxy Config (兼容旧代码) ====================
/// 获取代理配置(兼容旧接口,返回 claude 行的配置)
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT listen_address, listen_port, max_retries,
enable_logging,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
FROM proxy_config WHERE app_type = 'claude'",
[],
|row| {
Ok(ProxyConfig {
listen_address: row.get(0)?,
listen_port: row.get::<_, i32>(1)? as u16,
max_retries: row.get::<_, i32>(2)? as u8,
request_timeout: 600, // 废弃字段,返回默认值
enable_logging: row.get::<_, i32>(3)? != 0,
live_takeover_active: false, // 废弃字段
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(60) as u64,
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(120) as u64,
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(600) as u64,
})
},
)
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,初始化默认配置
self.init_proxy_config_rows().await?;
Ok(ProxyConfig::default())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新代理配置(兼容旧接口,更新所有三行的公共字段)
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 更新所有三行的公共字段
conn.execute(
"UPDATE proxy_config SET
listen_address = ?1,
listen_port = ?2,
max_retries = ?3,
enable_logging = ?4,
streaming_first_byte_timeout = ?5,
streaming_idle_timeout = ?6,
non_streaming_timeout = ?7,
updated_at = datetime('now')",
rusqlite::params![
config.listen_address,
config.listen_port as i32,
config.max_retries as i32,
if config.enable_logging { 1 } else { 0 },
config.streaming_first_byte_timeout as i32,
config.streaming_idle_timeout as i32,
config.non_streaming_timeout as i32,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 设置 Live 接管状态(兼容旧版本,更新 enabled 字段)
pub async fn set_live_takeover_active(&self, _active: bool) -> Result<(), AppError> {
// 不再使用此字段,由 enabled 字段替代
// 保留空实现以兼容旧代码
Ok(())
}
/// 检查是否处于 Live 接管模式
///
/// 检查是否有任一 app 的 enabled = true
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM proxy_config WHERE enabled = 1",
[],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
}
// ==================== Provider Health ====================
/// 获取Provider健康状态
pub async fn get_provider_health(
&self,
provider_id: &str,
app_type: &str,
) -> Result<ProviderHealth, AppError> {
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
last_success_at, last_failure_at, last_error, updated_at
FROM provider_health
WHERE provider_id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
|row| {
Ok(ProviderHealth {
provider_id: row.get(0)?,
app_type: row.get(1)?,
is_healthy: row.get::<_, i64>(2)? != 0,
consecutive_failures: row.get::<_, i64>(3)? as u32,
last_success_at: row.get(4)?,
last_failure_at: row.get(5)?,
last_error: row.get(6)?,
updated_at: row.get(7)?,
})
},
)
};
match result {
Ok(health) => Ok(health),
// 缺少记录时视为健康(关闭后清空状态,再次打开时默认正常)
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ProviderHealth {
provider_id: provider_id.to_string(),
app_type: app_type.to_string(),
is_healthy: true,
consecutive_failures: 0,
last_success_at: None,
last_failure_at: None,
last_error: None,
updated_at: chrono::Utc::now().to_rfc3339(),
}),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新Provider健康状态
///
/// 使用默认阈值(5)判断是否健康,建议使用 `update_provider_health_with_threshold` 传入配置的阈值
pub async fn update_provider_health(
&self,
provider_id: &str,
app_type: &str,
success: bool,
error_msg: Option<String>,
) -> Result<(), AppError> {
// 默认阈值与 CircuitBreakerConfig::default() 保持一致
self.update_provider_health_with_threshold(provider_id, app_type, success, error_msg, 5)
.await
}
/// 更新Provider健康状态(带阈值参数)
///
/// # Arguments
/// * `failure_threshold` - 连续失败多少次后标记为不健康
pub async fn update_provider_health_with_threshold(
&self,
provider_id: &str,
app_type: &str,
success: bool,
error_msg: Option<String>,
failure_threshold: u32,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
let now = chrono::Utc::now().to_rfc3339();
// 先查询当前状态
let current = conn.query_row(
"SELECT consecutive_failures FROM provider_health
WHERE provider_id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
|row| Ok(row.get::<_, i64>(0)? as u32),
);
let (is_healthy, consecutive_failures) = if success {
// 成功:重置失败计数
(1, 0)
} else {
// 失败:增加失败计数
let failures = current.unwrap_or(0) + 1;
// 使用传入的阈值而非硬编码
let healthy = if failures >= failure_threshold { 0 } else { 1 };
(healthy, failures)
};
let (last_success_at, last_failure_at) = if success {
(Some(now.clone()), None)
} else {
(None, Some(now.clone()))
};
// UPSERT
conn.execute(
"INSERT OR REPLACE INTO provider_health
(provider_id, app_type, is_healthy, consecutive_failures,
last_success_at, last_failure_at, last_error, updated_at)
VALUES (?1, ?2, ?3, ?4,
COALESCE(?5, (SELECT last_success_at FROM provider_health
WHERE provider_id = ?1 AND app_type = ?2)),
COALESCE(?6, (SELECT last_failure_at FROM provider_health
WHERE provider_id = ?1 AND app_type = ?2)),
?7, ?8)",
rusqlite::params![
provider_id,
app_type,
is_healthy,
consecutive_failures as i64,
last_success_at,
last_failure_at,
error_msg,
&now,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 重置Provider健康状态
pub async fn reset_provider_health(
&self,
provider_id: &str,
app_type: &str,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::debug!("Reset health status for provider {provider_id} (app: {app_type})");
Ok(())
}
/// 清空指定应用的健康状态(关闭单个代理时使用)
pub async fn clear_provider_health_for_app(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM provider_health WHERE app_type = ?1",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::debug!("Cleared provider health records for app {app_type}");
Ok(())
}
/// 清空所有Provider健康状态(代理停止时调用)
pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM provider_health", [])
.map_err(|e| AppError::Database(e.to_string()))?;
log::debug!("Cleared all provider health records");
Ok(())
}
// ==================== Circuit Breaker Config (Legacy Compatibility) ====================
/// 获取熔断器配置(兼容旧接口,从 claude 行读取)
///
/// 熔断器配置已合并到 proxy_config 表,每 app 独立
/// 此方法保留用于兼容旧代码,建议使用 get_proxy_config_for_app
pub async fn get_circuit_breaker_config(
&self,
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
FROM proxy_config WHERE app_type = 'claude'",
[],
|row| {
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
failure_threshold: row.get::<_, i32>(0)? as u32,
success_threshold: row.get::<_, i32>(1)? as u32,
timeout_seconds: row.get::<_, i64>(2)? as u64,
error_rate_threshold: row.get(3)?,
min_requests: row.get::<_, i32>(4)? as u32,
})
},
)
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,初始化默认配置
self.init_proxy_config_rows().await?;
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig::default())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新熔断器配置(兼容旧接口,更新所有三行)
///
/// 熔断器配置已合并到 proxy_config 表
/// 此方法保留用于兼容旧代码,建议使用 update_proxy_config_for_app
pub async fn update_circuit_breaker_config(
&self,
config: &crate::proxy::circuit_breaker::CircuitBreakerConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 更新所有三行的熔断器配置
conn.execute(
"UPDATE proxy_config SET
circuit_failure_threshold = ?1,
circuit_success_threshold = ?2,
circuit_timeout_seconds = ?3,
circuit_error_rate_threshold = ?4,
circuit_min_requests = ?5,
updated_at = datetime('now')",
rusqlite::params![
config.failure_threshold as i32,
config.success_threshold as i32,
config.timeout_seconds as i64,
config.error_rate_threshold,
config.min_requests as i32,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
// ==================== Live Backup ====================
/// 保存 Live 配置备份
pub async fn save_live_backup(
&self,
app_type: &str,
config_json: &str,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT OR REPLACE INTO proxy_live_backup (app_type, original_config, backed_up_at)
VALUES (?1, ?2, ?3)",
rusqlite::params![app_type, config_json, now],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::info!("已备份 {app_type} Live 配置");
Ok(())
}
/// 检查是否存在任意 Live 配置备份
pub async fn has_any_live_backup(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM proxy_live_backup", [], |row| {
row.get(0)
})
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
}
/// 获取 Live 配置备份
pub async fn get_live_backup(&self, app_type: &str) -> Result<Option<LiveBackup>, AppError> {
let conn = lock_conn!(self.conn);
let result = conn.query_row(
"SELECT app_type, original_config, backed_up_at FROM proxy_live_backup WHERE app_type = ?1",
rusqlite::params![app_type],
|row| {
Ok(LiveBackup {
app_type: row.get(0)?,
original_config: row.get(1)?,
backed_up_at: row.get(2)?,
})
},
);
match result {
Ok(backup) => Ok(Some(backup)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 删除 Live 配置备份
pub async fn delete_live_backup(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM proxy_live_backup WHERE app_type = ?1",
rusqlite::params![app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::info!("已删除 {app_type} Live 配置备份");
Ok(())
}
/// 删除所有 Live 配置备份
pub async fn delete_all_live_backups(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM proxy_live_backup", [])
.map_err(|e| AppError::Database(e.to_string()))?;
log::info!("已删除所有 Live 配置备份");
Ok(())
}
}
+101
View File
@@ -62,4 +62,105 @@ impl Database {
Ok(())
}
}
// --- 全局出站代理 ---
/// 全局代理 URL 的存储键名
const GLOBAL_PROXY_URL_KEY: &'static str = "global_proxy_url";
/// 获取全局出站代理 URL
///
/// 返回 None 表示未配置或已清除代理(直连)
/// 返回 Some(url) 表示已配置代理
pub fn get_global_proxy_url(&self) -> Result<Option<String>, AppError> {
self.get_setting(Self::GLOBAL_PROXY_URL_KEY)
}
/// 设置全局出站代理 URL
///
/// - 传入非空字符串:启用代理
/// - 传入空字符串或 None:清除代理设置(直连)
pub fn set_global_proxy_url(&self, url: Option<&str>) -> Result<(), AppError> {
match url {
Some(u) if !u.trim().is_empty() => {
self.set_setting(Self::GLOBAL_PROXY_URL_KEY, u.trim())
}
_ => {
// 清除代理设置
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM settings WHERE key = ?1",
params![Self::GLOBAL_PROXY_URL_KEY],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
}
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
/// 获取指定应用的代理接管状态
///
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
/// 此方法仅用于数据库迁移时读取旧数据
#[deprecated(since = "3.9.0", note = "使用 get_proxy_config_for_app().enabled 替代")]
pub fn get_proxy_takeover_enabled(&self, app_type: &str) -> Result<bool, AppError> {
let key = format!("proxy_takeover_{app_type}");
match self.get_setting(&key)? {
Some(value) => Ok(value == "true"),
None => Ok(false),
}
}
/// 设置指定应用的代理接管状态
///
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
#[deprecated(
since = "3.9.0",
note = "使用 update_proxy_config_for_app() 修改 enabled 字段"
)]
pub fn set_proxy_takeover_enabled(
&self,
app_type: &str,
enabled: bool,
) -> Result<(), AppError> {
let key = format!("proxy_takeover_{app_type}");
let value = if enabled { "true" } else { "false" };
self.set_setting(&key, value)
}
/// 检查是否有任一应用开启了代理接管
///
/// **已废弃**: 请使用 `is_live_takeover_active()` 替代
#[deprecated(since = "3.9.0", note = "使用 is_live_takeover_active() 替代")]
pub fn has_any_proxy_takeover(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM settings WHERE key LIKE 'proxy_takeover_%' AND value = 'true'",
[],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
}
/// 清除所有代理接管状态(将所有 proxy_takeover_* 设置为 false
///
/// **已废弃**: settings 表不再用于存储代理状态
#[deprecated(
since = "3.9.0",
note = "使用 update_proxy_config_for_app() 清除各应用的 enabled 字段"
)]
pub fn clear_all_proxy_takeover(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE settings SET value = 'false' WHERE key LIKE 'proxy_takeover_%'",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::info!("已清除所有代理接管状态");
Ok(())
}
}
+122 -24
View File
@@ -1,59 +1,156 @@
//! Skills 数据访问对象
//!
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
//!
//! v3.10.0+ 统一管理架构:
//! - Skills 使用统一的 id 主键,支持三应用启用标志
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
use crate::app_config::{InstalledSkill, SkillApps};
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::services::skill::{SkillRepo, SkillState};
use crate::services::skill::SkillRepo;
use indexmap::IndexMap;
use rusqlite::params;
impl Database {
/// 获取所有 Skills 状态
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
// ========== InstalledSkill CRUD ==========
/// 获取所有已安装的 Skills
pub fn get_all_installed_skills(&self) -> Result<IndexMap<String, InstalledSkill>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC")
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let skill_iter = stmt
.query_map([], |row| {
let key: String = row.get(0)?;
let installed: bool = row.get(1)?;
let installed_at_ts: i64 = row.get(2)?;
let installed_at =
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
Ok((
key,
SkillState {
installed,
installed_at,
Ok(InstalledSkill {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
directory: row.get(3)?,
repo_owner: row.get(4)?,
repo_name: row.get(5)?,
repo_branch: row.get(6)?,
readme_url: row.get(7)?,
apps: SkillApps {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
},
))
installed_at: row.get(11)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut skills = IndexMap::new();
for skill_res in skill_iter {
let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
skills.insert(key, skill);
let skill = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
skills.insert(skill.id.clone(), skill);
}
Ok(skills)
}
/// 更新 Skill 状态
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
/// 获取单个已安装的 Skill
pub fn get_installed_skill(&self, id: &str) -> Result<Option<InstalledSkill>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
FROM skills WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let result = stmt.query_row([id], |row| {
Ok(InstalledSkill {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
directory: row.get(3)?,
repo_owner: row.get(4)?,
repo_name: row.get(5)?,
repo_branch: row.get(6)?,
readme_url: row.get(7)?,
apps: SkillApps {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
},
installed_at: row.get(11)?,
})
});
match result {
Ok(skill) => Ok(Some(skill)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 保存 Skill(添加或更新)
pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
params![key, state.installed, state.installed_at.timestamp()],
"INSERT OR REPLACE INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
skill.id,
skill.name,
skill.description,
skill.directory,
skill.repo_owner,
skill.repo_name,
skill.repo_branch,
skill.readme_url,
skill.apps.claude,
skill.apps.codex,
skill.apps.gemini,
skill.installed_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 删除 Skill
pub fn delete_skill(&self, id: &str) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let affected = conn
.execute("DELETE FROM skills WHERE id = ?1", params![id])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
}
/// 清空所有 Skills(用于迁移)
pub fn clear_skills(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM skills", [])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 更新 Skill 的应用启用状态
pub fn update_skill_apps(&self, id: &str, apps: &SkillApps) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let affected = conn
.execute(
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4",
params![apps.claude, apps.codex, apps.gemini, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
}
// ========== SkillRepo CRUD(保持原有) ==========
/// 获取所有 Skill 仓库
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
let conn = lock_conn!(self.conn);
@@ -87,7 +184,8 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
params![repo.owner, repo.name, repo.branch, repo.enabled],
).map_err(|e| AppError::Database(e.to_string()))?;
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
@@ -0,0 +1,57 @@
//! 流式健康检查日志 DAO
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::services::stream_check::{StreamCheckConfig, StreamCheckResult};
impl Database {
/// 保存流式检查日志
pub fn save_stream_check_log(
&self,
provider_id: &str,
provider_name: &str,
app_type: &str,
result: &StreamCheckResult,
) -> Result<i64, AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT INTO stream_check_logs
(provider_id, provider_name, app_type, status, success, message,
response_time_ms, http_status, model_used, retry_count, tested_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
provider_id,
provider_name,
app_type,
format!("{:?}", result.status).to_lowercase(),
result.success,
result.message,
result.response_time_ms.map(|t| t as i64),
result.http_status.map(|s| s as i64),
result.model_used,
result.retry_count as i64,
result.tested_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(conn.last_insert_rowid())
}
/// 获取流式检查配置
pub fn get_stream_check_config(&self) -> Result<StreamCheckConfig, AppError> {
match self.get_setting("stream_check_config")? {
Some(json) => serde_json::from_str(&json)
.map_err(|e| AppError::Message(format!("解析配置失败: {e}"))),
None => Ok(StreamCheckConfig::default()),
}
}
/// 保存流式检查配置
pub fn save_stream_check_config(&self, config: &StreamCheckConfig) -> Result<(), AppError> {
let json = serde_json::to_string(config)
.map_err(|e| AppError::Message(format!("序列化配置失败: {e}")))?;
self.set_setting("stream_check_config", &json)
}
}
@@ -0,0 +1,74 @@
//! 统一供应商 (Universal Provider) DAO
//!
//! 提供统一供应商的 CRUD 操作。
use crate::database::{lock_conn, to_json_string, Database};
use crate::error::AppError;
use crate::provider::UniversalProvider;
use std::collections::HashMap;
/// 统一供应商的 Settings Key
const UNIVERSAL_PROVIDERS_KEY: &str = "universal_providers";
impl Database {
/// 获取所有统一供应商
pub fn get_all_universal_providers(
&self,
) -> Result<HashMap<String, UniversalProvider>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT value FROM settings WHERE key = ?")
.map_err(|e| AppError::Database(e.to_string()))?;
let result: Option<String> = stmt
.query_row([UNIVERSAL_PROVIDERS_KEY], |row| row.get(0))
.ok();
match result {
Some(json) => serde_json::from_str(&json)
.map_err(|e| AppError::Database(format!("解析统一供应商数据失败: {e}"))),
None => Ok(HashMap::new()),
}
}
/// 获取单个统一供应商
pub fn get_universal_provider(&self, id: &str) -> Result<Option<UniversalProvider>, AppError> {
let providers = self.get_all_universal_providers()?;
Ok(providers.get(id).cloned())
}
/// 保存统一供应商(添加或更新)
pub fn save_universal_provider(&self, provider: &UniversalProvider) -> Result<(), AppError> {
let mut providers = self.get_all_universal_providers()?;
providers.insert(provider.id.clone(), provider.clone());
self.save_all_universal_providers(&providers)
}
/// 删除统一供应商
pub fn delete_universal_provider(&self, id: &str) -> Result<bool, AppError> {
let mut providers = self.get_all_universal_providers()?;
let existed = providers.remove(id).is_some();
if existed {
self.save_all_universal_providers(&providers)?;
}
Ok(existed)
}
/// 保存所有统一供应商(内部方法)
fn save_all_universal_providers(
&self,
providers: &HashMap<String, UniversalProvider>,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
let json = to_json_string(providers)?;
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
[UNIVERSAL_PROVIDERS_KEY, &json],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
+10 -7
View File
@@ -192,13 +192,16 @@ impl Database {
tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig,
) -> Result<(), AppError> {
for (key, state) in &config.skills.skills {
tx.execute(
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
params![key, state.installed, state.installed_at.timestamp()],
)
.map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?;
}
// v3.10.0+Skills 的 SSOT 已迁移到文件系统(~/.cc-switch/skills/+ 数据库统一结构。
//
// 旧版 config.json 里的 `skills.skills` 仅记录“安装状态”,但不包含完整元数据,
// 且无法保证 SSOT 目录中一定存在对应的 skill 文件。
//
// 因此这里不再直接把旧的安装状态写入新 skills 表,避免产生“数据库显示已安装但文件缺失”的不一致。
// 迁移后可通过:
// - 前端「导入已有」(扫描各应用的 skills 目录并复制到 SSOT)
// - 或后续启动时的自动扫描逻辑
// 来重建已安装技能记录。
for repo in &config.skills.repos {
tx.execute(
+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 = 3;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
@@ -95,6 +98,7 @@ impl Database {
};
db.create_tables()?;
db.apply_schema_migrations()?;
db.ensure_model_pricing_seeded()?;
Ok(db)
}
@@ -111,6 +115,7 @@ impl Database {
conn: Mutex::new(conn),
};
db.create_tables()?;
db.ensure_model_pricing_seeded()?;
Ok(db)
}
File diff suppressed because it is too large Load Diff
+300 -8
View File
@@ -6,7 +6,7 @@ use super::*;
use crate::app_config::MultiAppConfig;
use crate::provider::{Provider, ProviderManager};
use indexmap::IndexMap;
use rusqlite::Connection;
use rusqlite::{params, Connection};
use serde_json::json;
use std::collections::HashMap;
@@ -51,9 +51,76 @@ const LEGACY_SCHEMA_SQL: &str = r#"
);
"#;
// v3.8.xschema v1)的真实表结构快照:用于验证从 v3.8.* 升级到当前版本的迁移链路
// 参考:tag v3.8.3 的 src-tauri/src/database/schema.rs
const V3_8_SCHEMA_V1_SQL: &str = r#"
CREATE TABLE providers (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
settings_config TEXT NOT NULL,
website_url TEXT,
category TEXT,
created_at INTEGER,
sort_index INTEGER,
notes TEXT,
icon TEXT,
icon_color TEXT,
meta TEXT NOT NULL DEFAULT '{}',
is_current BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id, app_type)
);
CREATE TABLE provider_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
url TEXT NOT NULL,
added_at INTEGER,
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
);
CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
server_config TEXT NOT NULL,
description TEXT,
homepage TEXT,
docs TEXT,
tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
);
CREATE TABLE prompts (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT,
enabled BOOLEAN NOT NULL DEFAULT 1,
created_at INTEGER,
updated_at INTEGER,
PRIMARY KEY (id, app_type)
);
CREATE TABLE skills (
key TEXT PRIMARY KEY,
installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE skill_repos (
owner TEXT NOT NULL,
name TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'main',
enabled BOOLEAN NOT NULL DEFAULT 1,
PRIMARY KEY (owner, name)
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT
);
"#;
#[derive(Debug)]
struct ColumnInfo {
name: String,
r#type: String,
notnull: i64,
default: Option<String>,
@@ -65,10 +132,9 @@ fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo {
.expect("prepare pragma");
let mut rows = stmt.query([]).expect("query pragma");
while let Some(row) = rows.next().expect("read row") {
let name: String = row.get(1).expect("name");
if name.eq_ignore_ascii_case(column) {
let column_name: String = row.get(1).expect("name");
if column_name.eq_ignore_ascii_case(column) {
return ColumnInfo {
name,
r#type: row.get::<_, String>(2).expect("type"),
notnull: row.get::<_, i64>(3).expect("notnull"),
default: row.get::<_, Option<String>>(4).ok().flatten(),
@@ -201,6 +267,171 @@ fn migration_aligns_column_defaults_and_types() {
);
}
#[test]
fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
let conn = Connection::open_in_memory().expect("open memory db");
// 模拟测试版 v2user_version=2,但 proxy_config 仍是单例结构(无 app_type
Database::set_user_version(&conn, 2).expect("set user_version");
conn.execute_batch(
r#"
CREATE TABLE proxy_config (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000,
max_retries INTEGER NOT NULL DEFAULT 3,
request_timeout INTEGER NOT NULL DEFAULT 300,
enable_logging INTEGER NOT NULL DEFAULT 1,
target_app TEXT NOT NULL DEFAULT 'claude',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO proxy_config (id, enabled) VALUES (1, 1);
"#,
)
.expect("seed legacy proxy_config");
Database::create_tables_on_conn(&conn).expect("create tables should repair proxy_config");
assert!(
Database::has_column(&conn, "proxy_config", "app_type").expect("check app_type"),
"proxy_config should be migrated to per-app structure"
);
let count: i32 = conn
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
.expect("count rows");
assert_eq!(count, 3, "per-app proxy_config should have 3 rows");
// 新结构下应能按 app_type 查询
let _: i32 = conn
.query_row(
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'claude'",
[],
|r| r.get(0),
)
.expect("query by app_type");
}
#[test]
fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
let conn = Connection::open_in_memory().expect("open memory db");
conn.execute("PRAGMA foreign_keys = ON;", [])
.expect("enable foreign keys");
// 模拟 v3.8.* 用户的数据库(schema v1
conn.execute_batch(V3_8_SCHEMA_V1_SQL)
.expect("seed v3.8 schema v1");
Database::set_user_version(&conn, 1).expect("set user_version=1");
// 插入一条旧版 Provider + Skill(用于验证迁移不会破坏既有数据)
conn.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta, is_current
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
"p1",
"claude",
"Test Provider",
serde_json::to_string(&json!({ "anthropicApiKey": "sk-test" })).unwrap(),
Option::<String>::None,
Option::<String>::None,
Option::<i64>::None,
Option::<usize>::None,
Option::<String>::None,
Option::<String>::None,
Option::<String>::None,
"{}",
1,
],
)
.expect("seed provider");
conn.execute(
"INSERT INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
params!["claude:demo-skill", 1, 1700000000i64],
)
.expect("seed legacy skill");
// 按应用启动流程:先 create_tables(补齐新增表),再 apply_schema_migrations(按 user_version 迁移)
Database::create_tables_on_conn(&conn).expect("create tables");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
assert_eq!(
Database::get_user_version(&conn).expect("user_version after migration"),
SCHEMA_VERSION
);
// v1 -> v2providers 新增字段必须补齐
for column in [
"cost_multiplier",
"limit_daily_usd",
"limit_monthly_usd",
"provider_type",
"in_failover_queue",
] {
assert!(
Database::has_column(&conn, "providers", column).expect("check column"),
"providers.{column} should exist after migration"
);
}
// 旧 provider 不应丢失,且新增字段应有默认值
let provider_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM providers WHERE id = 'p1' AND app_type = 'claude'",
[],
|r| r.get(0),
)
.expect("count providers");
assert_eq!(provider_count, 1);
let cost_multiplier: String = conn
.query_row(
"SELECT cost_multiplier FROM providers WHERE id = 'p1' AND app_type = 'claude'",
[],
|r| r.get(0),
)
.expect("read cost_multiplier");
assert_eq!(cost_multiplier, "1.0");
// v2 -> v3skills 表重建为统一结构,并设置 pending 标记(后续由启动时扫描文件系统重建数据)
assert!(
Database::has_column(&conn, "skills", "enabled_claude").expect("check skills v3 column"),
"skills table should be migrated to v3 structure"
);
let skills_count: i64 = conn
.query_row("SELECT COUNT(*) FROM skills", [], |r| r.get(0))
.expect("count skills");
assert_eq!(skills_count, 0, "skills table should be rebuilt empty");
let pending: Option<String> = conn
.query_row(
"SELECT value FROM settings WHERE key = 'skills_ssot_migration_pending'",
[],
|r| r.get(0),
)
.ok();
assert!(
matches!(pending.as_deref(), Some("true") | Some("1")),
"skills_ssot_migration_pending should be set after v2->v3 migration"
);
// v3.9+ 新增:proxy_config 三行 seed 必须存在(否则 UI 会查不到默认值)
let proxy_rows: i64 = conn
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
.expect("count proxy_config rows");
assert_eq!(proxy_rows, 3);
// model_pricing 应具备默认数据(迁移时会 seed)
let pricing_rows: i64 = conn
.query_row("SELECT COUNT(*) FROM model_pricing", [], |r| r.get(0))
.expect("count model_pricing rows");
assert!(pricing_rows > 0, "model_pricing should be seeded");
}
#[test]
fn dry_run_does_not_write_to_disk() {
// Create minimal valid config for migration
@@ -245,12 +476,14 @@ fn dry_run_validates_schema_compatibility() {
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
},
);
let mut manager = ProviderManager::default();
manager.providers = providers;
manager.current = "test-provider".to_string();
let manager = ProviderManager {
providers,
current: "test-provider".to_string(),
};
let mut apps = HashMap::new();
apps.insert("claude".to_string(), manager);
@@ -272,3 +505,62 @@ fn dry_run_validates_schema_compatibility() {
"Dry-run should succeed with provider data: {result:?}"
);
}
#[test]
fn model_pricing_is_seeded_on_init() {
let db = Database::memory().expect("create memory db");
let conn = db.conn.lock().expect("lock conn");
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
.expect("count pricing");
assert!(
count > 0,
"模型定价数据应该在初始化时自动填充,实际数量: {}",
count
);
// 验证包含 Claude 模型
let claude_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'claude-%'",
[],
|row| row.get(0),
)
.expect("check claude");
assert!(
claude_count > 0,
"应该包含 Claude 模型定价,实际数量: {}",
claude_count
);
// 验证包含 GPT 模型
let gpt_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gpt-%'",
[],
|row| row.get(0),
)
.expect("check gpt");
assert!(
gpt_count > 0,
"应该包含 GPT 模型定价,实际数量: {}",
gpt_count
);
// 验证包含 Gemini 模型
let gemini_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gemini-%'",
[],
|row| row.get(0),
)
.expect("check gemini");
assert!(
gemini_count > 0,
"应该包含 Gemini 模型定价,实际数量: {}",
gemini_count
);
}
+24 -1
View File
@@ -55,7 +55,7 @@ pub struct DeepLinkImportRequest {
/// Provider homepage URL
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
/// API endpoint/base URL
/// API endpoint/base URL (supports comma-separated multiple URLs)
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
/// API key
@@ -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>,
}
+47 -2
View File
@@ -101,9 +101,13 @@ fn parse_provider_deeplink(
validate_url(hp, "homepage")?;
}
}
// Validate each endpoint (supports comma-separated multiple URLs)
if let Some(ref ep) = endpoint {
if !ep.is_empty() {
validate_url(ep, "endpoint")?;
for (i, url) in ep.split(',').enumerate() {
let trimmed = url.trim();
if !trimmed.is_empty() {
validate_url(trimmed, &format!("endpoint[{i}]"))?;
}
}
}
@@ -122,6 +126,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 +163,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 +230,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 +292,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 +347,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,
})
}
+144 -46
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;
@@ -33,12 +33,12 @@ pub fn import_provider_from_deeplink(
}
// Step 1: Merge config file if provided (v3.8+)
let merged_request = parse_and_merge_config(&request)?;
let mut merged_request = parse_and_merge_config(&request)?;
// Extract required fields (now as Option)
let app_str = merged_request
.app
.as_ref()
.clone()
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
@@ -51,14 +51,29 @@ pub fn import_provider_from_deeplink(
));
}
let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| {
// Get endpoint: supports comma-separated multiple URLs (first is primary)
let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| {
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
})?;
if endpoint.is_empty() {
return Err(AppError::InvalidInput(
"Endpoint cannot be empty".to_string(),
));
// Parse endpoints: split by comma, first is primary
let all_endpoints: Vec<String> = endpoint_str
.split(',')
.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty())
.collect();
let primary_endpoint = all_endpoints
.first()
.ok_or_else(|| AppError::InvalidInput("Endpoint cannot be empty".to_string()))?;
// Auto-infer homepage from endpoint if not provided
if merged_request
.homepage
.as_ref()
.is_none_or(|s| s.is_empty())
{
merged_request.homepage = infer_homepage_from_endpoint(primary_endpoint);
}
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
@@ -73,11 +88,11 @@ pub fn import_provider_from_deeplink(
let name = merged_request
.name
.as_ref()
.clone()
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
// Parse app type
let app_type = AppType::from_str(app_str)
let app_type = AppType::from_str(&app_str)
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
// Build provider configuration based on app type
@@ -97,6 +112,21 @@ pub fn import_provider_from_deeplink(
// Use ProviderService to add the provider
ProviderService::add(state, app_type.clone(), provider)?;
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
for ep in all_endpoints.iter().skip(1) {
let normalized = ep.trim().trim_end_matches('/').to_string();
if !normalized.is_empty() {
if let Err(e) = ProviderService::add_custom_endpoint(
state,
app_type.clone(),
&provider_id,
normalized.clone(),
) {
log::warn!("Failed to add custom endpoint '{normalized}': {e}");
}
}
}
// If enabled=true, set as current provider
if merged_request.enabled.unwrap_or(false) {
ProviderService::switch(state, app_type.clone(), &provider_id)?;
@@ -117,6 +147,9 @@ pub(crate) fn build_provider_from_request(
AppType::Gemini => build_gemini_settings(request),
};
// Build usage script configuration if provided
let meta = build_provider_meta(request)?;
let provider = Provider {
id: String::new(), // Will be generated by caller
name: request.name.clone().unwrap_or_default(),
@@ -126,14 +159,81 @@ pub(crate) fn build_provider_from_request(
created_at: None,
sort_index: None,
notes: request.notes.clone(),
meta: None,
meta,
icon: request.icon.clone(),
icon_color: None,
in_failover_queue: false,
};
Ok(provider)
}
/// Get primary endpoint from request (first one if comma-separated)
fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
request
.endpoint
.as_ref()
.and_then(|ep| ep.split(',').next())
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Build provider meta with usage script configuration
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
// Check if any usage script fields are provided
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
// Note: use primary endpoint only (first one if comma-separated)
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(|| {
let primary = get_primary_endpoint(request);
if primary.is_empty() {
None
} else {
Some(primary)
}
}),
access_token: request.usage_access_token.clone(),
user_id: request.usage_user_id.clone(),
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();
@@ -143,7 +243,7 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
);
env.insert(
"ANTHROPIC_BASE_URL".to_string(),
json!(request.endpoint.clone().unwrap_or_default()),
json!(get_primary_endpoint(request)),
);
// Add default model if provided
@@ -216,11 +316,8 @@ fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
.unwrap_or("gpt-5-codex")
.to_string();
// Endpoint: normalize trailing slashes
let endpoint = request
.endpoint
.as_deref()
.unwrap_or("")
// Endpoint: normalize trailing slashes (use primary endpoint only)
let endpoint = get_primary_endpoint(request)
.trim()
.trim_end_matches('/')
.to_string();
@@ -254,7 +351,7 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
env.insert(
"GOOGLE_GEMINI_BASE_URL".to_string(),
json!(request.endpoint),
json!(get_primary_endpoint(request)),
);
// Add model if provided
@@ -354,27 +451,26 @@ fn merge_claude_config(
})?;
// Auto-fill API key if not provided in URL
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
request.api_key = Some(token.to_string());
}
}
// Auto-fill endpoint if not provided in URL
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
request.endpoint = Some(base_url.to_string());
}
}
// Auto-fill homepage from endpoint if not provided
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
&& request.endpoint.is_some()
&& !request.endpoint.as_ref().unwrap().is_empty()
{
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
if request.homepage.is_none() {
request.homepage = Some("https://anthropic.com".to_string());
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
request.homepage = infer_homepage_from_endpoint(endpoint);
if request.homepage.is_none() {
request.homepage = Some("https://anthropic.com".to_string());
}
}
}
@@ -413,7 +509,7 @@ fn merge_codex_config(
config: &serde_json::Value,
) -> Result<(), AppError> {
// Auto-fill API key from auth.OPENAI_API_KEY
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(api_key) = config
.get("auth")
.and_then(|v| v.get("OPENAI_API_KEY"))
@@ -428,7 +524,7 @@ fn merge_codex_config(
// Parse TOML config string to extract base_url and model
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
// Extract base_url from model_providers section
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = extract_codex_base_url(&toml_value) {
request.endpoint = Some(base_url);
}
@@ -444,13 +540,12 @@ fn merge_codex_config(
}
// Auto-fill homepage from endpoint
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
&& request.endpoint.is_some()
&& !request.endpoint.as_ref().unwrap().is_empty()
{
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
if request.homepage.is_none() {
request.homepage = Some("https://openai.com".to_string());
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
request.homepage = infer_homepage_from_endpoint(endpoint);
if request.homepage.is_none() {
request.homepage = Some("https://openai.com".to_string());
}
}
}
@@ -463,14 +558,18 @@ fn merge_gemini_config(
config: &serde_json::Value,
) -> Result<(), AppError> {
// Gemini uses flat env structure
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
request.api_key = Some(api_key.to_string());
}
}
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = config
.get("GOOGLE_GEMINI_BASE_URL")
.or_else(|| config.get("GEMINI_BASE_URL"))
.and_then(|v| v.as_str())
{
request.endpoint = Some(base_url.to_string());
}
}
@@ -483,13 +582,12 @@ fn merge_gemini_config(
}
// Auto-fill homepage from endpoint
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
&& request.endpoint.is_some()
&& !request.endpoint.as_ref().unwrap().is_empty()
{
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
if request.homepage.is_none() {
request.homepage = Some("https://ai.google.dev".to_string());
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
request.homepage = infer_homepage_from_endpoint(endpoint);
if request.homepage.is_none() {
request.homepage = Some("https://ai.google.dev".to_string());
}
}
}
+85 -3
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();
@@ -347,7 +375,7 @@ fn test_parse_prompt_deeplink() {
assert_eq!(request.name.unwrap(), "test");
assert_eq!(request.content.unwrap(), content_b64);
assert_eq!(request.description.unwrap(), "desc");
assert_eq!(request.enabled.unwrap(), true);
assert!(request.enabled.unwrap());
}
#[test]
@@ -363,16 +391,70 @@ fn test_parse_mcp_deeplink() {
assert_eq!(request.resource, "mcp");
assert_eq!(request.apps.unwrap(), "claude,codex");
assert_eq!(request.config.unwrap(), config_b64);
assert_eq!(request.enabled.unwrap(), true);
assert!(request.enabled.unwrap());
}
#[test]
fn test_parse_skill_deeplink() {
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
let request = parse_deeplink_url(&url).unwrap();
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.resource, "skill");
assert_eq!(request.repo.unwrap(), "owner/repo");
assert_eq!(request.directory.unwrap(), "skills");
assert_eq!(request.branch.unwrap(), "dev");
}
// =============================================================================
// Multiple Endpoints Tests
// =============================================================================
#[test]
fn test_parse_multiple_endpoints_comma_separated() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com,https%3A%2F%2Fapi2.example.com,https%3A%2F%2Fapi3.example.com&apiKey=sk-test";
let request = parse_deeplink_url(url).unwrap();
assert!(request.endpoint.is_some());
let endpoint = request.endpoint.unwrap();
// Should contain all endpoints comma-separated
assert!(endpoint.contains("https://api1.example.com"));
assert!(endpoint.contains("https://api2.example.com"));
assert!(endpoint.contains("https://api3.example.com"));
}
#[test]
fn test_parse_single_endpoint_backward_compatible() {
// Old format with single endpoint should still work
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(
request.endpoint,
Some("https://api.example.com".to_string())
);
}
#[test]
fn test_parse_endpoints_with_spaces_trimmed() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com%20,%20https%3A%2F%2Fapi2.example.com&apiKey=sk-test";
let request = parse_deeplink_url(url).unwrap();
// Validation should pass (spaces are trimmed during validation)
assert!(request.endpoint.is_some());
}
#[test]
fn test_infer_homepage_from_endpoint_without_homepage() {
// Test that homepage is auto-inferred from endpoint when not provided
assert_eq!(
infer_homepage_from_endpoint("https://api.cubence.com/v1"),
Some("https://cubence.com".to_string())
);
assert_eq!(
infer_homepage_from_endpoint("https://cubence.com"),
Some("https://cubence.com".to_string())
);
}
+19
View File
@@ -52,6 +52,10 @@ pub enum AppError {
},
#[error("数据库错误: {0}")]
Database(String),
#[error("所有供应商已熔断,无可用渠道")]
AllProvidersCircuitOpen,
#[error("未配置供应商")]
NoProvidersConfigured,
}
impl AppError {
@@ -91,12 +95,27 @@ impl<T> From<PoisonError<T>> for AppError {
}
}
impl From<rusqlite::Error> for AppError {
fn from(err: rusqlite::Error) -> Self {
Self::Database(err.to_string())
}
}
impl From<AppError> for String {
fn from(err: AppError) -> Self {
err.to_string()
}
}
impl serde::Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
/// 格式化为 JSON 错误字符串,前端可解析为结构化错误
pub fn format_skill_error(
code: &str,
+9 -3
View File
@@ -5,15 +5,21 @@ use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Gemini 配置目录路径(支持设置覆盖)
pub fn get_gemini_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_gemini_override_dir() {
return custom;
}
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".gemini")
get_home_dir().join(".gemini")
}
/// 获取 Gemini .env 文件路径
+38 -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()));
}
}
}
}
@@ -127,6 +134,33 @@ pub fn set_mcp_servers_map(
obj.remove("homepage");
obj.remove("docs");
// Timeout 转换:Claude/Codex 使用 startup_timeout_sec/tool_timeout_sec
// Gemini CLI 只支持 timeout(单位 ms
// 默认值:startup=10s, tool=60s
const DEFAULT_STARTUP_MS: u64 = 10_000;
const DEFAULT_TOOL_MS: u64 = 60_000;
let extract_timeout =
|obj: &mut Map<String, Value>, key: &str, multiplier: u64| -> Option<u64> {
obj.remove(key).and_then(|val| {
val.as_u64()
.map(|n| n * multiplier)
.or_else(|| val.as_f64().map(|f| (f * multiplier as f64) as u64))
})
};
// 分别收集 startup 和 tool timeout,未设置时使用默认值
let startup_ms = extract_timeout(&mut obj, "startup_timeout_sec", 1000)
.or_else(|| extract_timeout(&mut obj, "startup_timeout_ms", 1))
.unwrap_or(DEFAULT_STARTUP_MS);
let tool_ms = extract_timeout(&mut obj, "tool_timeout_sec", 1000)
.or_else(|| extract_timeout(&mut obj, "tool_timeout_ms", 1))
.unwrap_or(DEFAULT_TOOL_MS);
// 取最大值作为 Gemini timeout
let final_timeout = startup_ms.max(tool_ms);
obj.insert("timeout".to_string(), Value::Number(final_timeout.into()));
out.insert(id.clone(), Value::Object(obj));
}
+41
View File
@@ -52,6 +52,47 @@ pub fn take_migration_success() -> bool {
}
}
// ============================================================
// Skills SSOT 迁移结果状态
// ============================================================
#[derive(Debug, Clone, Serialize)]
pub struct SkillsMigrationPayload {
pub count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
static SKILLS_MIGRATION_RESULT: OnceLock<RwLock<Option<SkillsMigrationPayload>>> = OnceLock::new();
fn skills_migration_cell() -> &'static RwLock<Option<SkillsMigrationPayload>> {
SKILLS_MIGRATION_RESULT.get_or_init(|| RwLock::new(None))
}
pub fn set_skills_migration_result(count: usize) {
if let Ok(mut guard) = skills_migration_cell().write() {
*guard = Some(SkillsMigrationPayload { count, error: None });
}
}
pub fn set_skills_migration_error(error: String) {
if let Ok(mut guard) = skills_migration_cell().write() {
*guard = Some(SkillsMigrationPayload {
count: 0,
error: Some(error),
});
}
}
/// 获取并消费 Skills 迁移结果(只返回一次 Some,之后返回 None)
pub fn take_skills_migration_result() -> Option<SkillsMigrationPayload> {
if let Ok(mut guard) = skills_migration_cell().write() {
guard.take()
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
+507 -66
View File
@@ -13,10 +13,12 @@ mod gemini_config;
mod gemini_mcp;
mod init_status;
mod mcp;
mod panic_hook;
mod prompt;
mod prompt_files;
mod provider;
mod provider_defaults;
mod proxy;
mod services;
mod settings;
mod store;
@@ -25,6 +27,7 @@ mod usage_script;
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
pub use commands::open_provider_terminal;
pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
pub use database::Database;
@@ -38,8 +41,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,11 +50,43 @@ use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc;
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
#[cfg(target_os = "macos")]
use tauri::image::Image;
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
use tauri::RunEvent;
use tauri::{Emitter, Manager};
fn redact_url_for_log(url_str: &str) -> String {
match url::Url::parse(url_str) {
Ok(url) => {
let mut output = format!("{}://", url.scheme());
if let Some(host) = url.host_str() {
output.push_str(host);
}
output.push_str(url.path());
let mut keys: Vec<String> = url.query_pairs().map(|(k, _)| k.to_string()).collect();
keys.sort();
keys.dedup();
if !keys.is_empty() {
output.push_str("?[keys:");
output.push_str(&keys.join(","));
output.push(']');
}
output
}
Err(_) => {
let base = url_str.split('#').next().unwrap_or(url_str);
match base.split_once('?') {
Some((prefix, _)) => format!("{prefix}?[redacted]"),
None => base.to_string(),
}
}
}
}
/// 统一处理 ccswitch:// 深链接 URL
///
/// - 解析 URL
@@ -67,7 +102,9 @@ fn handle_deeplink_url(
return false;
}
log::info!("✓ Deep link URL detected from {source}: {url_str}");
let redacted_url = redact_url_for_log(url_str);
log::info!("✓ Deep link URL detected from {source}: {redacted_url}");
log::debug!("Deep link URL (raw) from {source}: {url_str}");
match crate::deeplink::parse_deeplink_url(url_str) {
Ok(request) => {
@@ -133,17 +170,33 @@ 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() {
// 设置 panic hook,在应用崩溃时记录日志到 <app_config_dir>/crash.log(默认 ~/.cc-switch/crash.log
panic_hook::setup_panic_hook();
let mut builder = tauri::Builder::default();
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
log::info!("=== Single Instance Callback Triggered ===");
log::info!("Args count: {}", args.len());
log::debug!("Args count: {}", args.len());
for (i, arg) in args.iter().enumerate() {
log::info!(" arg[{i}]: {arg}");
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
}
// Check for deep link URL in args (mainly for Windows/Linux command line)
@@ -197,6 +250,10 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_store::Builder::new().build())
.setup(|app| {
// 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)
app_store::refresh_app_config_dir_override(app.handle());
panic_hook::init_app_config_dir(crate::config::get_app_config_dir());
// 注册 Updater 插件(桌面端)
#[cfg(desktop)]
{
@@ -208,55 +265,34 @@ pub fn run() {
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
}
}
#[cfg(target_os = "macos")]
// 初始化日志(Debug 和 Release 模式都启用 Info 级别)
// 日志同时输出到控制台和文件(<app_config_dir>/logs/;若设置了覆盖则使用覆盖目录)
{
// 设置 macOS 标题栏背景色为主界面蓝色
if let Some(window) = app.get_webview_window("main") {
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2_app_kit::NSColor;
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
match window.ns_window() {
Ok(ns_window_ptr) => {
if let Some(ns_window) =
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject) }
{
// 使用与主界面 banner 相同的蓝色 #3498db
// #3498db = RGB(52, 152, 219)
let bg_color = unsafe {
NSColor::colorWithRed_green_blue_alpha(
52.0 / 255.0, // R: 52
152.0 / 255.0, // G: 152
219.0 / 255.0, // B: 219
1.0, // Alpha: 1.0
)
};
let log_dir = panic_hook::get_log_dir();
unsafe {
use objc2::msg_send;
let _: () =
msg_send![&*ns_window, setBackgroundColor: &*bg_color];
}
} else {
log::warn!("Failed to retain NSWindow reference");
}
}
Err(e) => log::warn!("Failed to get NSWindow pointer: {e}"),
}
}
}
// 初始化日志
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.targets([
// 输出到控制台
Target::new(TargetKind::Stdout),
// 输出到日志文件
Target::new(TargetKind::Folder {
path: log_dir,
file_name: Some("cc-switch".into()),
}),
])
.rotation_strategy(RotationStrategy::KeepAll)
.max_file_size(5_000_000) // 5MB 单文件上限
.timezone_strategy(TimezoneStrategy::UseLocal)
.build(),
)?;
}
// 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径
app_store::refresh_app_config_dir_override(app.handle());
// 清理旧日志文件,只保留最近 2 个
panic_hook::cleanup_old_logs();
}
// 初始化数据库
let app_config_dir = crate::config::get_app_config_dir();
@@ -296,12 +332,25 @@ pub fn run() {
None
};
// 现在创建数据库
let db = match crate::database::Database::init() {
Ok(db) => Arc::new(db),
Err(e) => {
log::error!("Failed to init database: {e}");
return Err(Box::new(e));
// 现在创建数据库(包含 Schema 迁移)
//
// 说明:从 v3.8.* 升级的用户通常会走到这里的 SQLite schema 迁移,
// 若迁移失败(数据库损坏/权限不足/user_version 过新等),需要给用户明确提示,
// 否则表现可能只是“应用打不开/闪退”。
let db = loop {
match crate::database::Database::init() {
Ok(db) => break Arc::new(db),
Err(e) => {
log::error!("Failed to init database: {e}");
if !show_database_init_error_dialog(app.handle(), &db_path, &e.to_string())
{
log::info!("用户选择退出程序");
std::process::exit(1);
}
log::info!("用户选择重试初始化数据库");
}
}
};
@@ -331,6 +380,9 @@ pub fn run() {
let app_state = AppState::new(db);
// 设置 AppHandle 用于代理故障转移时的 UI 更新
app_state.proxy_service.set_app_handle(app.handle().clone());
// ============================================================
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响)
// ============================================================
@@ -344,6 +396,47 @@ pub fn run() {
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
}
// 1.1. Skills 统一管理迁移:当数据库迁移到 v3 结构后,自动从各应用目录导入到 SSOT
// 触发条件由 schema 迁移设置 settings.skills_ssot_migration_pending = true 控制。
match app_state.db.get_setting("skills_ssot_migration_pending") {
Ok(Some(flag)) if flag == "true" || flag == "1" => {
// 安全保护:如果用户已经有 v3 结构的 Skills 数据,就不要自动清空重建。
let has_existing = app_state
.db
.get_all_installed_skills()
.map(|skills| !skills.is_empty())
.unwrap_or(false);
if has_existing {
log::info!(
"Detected skills_ssot_migration_pending but skills table not empty; skipping auto import."
);
let _ = app_state
.db
.set_setting("skills_ssot_migration_pending", "false");
} else {
match crate::services::skill::migrate_skills_to_ssot(&app_state.db) {
Ok(count) => {
log::info!("✓ Auto imported {count} skill(s) into SSOT");
if count > 0 {
crate::init_status::set_skills_migration_result(count);
}
let _ = app_state
.db
.set_setting("skills_ssot_migration_pending", "false");
}
Err(e) => {
log::warn!("✗ Failed to auto import legacy skills to SSOT: {e}");
crate::init_status::set_skills_migration_error(e.to_string());
// 保留 pending 标志,方便下次启动重试
}
}
}
}
Ok(_) => {} // 未开启迁移标志,静默跳过
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
}
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过)
for app in [
crate::app_config::AppType::Claude,
@@ -356,6 +449,27 @@ pub fn run() {
) {
Ok(true) => {
log::info!("✓ Imported default provider for {}", app.as_str());
// 首次运行:自动提取通用配置片段(仅当通用配置为空时)
if app_state
.db
.get_config_snippet(app.as_str())
.ok()
.flatten()
.is_none()
{
match crate::services::provider::ProviderService::extract_common_config_snippet(&app_state, app.clone()) {
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
if let Err(e) = app_state.db.set_config_snippet(app.as_str(), Some(snippet)) {
log::warn!("✗ Failed to save common config snippet for {}: {e}", app.as_str());
} else {
log::info!("✓ Extracted common config snippet for {}", app.as_str());
}
}
Ok(_) => log::debug!("○ No common config to extract for {}", app.as_str()),
Err(e) => log::debug!("○ Failed to extract common config for {}: {e}", app.as_str()),
}
}
}
Ok(false) => {} // 已有供应商,静默跳过
Err(e) => {
@@ -474,7 +588,7 @@ pub fn run() {
for (i, url) in urls.iter().enumerate() {
let url_str = url.as_str();
log::info!(" URL[{i}]: {url_str}");
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
break; // Process only first ccswitch:// URL
@@ -500,11 +614,26 @@ pub fn run() {
})
.show_menu_on_left_click(true);
// 统一使用应用默认图标;待托盘模板图标就绪后再启用
if let Some(icon) = app.default_window_icon() {
tray_builder = tray_builder.icon(icon.clone());
} else {
log::warn!("Failed to get default window icon for tray");
// 使用平台对应的托盘图标(macOS 使用模板图标适配深浅色)
#[cfg(target_os = "macos")]
{
if let Some(icon) = macos_tray_icon() {
tray_builder = tray_builder.icon(icon).icon_as_template(true);
} else if let Some(icon) = app.default_window_icon() {
log::warn!("Falling back to default window icon for tray");
tray_builder = tray_builder.icon(icon.clone());
} else {
log::warn!("Failed to load macOS tray icon for tray");
}
}
#[cfg(not(target_os = "macos"))]
{
if let Some(icon) = app.default_window_icon() {
tray_builder = tray_builder.icon(icon.clone());
} else {
log::warn!("Failed to get default window icon for tray");
}
}
let _tray = tray_builder.build(app)?;
@@ -512,15 +641,69 @@ pub fn run() {
app.manage(app_state);
// 初始化 SkillService
match SkillService::new() {
Ok(skill_service) => {
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
}
Err(e) => {
log::warn!("初始化 SkillService 失败: {e}");
let skill_service = SkillService::new();
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
// 初始化全局出站代理 HTTP 客户端
{
let db = &app.state::<AppState>().db;
let proxy_url = db.get_global_proxy_url().ok().flatten();
if let Err(e) = crate::proxy::http_client::init(proxy_url.as_deref()) {
log::error!(
"[GlobalProxy] [GP-005] Failed to initialize with saved config: {e}"
);
// 清除无效的代理配置
if proxy_url.is_some() {
log::warn!(
"[GlobalProxy] [GP-006] Clearing invalid proxy config from database"
);
if let Err(clear_err) = db.set_global_proxy_url(None) {
log::error!(
"[GlobalProxy] [GP-007] Failed to clear invalid config: {clear_err}"
);
}
}
// 使用直连模式重新初始化
if let Err(fallback_err) = crate::proxy::http_client::init(None) {
log::error!(
"[GlobalProxy] [GP-008] Failed to initialize direct connection: {fallback_err}"
);
}
}
}
// 异常退出恢复 + 代理状态自动恢复
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let state = app_handle.state::<AppState>();
// 检查是否有 Live 备份(表示上次异常退出时可能处于接管状态)
let has_backups = match state.db.has_any_live_backup().await {
Ok(v) => v,
Err(e) => {
log::error!("检查 Live 备份失败: {e}");
false
}
};
// 检查 Live 配置是否仍处于被接管状态(包含占位符)
let live_taken_over = state.proxy_service.detect_takeover_in_live_configs();
if has_backups || live_taken_over {
log::warn!("检测到上次异常退出(存在接管残留),正在恢复 Live 配置...");
if let Err(e) = state.proxy_service.recover_from_crash().await {
log::error!("恢复 Live 配置失败: {e}");
} else {
log::info!("Live 配置已恢复");
}
}
// 检查 settings 表中的代理状态,自动恢复代理服务
restore_proxy_state_on_startup(&state).await;
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -540,12 +723,14 @@ pub fn run() {
commands::open_external,
commands::get_init_error,
commands::get_migration_result,
commands::get_skills_migration_result,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_claude_common_config_snippet,
commands::set_claude_common_config_snippet,
commands::get_common_config_snippet,
commands::set_common_config_snippet,
commands::extract_common_config_snippet,
commands::read_live_provider_settings,
commands::get_settings,
commands::save_settings,
@@ -556,6 +741,8 @@ pub fn run() {
commands::read_claude_plugin_config,
commands::apply_claude_plugin_config,
commands::is_claude_plugin_applied,
commands::apply_claude_onboarding_skip,
commands::clear_claude_onboarding_skip,
// Claude MCP management
commands::get_claude_mcp_status,
commands::read_claude_mcp_config,
@@ -570,11 +757,12 @@ pub fn run() {
commands::upsert_mcp_server_in_config,
commands::delete_mcp_server_in_config,
commands::set_mcp_enabled,
// v3.7.0: Unified MCP management
// Unified MCP management
commands::get_mcp_servers,
commands::upsert_mcp_server,
commands::delete_mcp_server,
commands::toggle_mcp_app,
commands::import_mcp_from_apps,
// Prompt management
commands::get_prompts,
commands::upsert_prompt,
@@ -609,16 +797,87 @@ pub fn run() {
commands::check_env_conflicts,
commands::delete_env_vars,
commands::restore_env_backup,
// Skill management
// Skill management (v3.10.0+ unified)
commands::get_installed_skills,
commands::install_skill_unified,
commands::uninstall_skill_unified,
commands::toggle_skill_app,
commands::scan_unmanaged_skills,
commands::import_skills_from_apps,
commands::discover_available_skills,
// Skill management (legacy API compatibility)
commands::get_skills,
commands::get_skills_for_app,
commands::install_skill,
commands::install_skill_for_app,
commands::uninstall_skill,
commands::uninstall_skill_for_app,
commands::get_skill_repos,
commands::add_skill_repo,
commands::remove_skill_repo,
// Auto launch
commands::set_auto_launch,
commands::get_auto_launch_status,
// Proxy server management
commands::start_proxy_server,
commands::stop_proxy_with_restore,
commands::get_proxy_takeover_status,
commands::set_proxy_takeover_for_app,
commands::get_proxy_status,
commands::get_proxy_config,
commands::update_proxy_config,
// Global & Per-App Config
commands::get_global_proxy_config,
commands::update_global_proxy_config,
commands::get_proxy_config_for_app,
commands::update_proxy_config_for_app,
commands::is_proxy_running,
commands::is_live_takeover_active,
commands::switch_proxy_provider,
// Proxy failover commands
commands::get_provider_health,
commands::reset_circuit_breaker,
commands::get_circuit_breaker_config,
commands::update_circuit_breaker_config,
commands::get_circuit_breaker_stats,
// Failover queue management
commands::get_failover_queue,
commands::get_available_providers_for_failover,
commands::add_to_failover_queue,
commands::remove_from_failover_queue,
commands::get_auto_failover_enabled,
commands::set_auto_failover_enabled,
// Usage statistics
commands::get_usage_summary,
commands::get_usage_trends,
commands::get_provider_stats,
commands::get_model_stats,
commands::get_request_logs,
commands::get_request_detail,
commands::get_model_pricing,
commands::update_model_pricing,
commands::delete_model_pricing,
commands::check_provider_limits,
// Stream health check
commands::stream_check_provider,
commands::stream_check_all_providers,
commands::get_stream_check_config,
commands::save_stream_check_config,
commands::get_tool_versions,
// Provider terminal
commands::open_provider_terminal,
// Universal Provider management
commands::get_universal_providers,
commands::get_universal_provider,
commands::upsert_universal_provider,
commands::delete_universal_provider,
commands::sync_universal_provider,
// Global upstream proxy
commands::get_global_proxy_url,
commands::set_global_proxy_url,
commands::test_proxy_url,
commands::get_upstream_proxy_status,
commands::scan_local_proxies,
]);
let app = builder
@@ -626,6 +885,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 {
@@ -705,6 +984,103 @@ pub fn run() {
});
}
// ============================================================
// 应用退出清理
// ============================================================
/// 应用退出前的清理工作
///
/// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。
/// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。
/// 使用 stop_with_restore_keep_state 保留 settings 表中的代理状态,下次启动时自动恢复。
pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
if let Some(state) = app_handle.try_state::<store::AppState>() {
let proxy_service = &state.proxy_service;
// 退出时也需要兜底:代理可能已崩溃/未运行,但 Live 接管残留仍在(占位符/备份)。
let has_backups = match state.db.has_any_live_backup().await {
Ok(v) => v,
Err(e) => {
log::error!("退出时检查 Live 备份失败: {e}");
false
}
};
let live_taken_over = proxy_service.detect_takeover_in_live_configs();
let needs_restore = has_backups || live_taken_over;
if needs_restore {
log::info!("检测到接管残留,开始恢复 Live 配置(保留代理状态)...");
// 使用 keep_state 版本,保留 settings 表中的代理状态
if let Err(e) = proxy_service.stop_with_restore_keep_state().await {
log::error!("退出时恢复 Live 配置失败: {e}");
} else {
log::info!("已恢复 Live 配置(代理状态已保留,下次启动将自动恢复)");
}
return;
}
// 非接管模式:代理在运行则仅停止代理
if proxy_service.is_running().await {
log::info!("检测到代理服务器正在运行,开始停止...");
if let Err(e) = proxy_service.stop().await {
log::error!("退出时停止代理失败: {e}");
}
log::info!("代理服务器清理完成");
}
}
}
// ============================================================
// 启动时恢复代理状态
// ============================================================
/// 启动时根据 proxy_config 表中的代理状态自动恢复代理服务
///
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`,
/// 则自动启动代理服务并接管对应应用的 Live 配置。
async fn restore_proxy_state_on_startup(state: &store::AppState) {
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
let mut apps_to_restore = Vec::new();
for app_type in ["claude", "codex", "gemini"] {
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {
if config.enabled {
apps_to_restore.push(app_type);
}
}
}
if apps_to_restore.is_empty() {
log::debug!("启动时无需恢复代理状态");
return;
}
log::info!("检测到上次代理状态需要恢复,应用列表: {apps_to_restore:?}");
// 逐个恢复接管状态
for app_type in apps_to_restore {
match state
.proxy_service
.set_takeover_for_app(app_type, true)
.await
{
Ok(()) => {
log::info!("✓ 已恢复 {app_type} 的代理接管状态");
}
Err(e) => {
log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}");
// 失败时清除该应用的状态,避免下次启动再次尝试
if let Err(clear_err) = state
.proxy_service
.set_takeover_for_app(app_type, false)
.await
{
log::error!("清除 {app_type} 代理状态失败: {clear_err}");
}
}
}
}
}
// ============================================================
// 迁移错误对话框辅助函数
// ============================================================
@@ -768,3 +1144,68 @@ fn show_migration_error_dialog(app: &tauri::AppHandle, error: &str) -> bool {
))
.blocking_show()
}
/// 显示数据库初始化/Schema 迁移失败对话框
/// 返回 true 表示用户选择重试,false 表示用户选择退出
fn show_database_init_error_dialog(
app: &tauri::AppHandle,
db_path: &std::path::Path,
error: &str,
) -> bool {
let title = if is_chinese_locale() {
"数据库初始化失败"
} else {
"Database Initialization Failed"
};
let message = if is_chinese_locale() {
format!(
"初始化数据库或迁移数据库结构时发生错误:\n\n{error}\n\n\
数据库文件路径:\n{db}\n\n\
您的数据尚未丢失,应用不会自动删除数据库文件。\n\
常见原因包括:数据库版本过新、文件损坏、权限不足、磁盘空间不足等。\n\n\
建议:\n\
1) 先备份整个配置目录(包含 cc-switch.db\n\
2) 如果提示“数据库版本过新”,请升级到更新版本\n\
3) 如果刚升级出现异常,可回退旧版本导出/备份后再升级\n\n\
点击「重试」重新尝试初始化\n\
点击「退出」关闭程序",
db = db_path.display()
)
} else {
format!(
"An error occurred while initializing or migrating the database:\n\n{error}\n\n\
Database file path:\n{db}\n\n\
Your data is NOT lost - the app will not delete the database automatically.\n\
Common causes include: newer database version, corrupted file, permission issues, or low disk space.\n\n\
Suggestions:\n\
1) Back up the entire config directory (including cc-switch.db)\n\
2) If you see “database version is newer”, please upgrade CC Switch\n\
3) If this happened right after upgrading, consider rolling back to export/backup then upgrade again\n\n\
Click 'Retry' to attempt initialization again\n\
Click 'Exit' to close the program",
db = db_path.display()
)
};
let retry_text = if is_chinese_locale() {
"重试"
} else {
"Retry"
};
let exit_text = if is_chinese_locale() {
"退出"
} else {
"Exit"
};
app.dialog()
.message(&message)
.title(title)
.kind(MessageDialogKind::Error)
.buttons(MessageDialogButtons::OkCancelCustom(
retry_text.to_string(),
exit_text.to_string(),
))
.blocking_show()
}
+15
View File
@@ -8,6 +8,12 @@ use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec};
fn should_sync_claude_mcp() -> bool {
// Claude 未安装/未初始化时:通常 ~/.claude 目录与 ~/.claude.json 都不存在。
// 按用户偏好:此时跳过写入/删除,不创建任何文件或目录。
crate::config::get_claude_config_dir().exists() || crate::config::get_claude_mcp_path().exists()
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new();
@@ -33,6 +39,9 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
let enabled = collect_enabled_servers(&config.mcp.claude);
crate::claude_mcp::set_mcp_servers_map(&enabled)
}
@@ -107,6 +116,9 @@ pub fn sync_single_server_to_claude(
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置
let current = crate::claude_mcp::read_mcp_servers_map()?;
@@ -120,6 +132,9 @@ pub fn sync_single_server_to_claude(
/// 从 Claude live 配置中移除单个 MCP 服务器
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置
let mut current = crate::claude_mcp::read_mcp_servers_map()?;
+35 -8
View File
@@ -13,6 +13,12 @@ use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec};
fn should_sync_codex_mcp() -> bool {
// Codex 未安装/未初始化时:~/.codex 目录不存在。
// 按用户偏好:目录缺失时跳过写入/删除,不创建任何文件或目录。
crate::codex_config::get_codex_config_dir().exists()
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new();
@@ -273,6 +279,9 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
/// - 仅更新 `mcp_servers` 表,保留其它键
/// - 仅写入启用项;无启用项时清理 mcp_servers 表
pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
use toml_edit::{Item, Table};
// 1) 收集启用项(Codex 维度)
@@ -339,6 +348,9 @@ pub fn sync_single_server_to_codex(
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
use toml_edit::Item;
// 读取现有的 config.toml
@@ -347,9 +359,14 @@ pub fn sync_single_server_to_codex(
let mut doc = if config_path.exists() {
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
// 尝试解析现有配置,如果失败则创建新文档(容错处理)
match content.parse::<toml_edit::DocumentMut>() {
Ok(doc) => doc,
Err(e) => {
log::warn!("解析 Codex config.toml 失败: {e},将创建新配置");
toml_edit::DocumentMut::new()
}
}
} else {
toml_edit::DocumentMut::new()
};
@@ -376,7 +393,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(())
}
@@ -384,6 +402,9 @@ pub fn sync_single_server_to_codex(
/// 从 Codex live 配置中移除单个 MCP 服务器
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
let config_path = crate::codex_config::get_codex_config_path();
if !config_path.exists() {
@@ -393,9 +414,14 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
let mut doc = content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
// 尝试解析现有配置,如果失败则直接返回(无法删除不存在的内容)
let mut doc = match content.parse::<toml_edit::DocumentMut>() {
Ok(doc) => doc,
Err(e) => {
log::warn!("解析 Codex config.toml 失败: {e},跳过删除操作");
return Ok(());
}
};
// 从正确的位置删除:[mcp_servers]
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
@@ -412,7 +438,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(())
}
+15
View File
@@ -8,6 +8,12 @@ use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec};
fn should_sync_gemini_mcp() -> bool {
// Gemini 未安装/未初始化时:~/.gemini 目录不存在。
// 按用户偏好:目录缺失时跳过写入/删除,不创建任何文件或目录。
crate::gemini_config::get_gemini_dir().exists()
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new();
@@ -33,6 +39,9 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
/// 将 config.json 中 Gemini 的 enabled==true 项写入 Gemini MCP 配置
pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
if !should_sync_gemini_mcp() {
return Ok(());
}
let enabled = collect_enabled_servers(&config.mcp.gemini);
crate::gemini_mcp::set_mcp_servers_map(&enabled)
}
@@ -103,6 +112,9 @@ pub fn sync_single_server_to_gemini(
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
if !should_sync_gemini_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
@@ -115,6 +127,9 @@ pub fn sync_single_server_to_gemini(
/// 从 Gemini live 配置中移除单个 MCP 服务器
pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
if !should_sync_gemini_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
+250
View File
@@ -0,0 +1,250 @@
//! Panic Hook 模块
//!
//! 在应用崩溃时捕获 panic 信息并记录到 `<app_config_dir>/crash.log` 文件中(默认 `~/.cc-switch/crash.log`)。
//! 便于用户和开发者诊断闪退问题。
use std::fs::OpenOptions;
use std::io::Write;
use std::panic;
use std::path::PathBuf;
use std::sync::OnceLock;
/// 应用版本号(从 Cargo.toml 读取)
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
/// 日志文件保留数量
const LOG_FILES_TO_KEEP: usize = 2;
static APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
pub fn init_app_config_dir(dir: PathBuf) {
let _ = APP_CONFIG_DIR.set(dir);
}
/// 获取默认应用配置目录(不会 panic)
fn default_app_config_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".cc-switch")
}
/// 获取应用配置目录(优先使用初始化时写入的值;不会 panic)
fn get_app_config_dir() -> PathBuf {
APP_CONFIG_DIR
.get()
.cloned()
.unwrap_or_else(default_app_config_dir)
}
/// 获取崩溃日志文件路径
fn get_crash_log_path() -> PathBuf {
get_app_config_dir().join("crash.log")
}
/// 获取日志目录路径
pub fn get_log_dir() -> PathBuf {
get_app_config_dir().join("logs")
}
/// 清理旧日志文件,只保留最近 N 个
///
/// 在应用启动时调用,确保日志文件不会无限增长。
pub fn cleanup_old_logs() {
let log_dir = get_log_dir();
if !log_dir.exists() {
return;
}
// 读取目录中的所有 .log 文件
let mut log_files: Vec<_> = match std::fs::read_dir(&log_dir) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|ext| ext == "log").unwrap_or(false))
.collect(),
Err(_) => return,
};
// 如果文件数量不超过保留数量,无需清理
if log_files.len() <= LOG_FILES_TO_KEEP {
return;
}
// 按修改时间排序(最新的在前)
log_files.sort_by(|a, b| {
let time_a = a.metadata().and_then(|m| m.modified()).ok();
let time_b = b.metadata().and_then(|m| m.modified()).ok();
time_b.cmp(&time_a) // 降序
});
// 删除多余的旧文件
for old_file in log_files.into_iter().skip(LOG_FILES_TO_KEEP) {
if let Err(e) = std::fs::remove_file(&old_file) {
log::warn!("清理旧日志文件失败 {}: {e}", old_file.display());
} else {
log::info!("已清理旧日志文件: {}", old_file.display());
}
}
}
/// 安全获取环境信息(不会 panic)
fn get_system_info() -> String {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let family = std::env::consts::FAMILY;
// 安全获取当前工作目录
let cwd = std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "unknown".to_string());
// 安全获取当前线程信息
let thread = std::thread::current();
let thread_name = thread.name().unwrap_or("unnamed");
let thread_id = format!("{:?}", thread.id());
format!(
"OS: {os} ({family})\n\
Arch: {arch}\n\
App Version: {APP_VERSION}\n\
Working Dir: {cwd}\n\
Thread: {thread_name} (ID: {thread_id})"
)
}
/// 设置 panic hook,捕获崩溃信息并写入日志文件
///
/// 在应用启动时调用此函数,确保任何 panic 都会被记录。
/// 日志格式包含:
/// - 时间戳
/// - 应用版本和系统信息
/// - Panic 信息
/// - 发生位置(文件:行号)
/// - Backtrace(完整调用栈)
pub fn setup_panic_hook() {
// 启用 backtrace(确保 release 模式也能捕获)
if std::env::var("RUST_BACKTRACE").is_err() {
std::env::set_var("RUST_BACKTRACE", "1");
}
let default_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic_info| {
let log_path = get_crash_log_path();
// 确保目录存在
if let Some(parent) = log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// 构建崩溃信息(使用 catch_unwind 保护时间格式化,避免嵌套 panic)
let timestamp = std::panic::catch_unwind(|| {
chrono::Local::now()
.format("%Y-%m-%d %H:%M:%S%.3f")
.to_string()
})
.unwrap_or_else(|_| {
// chrono panic 时回退到 unix timestamp
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| format!("unix:{}.{:03}", d.as_secs(), d.subsec_millis()))
.unwrap_or_else(|_| "unknown".to_string())
});
// 获取系统信息
let system_info = std::panic::catch_unwind(get_system_info)
.unwrap_or_else(|_| "Failed to get system info".to_string());
// 获取 panic 消息(尝试多种方式提取)
let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
s.clone()
} else {
// 尝试使用 Display trait
format!("{panic_info}")
};
// 获取位置信息
let location = if let Some(loc) = panic_info.location() {
format!(
"File: {}\n Line: {}\n Column: {}",
loc.file(),
loc.line(),
loc.column()
)
} else {
"Unknown location".to_string()
};
// 捕获 backtrace(完整调用栈)
let backtrace = std::backtrace::Backtrace::force_capture();
let backtrace_str = format!("{backtrace}");
// 格式化日志条目
let separator = "=".repeat(80);
let sub_separator = "-".repeat(40);
let crash_entry = format!(
r#"
{separator}
[CRASH REPORT] {timestamp}
{separator}
{sub_separator}
System Information
{sub_separator}
{system_info}
{sub_separator}
Error Details
{sub_separator}
Message: {message}
Location: {location}
{sub_separator}
Stack Trace (Backtrace)
{sub_separator}
{backtrace_str}
{separator}
"#
);
// 写入文件(追加模式)
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {
let _ = file.write_all(crash_entry.as_bytes());
let _ = file.flush();
// 记录日志文件位置到 stderr
eprintln!("\n[CC-Switch] Crash log saved to: {}", log_path.display());
}
// 同时输出到 stderr(便于开发调试)
eprintln!("{crash_entry}");
// 调用默认 hook
default_hook(panic_info);
}));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_crash_log_path() {
let path = get_crash_log_path();
assert!(path.ends_with("crash.log"));
assert!(path.to_string_lossy().contains(".cc-switch"));
}
#[test]
fn test_system_info() {
let info = get_system_info();
assert!(info.contains("OS:"));
assert!(info.contains("Arch:"));
assert!(info.contains("App Version:"));
}
}
+299
View File
@@ -36,6 +36,10 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")]
pub icon_color: Option<String>,
/// 是否加入故障转移队列
#[serde(default)]
#[serde(rename = "inFailoverQueue")]
pub in_failover_queue: bool,
}
impl Provider {
@@ -58,6 +62,7 @@ impl Provider {
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
}
@@ -142,6 +147,9 @@ pub struct ProviderMeta {
/// 用量查询脚本配置
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<UsageScript>,
/// 请求地址管理:测速后自动选择最佳端点
#[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
pub endpoint_auto_select: Option<bool>,
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
pub is_partner: Option<bool>,
@@ -151,6 +159,15 @@ pub struct ProviderMeta {
skip_serializing_if = "Option::is_none"
)]
pub partner_promotion_key: Option<String>,
/// 成本倍数(用于计算实际成本)
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
pub cost_multiplier: Option<String>,
/// 每日消费限额(USD
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
pub limit_daily_usd: Option<String>,
/// 每月消费限额(USD
#[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
pub limit_monthly_usd: Option<String>,
}
impl ProviderManager {
@@ -159,3 +176,285 @@ impl ProviderManager {
&self.providers
}
}
// ============================================================================
// 统一供应商(Universal Provider- 跨应用共享配置
// ============================================================================
/// 统一供应商的应用启用状态
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UniversalProviderApps {
#[serde(default)]
pub claude: bool,
#[serde(default)]
pub codex: bool,
#[serde(default)]
pub gemini: bool,
}
/// Claude 模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClaudeModelConfig {
/// 主模型
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Haiku 默认模型
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "haikuModel")]
pub haiku_model: Option<String>,
/// Sonnet 默认模型
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "sonnetModel")]
pub sonnet_model: Option<String>,
/// Opus 默认模型
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "opusModel")]
pub opus_model: Option<String>,
}
/// Codex 模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CodexModelConfig {
/// 模型名称
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// 推理强度
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "reasoningEffort")]
pub reasoning_effort: Option<String>,
}
/// Gemini 模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GeminiModelConfig {
/// 模型名称
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
/// 各应用的模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UniversalProviderModels {
#[serde(skip_serializing_if = "Option::is_none")]
pub claude: Option<ClaudeModelConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub codex: Option<CodexModelConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gemini: Option<GeminiModelConfig>,
}
/// 统一供应商(跨应用共享配置)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UniversalProvider {
/// 唯一标识
pub id: String,
/// 供应商名称
pub name: String,
/// 供应商类型(如 "newapi", "custom"
#[serde(rename = "providerType")]
pub provider_type: String,
/// 应用启用状态
pub apps: UniversalProviderApps,
/// API 基础地址
#[serde(rename = "baseUrl")]
pub base_url: String,
/// API 密钥
#[serde(rename = "apiKey")]
pub api_key: String,
/// 各应用的模型配置
#[serde(default)]
pub models: UniversalProviderModels,
/// 网站链接
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "websiteUrl")]
pub website_url: Option<String>,
/// 备注信息
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
/// 图标名称
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// 图标颜色
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")]
pub icon_color: Option<String>,
/// 元数据
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
/// 创建时间戳
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
/// 排序索引
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "sortIndex")]
pub sort_index: Option<usize>,
}
impl UniversalProvider {
/// 创建新的统一供应商
pub fn new(
id: String,
name: String,
provider_type: String,
base_url: String,
api_key: String,
) -> Self {
Self {
id,
name,
provider_type,
apps: UniversalProviderApps::default(),
base_url,
api_key,
models: UniversalProviderModels::default(),
website_url: None,
notes: None,
icon: None,
icon_color: None,
meta: None,
created_at: Some(chrono::Utc::now().timestamp_millis()),
sort_index: None,
}
}
/// 生成 Claude 供应商配置
pub fn to_claude_provider(&self) -> Option<Provider> {
if !self.apps.claude {
return None;
}
let models = self.models.claude.as_ref();
let model = models
.and_then(|m| m.model.clone())
.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
let haiku = models
.and_then(|m| m.haiku_model.clone())
.unwrap_or_else(|| model.clone());
let sonnet = models
.and_then(|m| m.sonnet_model.clone())
.unwrap_or_else(|| model.clone());
let opus = models
.and_then(|m| m.opus_model.clone())
.unwrap_or_else(|| model.clone());
let settings_config = serde_json::json!({
"env": {
"ANTHROPIC_BASE_URL": self.base_url,
"ANTHROPIC_AUTH_TOKEN": self.api_key,
"ANTHROPIC_MODEL": model,
"ANTHROPIC_DEFAULT_HAIKU_MODEL": haiku,
"ANTHROPIC_DEFAULT_SONNET_MODEL": sonnet,
"ANTHROPIC_DEFAULT_OPUS_MODEL": opus,
}
});
Some(Provider {
id: format!("universal-claude-{}", self.id),
name: self.name.clone(),
settings_config,
website_url: self.website_url.clone(),
category: Some("aggregator".to_string()),
created_at: self.created_at,
sort_index: self.sort_index,
notes: self.notes.clone(),
meta: self.meta.clone(),
icon: self.icon.clone(),
icon_color: self.icon_color.clone(),
in_failover_queue: false,
})
}
/// 生成 Codex 供应商配置
pub fn to_codex_provider(&self) -> Option<Provider> {
if !self.apps.codex {
return None;
}
let models = self.models.codex.as_ref();
let model = models
.and_then(|m| m.model.clone())
.unwrap_or_else(|| "gpt-4o".to_string());
let reasoning_effort = models
.and_then(|m| m.reasoning_effort.clone())
.unwrap_or_else(|| "high".to_string());
// 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API
let codex_base_url = if self.base_url.ends_with("/v1") {
self.base_url.clone()
} else {
format!("{}/v1", self.base_url.trim_end_matches('/'))
};
// 生成 Codex 的 config.toml 内容
let config_toml = format!(
r#"model_provider = "newapi"
model = "{model}"
model_reasoning_effort = "{reasoning_effort}"
disable_response_storage = true
[model_providers.newapi]
name = "NewAPI"
base_url = "{codex_base_url}"
wire_api = "responses"
requires_openai_auth = true"#
);
let settings_config = serde_json::json!({
"auth": {
"OPENAI_API_KEY": self.api_key
},
"config": config_toml
});
Some(Provider {
id: format!("universal-codex-{}", self.id),
name: self.name.clone(),
settings_config,
website_url: self.website_url.clone(),
category: Some("aggregator".to_string()),
created_at: self.created_at,
sort_index: self.sort_index,
notes: self.notes.clone(),
meta: self.meta.clone(),
icon: self.icon.clone(),
icon_color: self.icon_color.clone(),
in_failover_queue: false,
})
}
/// 生成 Gemini 供应商配置
pub fn to_gemini_provider(&self) -> Option<Provider> {
if !self.apps.gemini {
return None;
}
let models = self.models.gemini.as_ref();
let model = models
.and_then(|m| m.model.clone())
.unwrap_or_else(|| "gemini-2.5-pro".to_string());
let settings_config = serde_json::json!({
"env": {
"GOOGLE_GEMINI_BASE_URL": self.base_url,
"GEMINI_API_KEY": self.api_key,
"GEMINI_MODEL": model,
}
});
Some(Provider {
id: format!("universal-gemini-{}", self.id),
name: self.name.clone(),
settings_config,
website_url: self.website_url.clone(),
category: Some("aggregator".to_string()),
created_at: self.created_at,
sort_index: self.sort_index,
notes: self.notes.clone(),
meta: self.meta.clone(),
icon: self.icon.clone(),
icon_color: self.icon_color.clone(),
in_failover_queue: false,
})
}
}
+297
View File
@@ -0,0 +1,297 @@
//! 请求体过滤模块
//!
//! 过滤不应透传到上游的私有参数,防止内部信息泄露。
//!
//! ## 过滤规则
//! - 以 `_` 开头的字段被视为私有参数,会被递归过滤
//! - 支持白名单机制,允许透传特定的 `_` 前缀字段
//! - 支持嵌套对象和数组的深度过滤
//!
//! ## 使用场景
//! - `_internal_id`: 内部追踪 ID
//! - `_debug_mode`: 调试标记
//! - `_session_token`: 会话令牌
//! - `_client_version`: 客户端版本
use serde_json::Value;
use std::collections::HashSet;
/// 过滤私有参数(以 `_` 开头的字段)
///
/// 递归遍历 JSON 结构,移除所有以下划线开头的字段。
///
/// # Arguments
/// * `body` - 原始请求体
///
/// # Returns
/// 过滤后的请求体
///
/// # Example
/// ```ignore
/// let input = json!({
/// "model": "claude-3",
/// "_internal_id": "abc123",
/// "messages": [{"role": "user", "content": "hello", "_token": "secret"}]
/// });
/// let output = filter_private_params(input);
/// // output 中不包含 _internal_id 和 _token
/// ```
#[cfg(test)]
pub fn filter_private_params(body: Value) -> Value {
filter_private_params_with_whitelist(body, &[])
}
/// 过滤私有参数(支持白名单)
///
/// 递归遍历 JSON 结构,移除所有以下划线开头的字段,
/// 但保留白名单中指定的字段。
///
/// # Arguments
/// * `body` - 原始请求体
/// * `whitelist` - 白名单字段列表(不过滤这些字段)
///
/// # Returns
/// 过滤后的请求体
///
/// # Example
/// ```ignore
/// let input = json!({
/// "model": "claude-3",
/// "_metadata": {"key": "value"}, // 白名单中,保留
/// "_internal_id": "abc123" // 不在白名单中,过滤
/// });
/// let output = filter_private_params_with_whitelist(input, &["_metadata"]);
/// // output 包含 _metadata,不包含 _internal_id
/// ```
pub fn filter_private_params_with_whitelist(body: Value, whitelist: &[String]) -> Value {
let whitelist_set: HashSet<&str> = whitelist.iter().map(|s| s.as_str()).collect();
filter_recursive_with_whitelist(body, &mut Vec::new(), &whitelist_set)
}
/// 递归过滤实现(支持白名单)
fn filter_recursive_with_whitelist(
value: Value,
removed_keys: &mut Vec<String>,
whitelist: &HashSet<&str>,
) -> Value {
match value {
Value::Object(map) => {
let filtered: serde_json::Map<String, Value> = map
.into_iter()
.filter_map(|(key, val)| {
// 以 _ 开头且不在白名单中的字段被过滤
if key.starts_with('_') && !whitelist.contains(key.as_str()) {
removed_keys.push(key);
None
} else {
Some((
key,
filter_recursive_with_whitelist(val, removed_keys, whitelist),
))
}
})
.collect();
// 仅在有过滤时记录日志(避免每次请求都打印)
if !removed_keys.is_empty() {
log::debug!("[BodyFilter] 过滤私有参数: {removed_keys:?}");
removed_keys.clear();
}
Value::Object(filtered)
}
Value::Array(arr) => Value::Array(
arr.into_iter()
.map(|v| filter_recursive_with_whitelist(v, removed_keys, whitelist))
.collect(),
),
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_filter_top_level_private_params() {
let input = json!({
"model": "claude-3",
"_internal_id": "abc123",
"_debug": true,
"max_tokens": 1024
});
let output = filter_private_params(input);
assert!(output.get("model").is_some());
assert!(output.get("max_tokens").is_some());
assert!(output.get("_internal_id").is_none());
assert!(output.get("_debug").is_none());
}
#[test]
fn test_filter_nested_private_params() {
let input = json!({
"model": "claude-3",
"messages": [
{
"role": "user",
"content": "hello",
"_session_token": "secret"
}
],
"metadata": {
"user_id": "user-1",
"_tracking_id": "track-1"
}
});
let output = filter_private_params(input);
// 顶级字段保留
assert!(output.get("model").is_some());
assert!(output.get("messages").is_some());
assert!(output.get("metadata").is_some());
// messages 数组中的私有参数被过滤
let messages = output.get("messages").unwrap().as_array().unwrap();
assert!(messages[0].get("role").is_some());
assert!(messages[0].get("content").is_some());
assert!(messages[0].get("_session_token").is_none());
// metadata 对象中的私有参数被过滤
let metadata = output.get("metadata").unwrap();
assert!(metadata.get("user_id").is_some());
assert!(metadata.get("_tracking_id").is_none());
}
#[test]
fn test_filter_deeply_nested() {
let input = json!({
"level1": {
"level2": {
"level3": {
"keep": "value",
"_remove": "secret"
}
}
}
});
let output = filter_private_params(input);
let level3 = output
.get("level1")
.unwrap()
.get("level2")
.unwrap()
.get("level3")
.unwrap();
assert!(level3.get("keep").is_some());
assert!(level3.get("_remove").is_none());
}
#[test]
fn test_filter_array_of_objects() {
let input = json!({
"items": [
{"id": 1, "_secret": "a"},
{"id": 2, "_secret": "b"},
{"id": 3, "_secret": "c"}
]
});
let output = filter_private_params(input);
let items = output.get("items").unwrap().as_array().unwrap();
for item in items {
assert!(item.get("id").is_some());
assert!(item.get("_secret").is_none());
}
}
#[test]
fn test_no_private_params() {
let input = json!({
"model": "claude-3",
"messages": [{"role": "user", "content": "hello"}]
});
let output = filter_private_params(input.clone());
// 无私有参数时,输出应与输入相同
assert_eq!(input, output);
}
#[test]
fn test_empty_object() {
let input = json!({});
let output = filter_private_params(input);
assert_eq!(output, json!({}));
}
#[test]
fn test_primitive_values() {
// 原始值不应被修改
assert_eq!(filter_private_params(json!(42)), json!(42));
assert_eq!(filter_private_params(json!("string")), json!("string"));
assert_eq!(filter_private_params(json!(true)), json!(true));
assert_eq!(filter_private_params(json!(null)), json!(null));
}
#[test]
fn test_whitelist_preserves_private_params() {
let input = json!({
"model": "claude-3",
"_metadata": {"key": "value"},
"_internal_id": "abc123",
"_stream_options": {"include_usage": true}
});
let whitelist = vec!["_metadata".to_string(), "_stream_options".to_string()];
let output = filter_private_params_with_whitelist(input, &whitelist);
// 白名单中的字段保留
assert!(output.get("_metadata").is_some());
assert!(output.get("_stream_options").is_some());
// 不在白名单中的私有字段被过滤
assert!(output.get("_internal_id").is_none());
// 普通字段保留
assert!(output.get("model").is_some());
}
#[test]
fn test_whitelist_nested() {
let input = json!({
"data": {
"_allowed": "keep",
"_forbidden": "remove",
"normal": "value"
}
});
let whitelist = vec!["_allowed".to_string()];
let output = filter_private_params_with_whitelist(input, &whitelist);
let data = output.get("data").unwrap();
assert!(data.get("_allowed").is_some());
assert!(data.get("_forbidden").is_none());
assert!(data.get("normal").is_some());
}
#[test]
fn test_empty_whitelist_same_as_default() {
let input = json!({
"model": "claude-3",
"_internal_id": "abc123"
});
let output1 = filter_private_params(input.clone());
let output2 = filter_private_params_with_whitelist(input, &[]);
assert_eq!(output1, output2);
}
}
+478
View File
@@ -0,0 +1,478 @@
//! 熔断器模块
//!
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
use super::log_codes::cb as log_cb;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::RwLock;
/// 熔断器状态
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CircuitState {
/// 关闭状态 - 正常工作
Closed,
/// 打开状态 - 熔断激活,拒绝请求
Open,
/// 半开状态 - 尝试恢复,允许部分请求通过
HalfOpen,
}
impl std::fmt::Display for CircuitState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CircuitState::Closed => write!(f, "closed"),
CircuitState::Open => write!(f, "open"),
CircuitState::HalfOpen => write!(f, "half_open"),
}
}
}
/// 熔断器配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CircuitBreakerConfig {
/// 失败阈值 - 连续失败多少次后打开熔断器
pub failure_threshold: u32,
/// 成功阈值 - 半开状态下成功多少次后关闭熔断器
pub success_threshold: u32,
/// 超时时间 - 熔断器打开后多久尝试半开(秒)
pub timeout_seconds: u64,
/// 错误率阈值 - 错误率超过此值时打开熔断器 (0.0-1.0)
pub error_rate_threshold: f64,
/// 最小请求数 - 计算错误率前的最小请求数
pub min_requests: u32,
}
impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
failure_threshold: 4,
success_threshold: 2,
timeout_seconds: 60,
error_rate_threshold: 0.6,
min_requests: 10,
}
}
}
/// 熔断器实例
pub struct CircuitBreaker {
/// 当前状态
state: Arc<RwLock<CircuitState>>,
/// 连续失败计数
consecutive_failures: Arc<AtomicU32>,
/// 连续成功计数(半开状态)
consecutive_successes: Arc<AtomicU32>,
/// 总请求计数
total_requests: Arc<AtomicU32>,
/// 失败请求计数
failed_requests: Arc<AtomicU32>,
/// 上次打开时间
last_opened_at: Arc<RwLock<Option<Instant>>>,
/// 配置(支持热更新)
config: Arc<RwLock<CircuitBreakerConfig>>,
/// 半开状态已放行的请求数(用于限流)
half_open_requests: Arc<AtomicU32>,
}
/// 熔断器放行结果
///
/// `used_half_open_permit` 表示本次放行是否占用了 HalfOpen 探测名额。
/// 调用方应在请求结束后把该值传回 `record_success` / `record_failure` 用于正确释放名额。
#[derive(Debug, Clone, Copy)]
pub struct AllowResult {
pub allowed: bool,
pub used_half_open_permit: bool,
}
impl CircuitBreaker {
/// 创建新的熔断器
pub fn new(config: CircuitBreakerConfig) -> Self {
Self {
state: Arc::new(RwLock::new(CircuitState::Closed)),
consecutive_failures: Arc::new(AtomicU32::new(0)),
consecutive_successes: Arc::new(AtomicU32::new(0)),
total_requests: Arc::new(AtomicU32::new(0)),
failed_requests: Arc::new(AtomicU32::new(0)),
last_opened_at: Arc::new(RwLock::new(None)),
config: Arc::new(RwLock::new(config)),
half_open_requests: Arc::new(AtomicU32::new(0)),
}
}
/// 更新熔断器配置(热更新,不重置状态)
pub async fn update_config(&self, new_config: CircuitBreakerConfig) {
*self.config.write().await = new_config;
}
/// 判断当前 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!(
"[{}] 熔断器 Open → HalfOpen (超时恢复)",
log_cb::OPEN_TO_HALF_OPEN
);
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!(
"[{}] 熔断器 Open → HalfOpen (超时恢复)",
log_cb::OPEN_TO_HALF_OPEN
);
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);
if state == CircuitState::HalfOpen {
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
if successes >= config.success_threshold {
drop(config); // 释放读锁再转换状态
log::info!(
"[{}] 熔断器 HalfOpen → Closed (恢复正常)",
log_cb::HALF_OPEN_TO_CLOSED
);
self.transition_to_closed().await;
}
}
}
/// 记录失败
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);
// 检查是否应该打开熔断器
match state {
CircuitState::HalfOpen => {
// HalfOpen 状态下失败,立即转为 Open
log::warn!(
"[{}] 熔断器 HalfOpen 探测失败 → Open",
log_cb::HALF_OPEN_PROBE_FAILED
);
drop(config);
self.transition_to_open().await;
}
CircuitState::Closed => {
// 检查连续失败次数
if failures >= config.failure_threshold {
log::warn!(
"[{}] 熔断器触发: 连续失败 {failures} 次 → Open",
log_cb::TRIGGERED_FAILURES
);
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;
if error_rate >= config.error_rate_threshold {
log::warn!(
"[{}] 熔断器触发: 错误率 {:.1}% → Open",
log_cb::TRIGGERED_ERROR_RATE,
error_rate * 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!("[{}] 熔断器手动重置 → Closed", log_cb::MANUAL_RESET);
self.transition_to_closed().await;
}
fn allow_half_open_probe(&self) -> AllowResult {
// 半开状态限流:只允许有限请求通过进行探测
let max_half_open_requests = 1u32;
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
if current < max_half_open_requests {
AllowResult {
allowed: true,
used_half_open_permit: true,
}
} else {
// 超过限额,回退计数,拒绝请求
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
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 {
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);
}
}
+207
View File
@@ -0,0 +1,207 @@
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("停止超时")]
StopTimeout,
#[error("停止失败: {0}")]
StopFailed(String),
#[error("请求转发失败: {0}")]
ForwardFailed(String),
#[error("无可用的Provider")]
NoAvailableProvider,
#[error("所有供应商已熔断,无可用渠道")]
AllProvidersCircuitOpen,
#[error("未配置供应商")]
NoProvidersConfigured,
#[allow(dead_code)]
#[error("Provider不健康: {0}")]
ProviderUnhealthy(String),
#[error("上游错误 (状态码 {status}): {body:?}")]
UpstreamError { status: u16, body: Option<String> },
#[error("超过最大重试次数")]
MaxRetriesExceeded,
#[error("数据库错误: {0}")]
DatabaseError(String),
#[error("配置错误: {0}")]
ConfigError(String),
#[allow(dead_code)]
#[error("格式转换错误: {0}")]
TransformError(String),
#[allow(dead_code)]
#[error("无效的请求: {0}")]
InvalidRequest(String),
#[error("超时: {0}")]
Timeout(String),
/// 流式响应空闲超时
#[allow(dead_code)]
#[error("流式响应空闲超时: {0}秒无数据")]
StreamIdleTimeout(u64),
/// 认证错误
#[allow(dead_code)]
#[error("认证失败: {0}")]
AuthError(String),
#[allow(dead_code)]
#[error("内部错误: {0}")]
Internal(String),
}
impl IntoResponse for ProxyError {
fn into_response(self) -> Response {
let (status, body) = match &self {
ProxyError::UpstreamError {
status: upstream_status,
body: upstream_body,
} => {
let http_status =
StatusCode::from_u16(*upstream_status).unwrap_or(StatusCode::BAD_GATEWAY);
// 尝试解析上游响应体为 JSON,如果失败则包装为字符串
let error_body = if let Some(body_str) = upstream_body {
if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(body_str) {
// 上游返回的是 JSON,直接透传
json_body
} else {
// 上游返回的不是 JSON,包装为错误消息
json!({
"error": {
"message": body_str,
"type": "upstream_error",
}
})
}
} else {
json!({
"error": {
"message": format!("Upstream error (status {})", upstream_status),
"type": "upstream_error",
}
})
};
(http_status, error_body)
}
_ => {
let (http_status, message) = match &self {
ProxyError::AlreadyRunning => (StatusCode::CONFLICT, self.to_string()),
ProxyError::NotRunning => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()),
ProxyError::BindFailed(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::StopTimeout => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::StopFailed(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
ProxyError::NoAvailableProvider => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
}
ProxyError::AllProvidersCircuitOpen => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
}
ProxyError::NoProvidersConfigured => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
}
ProxyError::ProviderUnhealthy(_) => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
}
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
}
}
+118
View File
@@ -0,0 +1,118 @@
//! 错误类型到 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::AllProvidersCircuitOpen => 503,
// 未配置供应商:503 Service Unavailable
ProxyError::NoProvidersConfigured => 503,
// 重试耗尽:503 Service Unavailable
ProxyError::MaxRetriesExceeded => 503,
// Provider 不健康:503 Service Unavailable
ProxyError::ProviderUnhealthy(_) => 503,
// 数据库错误:500 Internal Server Error
ProxyError::DatabaseError(_) => 500,
// 转换错误:500 Internal Server Error
ProxyError::TransformError(_) => 500,
// 其他未知错误:500 Internal Server Error
_ => 500,
}
}
/// 将 ProxyError 转换为用户友好的错误消息
pub fn get_error_message(error: &ProxyError) -> String {
match error {
ProxyError::UpstreamError { status, body } => {
if let Some(body) = body {
format!("上游错误 ({status}): {body}")
} else {
format!("上游错误 ({status})")
}
}
ProxyError::Timeout(msg) => format!("请求超时: {msg}"),
ProxyError::ForwardFailed(msg) => format!("转发失败: {msg}"),
ProxyError::NoAvailableProvider => "无可用 Provider".to_string(),
ProxyError::AllProvidersCircuitOpen => "所有供应商已熔断,无可用渠道".to_string(),
ProxyError::NoProvidersConfigured => "未配置供应商".to_string(),
ProxyError::MaxRetriesExceeded => "所有 Provider 都失败,重试耗尽".to_string(),
ProxyError::ProviderUnhealthy(msg) => format!("Provider 不健康: {msg}"),
ProxyError::DatabaseError(msg) => format!("数据库错误: {msg}"),
ProxyError::TransformError(msg) => format!("请求/响应转换错误: {msg}"),
_ => error.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_map_upstream_error() {
let error = ProxyError::UpstreamError {
status: 401,
body: Some("Unauthorized".to_string()),
};
assert_eq!(map_proxy_error_to_status(&error), 401);
}
#[test]
fn test_map_timeout_error() {
let error = ProxyError::Timeout("Request timeout".to_string());
assert_eq!(map_proxy_error_to_status(&error), 504);
}
#[test]
fn test_map_connection_error() {
let error = ProxyError::ForwardFailed("Connection refused".to_string());
assert_eq!(map_proxy_error_to_status(&error), 502);
}
#[test]
fn test_map_no_provider_error() {
let error = ProxyError::NoAvailableProvider;
assert_eq!(map_proxy_error_to_status(&error), 503);
}
#[test]
fn test_get_error_message() {
let error = ProxyError::UpstreamError {
status: 500,
body: Some("Internal Server Error".to_string()),
};
let msg = get_error_message(&error);
assert!(msg.contains("上游错误"));
assert!(msg.contains("500"));
assert!(msg.contains("Internal Server Error"));
}
}
+147
View File
@@ -0,0 +1,147 @@
//! 故障转移切换模块
//!
//! 处理故障转移成功后的供应商切换逻辑,包括:
//! - 去重控制(避免多个请求同时触发)
//! - 数据库更新
//! - 托盘菜单更新
//! - 前端事件发射
//! - Live 备份更新
use crate::database::Database;
use crate::error::AppError;
use std::collections::HashSet;
use std::str::FromStr;
use std::sync::Arc;
use tauri::{Emitter, Manager};
use tokio::sync::RwLock;
/// 故障转移切换管理器
///
/// 负责处理故障转移成功后的供应商切换,确保 UI 能够直观反映当前使用的供应商。
#[derive(Clone)]
pub struct FailoverSwitchManager {
/// 正在处理中的切换(key = "app_type:provider_id"
pending_switches: Arc<RwLock<HashSet<String>>>,
db: Arc<Database>,
}
impl FailoverSwitchManager {
pub fn new(db: Arc<Database>) -> Self {
Self {
pending_switches: Arc::new(RwLock::new(HashSet::new())),
db,
}
}
/// 尝试执行故障转移切换
///
/// 如果相同的切换已在进行中,则跳过;否则执行切换逻辑。
///
/// # Returns
/// - `Ok(true)` - 切换成功执行
/// - `Ok(false)` - 切换已在进行中,跳过
/// - `Err(e)` - 切换过程中发生错误
pub async fn try_switch(
&self,
app_handle: Option<&tauri::AppHandle>,
app_type: &str,
provider_id: &str,
provider_name: &str,
) -> Result<bool, AppError> {
let switch_key = format!("{app_type}:{provider_id}");
// 去重检查:如果相同切换已在进行中,跳过
{
let mut pending = self.pending_switches.write().await;
if pending.contains(&switch_key) {
log::debug!("[Failover] 切换已在进行中,跳过: {app_type} -> {provider_id}");
return Ok(false);
}
pending.insert(switch_key.clone());
}
// 执行切换(确保最后清理 pending 标记)
let result = self
.do_switch(app_handle, app_type, provider_id, provider_name)
.await;
// 清理 pending 标记
{
let mut pending = self.pending_switches.write().await;
pending.remove(&switch_key);
}
result
}
async fn do_switch(
&self,
app_handle: Option<&tauri::AppHandle>,
app_type: &str,
provider_id: &str,
provider_name: &str,
) -> Result<bool, AppError> {
// 检查该应用是否已被代理接管(enabled=true
// 只有被接管的应用才允许执行故障转移切换
let app_enabled = match self.db.get_proxy_config_for_app(app_type).await {
Ok(config) => config.enabled,
Err(e) => {
log::warn!("[FO-002] 无法读取 {app_type} 配置: {e},跳过切换");
return Ok(false);
}
};
if !app_enabled {
log::debug!("[Failover] {app_type} 未启用代理,跳过切换");
return Ok(false);
}
log::info!("[FO-001] 切换: {app_type} → {provider_name}");
// 1. 更新数据库 is_current
self.db.set_current_provider(app_type, provider_id)?;
// 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!("[FO-003] 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}");
}
}
Ok(true)
}
}
+506
View File
@@ -0,0 +1,506 @@
//! 请求转发器
//!
//! 负责将请求转发到上游Provider,支持故障转移
use super::{
body_filter::filter_private_params_with_whitelist,
error::*,
failover_switch::FailoverSwitchManager,
provider_router::ProviderRouter,
providers::{get_adapter, ProviderAdapter},
types::ProxyStatus,
ProxyError,
};
use crate::{app_config::AppType, provider::Provider};
use reqwest::Response;
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Headers 黑名单 - 不透传到上游的 Headers
///
/// 精简版黑名单,只过滤必须覆盖或可能导致问题的 header
/// 参考成功透传的请求,保留更多原始 header
///
/// 注意:客户端 IP 类(x-forwarded-for, x-real-ip)默认透传
const HEADER_BLACKLIST: &[&str] = &[
// 认证类(会被覆盖)
"authorization",
"x-api-key",
// 连接类(由 HTTP 客户端管理)
"host",
"content-length",
"transfer-encoding",
// 编码类(会被覆盖为 identity)
"accept-encoding",
// 代理转发类(保留 x-forwarded-for 和 x-real-ip
"x-forwarded-host",
"x-forwarded-port",
"x-forwarded-proto",
"forwarded",
// CDN/云服务商特定头
"cf-connecting-ip",
"cf-ipcountry",
"cf-ray",
"cf-visitor",
"true-client-ip",
"fastly-client-ip",
"x-azure-clientip",
"x-azure-fdid",
"x-azure-ref",
"akamai-origin-hop",
"x-akamai-config-log-detail",
// 请求追踪类
"x-request-id",
"x-correlation-id",
"x-trace-id",
"x-amzn-trace-id",
"x-b3-traceid",
"x-b3-spanid",
"x-b3-parentspanid",
"x-b3-sampled",
"traceparent",
"tracestate",
// anthropic 特定头单独处理,避免重复
"anthropic-beta",
"anthropic-version",
// 客户端 IP 单独处理(默认透传)
"x-forwarded-for",
"x-real-ip",
];
pub struct ForwardResult {
pub response: Response,
pub provider: Provider,
}
pub struct ForwardError {
pub error: ProxyError,
pub provider: Option<Provider>,
}
pub struct RequestForwarder {
/// 共享的 ProviderRouter(持有熔断器状态)
router: Arc<ProviderRouter>,
status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
/// 故障转移切换管理器
failover_manager: Arc<FailoverSwitchManager>,
/// AppHandle,用于发射事件和更新托盘
app_handle: Option<tauri::AppHandle>,
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
current_provider_id_at_start: String,
/// 非流式请求超时(秒)
non_streaming_timeout: std::time::Duration,
}
impl RequestForwarder {
#[allow(clippy::too_many_arguments)]
pub fn new(
router: Arc<ProviderRouter>,
non_streaming_timeout: u64,
status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
failover_manager: Arc<FailoverSwitchManager>,
app_handle: Option<tauri::AppHandle>,
current_provider_id_at_start: String,
_streaming_first_byte_timeout: u64,
_streaming_idle_timeout: u64,
) -> Self {
Self {
router,
status,
current_providers,
failover_manager,
app_handle,
current_provider_id_at_start,
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
}
}
/// 转发请求(带故障转移)
///
/// # Arguments
/// * `app_type` - 应用类型
/// * `endpoint` - API 端点
/// * `body` - 请求体
/// * `headers` - 请求头
/// * `providers` - 已选择的 Provider 列表(由 RequestContext 提供,避免重复调用 select_providers
pub async fn forward_with_retry(
&self,
app_type: &AppType,
endpoint: &str,
body: Value,
headers: axum::http::HeaderMap,
providers: Vec<Provider>,
) -> Result<ForwardResult, ForwardError> {
// 获取适配器
let adapter = get_adapter(app_type);
let app_type_str = app_type.as_str();
if providers.is_empty() {
return Err(ForwardError {
error: ProxyError::NoAvailableProvider,
provider: None,
});
}
let mut last_error = None;
let mut last_provider = None;
let mut attempted_providers = 0usize;
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
let bypass_circuit_breaker = providers.len() == 1;
// 依次尝试每个供应商
for provider in providers.iter() {
// 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额)
// 单 Provider 场景下跳过此检查,避免熔断器阻塞所有请求
let (allowed, used_half_open_permit) = if bypass_circuit_breaker {
(true, false)
} else {
let permit = self
.router
.allow_provider_request(&provider.id, app_type_str)
.await;
(permit.allowed, permit.used_half_open_permit)
};
if !allowed {
continue;
}
attempted_providers += 1;
// 更新状态中的当前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());
}
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
match self
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
.await
{
Ok(response) => {
// 成功:记录成功并更新熔断器
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await;
// 更新当前应用类型使用的 provider
{
let mut current_providers = self.current_providers.write().await;
current_providers.insert(
app_type_str.to_string(),
(provider.id.clone(), provider.name.clone()),
);
}
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
status.last_error = None;
let should_switch =
self.current_provider_id_at_start.as_str() != provider.id.as_str();
if should_switch {
status.failover_count += 1;
// 异步触发供应商切换,更新 UI/托盘,并把“当前供应商”同步为实际使用的 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 {
let _ = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await;
});
}
// 重新计算成功率
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
}
return Ok(ForwardResult {
response,
provider: provider.clone(),
});
}
Err(e) => {
// 失败:记录失败并更新熔断器
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
false,
Some(e.to_string()),
)
.await;
// 分类错误
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!(
"[{}] [FWD-001] Provider {} 失败,切换下一个 ({}/{})",
app_type_str,
provider.name,
attempted_providers,
providers.len()
);
last_error = Some(e);
last_provider = Some(provider.clone());
// 继续尝试下一个供应商
continue;
}
ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => {
// 不可重试:直接返回错误
{
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
}
}
}
}
if attempted_providers == 0 {
// providers 列表非空,但全部被熔断器拒绝(典型:HalfOpen 探测名额被占用)
{
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some("所有供应商暂时不可用(熔断器限制)".to_string());
if status.total_requests > 0 {
status.success_rate =
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
}
}
return Err(ForwardError {
error: ProxyError::NoAvailableProvider,
provider: None,
});
}
// 所有供应商都失败了
{
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some("所有供应商都失败".to_string());
if status.total_requests > 0 {
status.success_rate =
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
}
}
log::warn!("[{app_type_str}] [FWD-002] 所有 Provider 均失败");
Err(ForwardError {
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
provider: last_provider,
})
}
/// 转发单个请求(使用适配器)
async fn forward(
&self,
provider: &Provider,
endpoint: &str,
body: &Value,
headers: &axum::http::HeaderMap,
adapter: &dyn ProviderAdapter,
) -> Result<Response, ProxyError> {
// 使用适配器提取 base_url
let base_url = adapter.extract_base_url(provider)?;
// 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider);
let effective_endpoint =
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
"/v1/chat/completions"
} else {
endpoint
};
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, effective_endpoint);
// 应用模型映射(独立于格式转换)
let (mapped_body, _original_model, _mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
// 转换请求体(如果需要)
let request_body = if needs_transform {
adapter.transform_request(mapped_body, provider)?
} else {
mapped_body
};
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
// 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
// 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置)
let client = super::http_client::get();
let mut request = client.post(&url);
// 只有当 timeout > 0 时才设置请求超时
// Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时"
// 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒)
if !self.non_streaming_timeout.is_zero() {
request = request.timeout(self.non_streaming_timeout);
}
// 过滤黑名单 Headers,保护隐私并避免冲突
for (key, value) in headers {
if HEADER_BLACKLIST
.iter()
.any(|h| key.as_str().eq_ignore_ascii_case(h))
{
continue;
}
request = request.header(key, value);
}
// 处理 anthropic-beta Header(仅 Claude
// 关键:确保包含 claude-code-20250219 标记,这是上游服务验证请求来源的依据
// 如果客户端发送的 beta 标记中没有包含 claude-code-20250219,需要补充
if adapter.name() == "Claude" {
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
let beta_value = if let Some(beta) = headers.get("anthropic-beta") {
if let Ok(beta_str) = beta.to_str() {
// 检查是否已包含 claude-code-20250219
if beta_str.contains(CLAUDE_CODE_BETA) {
beta_str.to_string()
} else {
// 补充 claude-code-20250219
format!("{CLAUDE_CODE_BETA},{beta_str}")
}
} else {
CLAUDE_CODE_BETA.to_string()
}
} else {
// 如果客户端没有发送,使用默认值
CLAUDE_CODE_BETA.to_string()
};
request = request.header("anthropic-beta", &beta_value);
}
// 客户端 IP 透传(默认开启)
if let Some(xff) = headers.get("x-forwarded-for") {
if let Ok(xff_str) = xff.to_str() {
request = request.header("x-forwarded-for", xff_str);
}
}
if let Some(real_ip) = headers.get("x-real-ip") {
if let Ok(real_ip_str) = real_ip.to_str() {
request = request.header("x-real-ip", real_ip_str);
}
}
// 禁用压缩,避免 gzip 流式响应解析错误
// 参考 CCH: undici 在连接提前关闭时会对不完整的 gzip 流抛出错误
request = request.header("accept-encoding", "identity");
// 使用适配器添加认证头
if let Some(auth) = adapter.extract_auth(provider) {
request = adapter.add_auth_headers(request, &auth);
}
// anthropic-version 统一处理(仅 Claude):优先使用客户端的版本号,否则使用默认值
// 注意:只设置一次,避免重复
if adapter.name() == "Claude" {
let version_str = headers
.get("anthropic-version")
.and_then(|v| v.to_str().ok())
.unwrap_or("2023-06-01");
request = request.header("anthropic-version", version_str);
}
// 发送请求
let response = request.json(&filtered_body).send().await.map_err(|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();
if status.is_success() {
Ok(response)
} else {
let status_code = status.as_u16();
let body_text = response.text().await.ok();
Err(ProxyError::UpstreamError {
status: status_code,
body: body_text,
})
}
}
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
match error {
// 网络和上游错误:都应该尝试下一个供应商
ProxyError::Timeout(_) => ErrorCategory::Retryable,
ProxyError::ForwardFailed(_) => ErrorCategory::Retryable,
ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable,
// 上游 HTTP 错误:无论状态码如何,都尝试下一个供应商
// 原因:不同供应商有不同的限制和认证,一个供应商的 4xx 错误
// 不代表其他供应商也会失败
ProxyError::UpstreamError { .. } => ErrorCategory::Retryable,
// Provider 级配置/转换问题:换一个 Provider 可能就能成功
ProxyError::ConfigError(_) => ErrorCategory::Retryable,
ProxyError::TransformError(_) => ErrorCategory::Retryable,
ProxyError::AuthError(_) => ErrorCategory::Retryable,
ProxyError::StreamIdleTimeout(_) => ErrorCategory::Retryable,
// 无可用供应商:所有供应商都试过了,无法重试
ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
// 其他错误(数据库/内部错误等):不是换供应商能解决的问题
_ => ErrorCategory::NonRetryable,
}
}
}
+188
View File
@@ -0,0 +1,188 @@
//! Handler 配置模块
//!
//! 定义各 API 处理器的配置结构和使用量解析器
use crate::app_config::AppType;
use crate::proxy::usage::parser::TokenUsage;
use serde_json::Value;
/// 使用量解析器类型别名
pub type StreamUsageParser = fn(&[Value]) -> Option<TokenUsage>;
pub type ResponseUsageParser = fn(&Value) -> Option<TokenUsage>;
/// 模型提取器类型别名
/// 参数: (流式事件列表, 请求中的模型名称) -> 最终使用的模型名称
pub type StreamModelExtractor = fn(&[Value], &str) -> String;
/// 各 API 的使用量解析配置
#[derive(Clone, Copy)]
pub struct UsageParserConfig {
/// 流式响应解析器
pub stream_parser: StreamUsageParser,
/// 非流式响应解析器
pub response_parser: ResponseUsageParser,
/// 流式响应中的模型提取器
pub model_extractor: StreamModelExtractor,
/// 应用类型字符串(用于日志记录)
pub app_type_str: &'static str,
}
// ============================================================================
// 模型提取器实现
// ============================================================================
/// Claude 流式响应模型提取(优先使用 usage.model
fn claude_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_claude_stream_events(events) {
if let Some(model) = usage.model {
return model;
}
}
request_model.to_string()
}
/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model
fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_openai_stream_events(events) {
if let Some(model) = usage.model {
return model;
}
}
// 回退:从事件中直接提取
events
.iter()
.find_map(|e| e.get("model")?.as_str())
.unwrap_or(request_model)
.to_string()
}
/// Codex 智能流式响应模型提取(自动检测格式)
fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) {
if let Some(model) = usage.model {
return model;
}
}
// 回退:从 response.completed 事件中提取
events
.iter()
.find_map(|e| {
if e.get("type")?.as_str()? == "response.completed" {
e.get("response")?.get("model")?.as_str()
} else {
None
}
})
.or_else(|| {
// 再回退:从 OpenAI 格式事件中提取
events.iter().find_map(|e| e.get("model")?.as_str())
})
.unwrap_or(request_model)
.to_string()
}
/// Gemini 流式响应模型提取(优先使用 usage.model
fn gemini_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_gemini_stream_chunks(events) {
if let Some(model) = usage.model {
return model;
}
}
request_model.to_string()
}
// ============================================================================
// 预定义配置
// ============================================================================
/// Claude API 解析配置
pub const CLAUDE_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_claude_stream_events,
response_parser: TokenUsage::from_claude_response,
model_extractor: claude_model_extractor,
app_type_str: "claude",
};
/// OpenAI Chat Completions API 解析配置(用于 Codex /v1/chat/completions
pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_openai_stream_events,
response_parser: TokenUsage::from_openai_response,
model_extractor: openai_model_extractor,
app_type_str: "codex",
};
/// Codex 智能解析配置(自动检测 OpenAI 或 Codex 格式)
pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_codex_stream_events_auto,
response_parser: TokenUsage::from_codex_response_auto,
model_extractor: codex_auto_model_extractor,
app_type_str: "codex",
};
/// Gemini API 解析配置
pub const GEMINI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_gemini_stream_chunks,
response_parser: TokenUsage::from_gemini_response,
model_extractor: gemini_model_extractor,
app_type_str: "gemini",
};
// ============================================================================
// Handler 配置(预留,用于进一步简化)
// ============================================================================
/// Handler 基础配置
///
/// 预留结构,可用于进一步统一各 handler 的配置
#[allow(dead_code)]
#[derive(Clone)]
pub struct HandlerConfig {
/// 应用类型
pub app_type: AppType,
/// 日志标签
pub tag: &'static str,
/// 应用类型字符串
pub app_type_str: &'static str,
/// 使用量解析配置
pub parser_config: &'static UsageParserConfig,
}
/// Claude Handler 配置
#[allow(dead_code)]
pub const CLAUDE_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
app_type: AppType::Claude,
tag: "Claude",
app_type_str: "claude",
parser_config: &CLAUDE_PARSER_CONFIG,
};
/// Codex Chat Completions Handler 配置
#[allow(dead_code)]
pub const CODEX_CHAT_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
app_type: AppType::Codex,
tag: "Codex",
app_type_str: "codex",
parser_config: &OPENAI_PARSER_CONFIG,
};
/// Codex Responses Handler 配置
#[allow(dead_code)]
pub const CODEX_RESPONSES_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
app_type: AppType::Codex,
tag: "Codex",
app_type_str: "codex",
parser_config: &CODEX_PARSER_CONFIG,
};
/// Gemini Handler 配置
#[allow(dead_code)]
pub const GEMINI_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
app_type: AppType::Gemini,
tag: "Gemini",
app_type_str: "gemini",
parser_config: &GEMINI_PARSER_CONFIG,
};
+246
View File
@@ -0,0 +1,246 @@
//! 请求上下文模块
//!
//! 提供请求生命周期的上下文管理,封装通用初始化逻辑
use crate::app_config::AppType;
use crate::provider::Provider;
use crate::proxy::{
extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig,
ProxyError,
};
use axum::http::HeaderMap;
use std::time::Instant;
/// 流式超时配置
#[derive(Debug, Clone, Copy)]
pub struct StreamingTimeoutConfig {
/// 首字节超时(秒),0 表示禁用
pub first_byte_timeout: u64,
/// 静默期超时(秒),0 表示禁用
pub idle_timeout: u64,
}
/// 请求上下文
///
/// 贯穿整个请求生命周期,包含:
/// - 计时信息
/// - 应用级代理配置(per-app
/// - 选中的 Provider 列表(用于故障转移)
/// - 请求模型名称
/// - 日志标签
/// - Session ID(用于日志关联)
pub struct RequestContext {
/// 请求开始时间
pub start_time: Instant,
/// 应用级代理配置(per-app,包含重试次数和超时配置)
pub app_config: AppProxyConfig,
/// 选中的 Provider(故障转移链的第一个)
pub provider: Provider,
/// 完整的 Provider 列表(用于故障转移)
providers: Vec<Provider>,
/// 请求开始时的"当前供应商"(用于判断是否需要同步 UI/托盘)
///
/// 这里使用本地 settings 的设备级 current provider。
/// 代理模式下如果实际使用的 provider 与此不一致,会触发切换以确保 UI 始终准确。
pub current_provider_id: String,
/// 请求中的模型名称
pub request_model: String,
/// 日志标签(如 "Claude"、"Codex"、"Gemini"
pub tag: &'static str,
/// 应用类型字符串(如 "claude"、"codex"、"gemini"
pub app_type_str: &'static str,
/// 应用类型(预留,目前通过 app_type_str 使用)
#[allow(dead_code)]
pub app_type: AppType,
/// Session ID(从客户端请求提取或新生成)
pub session_id: String,
}
impl RequestContext {
/// 创建请求上下文
///
/// # Arguments
/// * `state` - 代理服务器状态
/// * `body` - 请求体 JSON
/// * `headers` - 请求头(用于提取 Session ID
/// * `app_type` - 应用类型
/// * `tag` - 日志标签
/// * `app_type_str` - 应用类型字符串
///
/// # Errors
/// 返回 `ProxyError` 如果 Provider 选择失败
pub async fn new(
state: &ProxyState,
body: &serde_json::Value,
headers: &HeaderMap,
app_type: AppType,
tag: &'static str,
app_type_str: &'static str,
) -> Result<Self, ProxyError> {
let start_time = Instant::now();
// 从数据库读取应用级代理配置(per-app)
let app_config = state
.db
.get_proxy_config_for_app(app_type_str)
.await
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
let current_provider_id =
crate::settings::get_current_provider(&app_type).unwrap_or_default();
// 从请求体提取模型名称
let request_model = body
.get("model")
.and_then(|m| m.as_str())
.unwrap_or("unknown")
.to_string();
// 提取 Session ID
let session_result = extract_session_id(headers, body, app_type_str);
let session_id = session_result.session_id.clone();
log::debug!(
"[{}] Session ID: {} (from {:?}, client_provided: {})",
tag,
session_id,
session_result.source,
session_result.client_provided
);
// 使用共享的 ProviderRouter 选择 Provider(熔断器状态跨请求保持)
// 注意:只在这里调用一次,结果传递给 forwarder,避免重复消耗 HalfOpen 名额
let providers = state
.provider_router
.select_providers(app_type_str)
.await
.map_err(|e| match e {
crate::error::AppError::AllProvidersCircuitOpen => {
ProxyError::AllProvidersCircuitOpen
}
crate::error::AppError::NoProvidersConfigured => ProxyError::NoProvidersConfigured,
_ => ProxyError::DatabaseError(e.to_string()),
})?;
let provider = providers
.first()
.cloned()
.ok_or(ProxyError::NoAvailableProvider)?;
log::debug!(
"[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}",
tag,
provider.name,
request_model,
providers.len(),
session_id
);
Ok(Self {
start_time,
app_config,
provider,
providers,
current_provider_id,
request_model,
tag,
app_type_str,
app_type,
session_id,
})
}
/// 从 URI 提取模型名称(Gemini 专用)
///
/// Gemini API 的模型名称在 URI 中,格式如:
/// `/v1beta/models/gemini-pro:generateContent`
pub fn with_model_from_uri(mut self, uri: &axum::http::Uri) -> Self {
let endpoint = uri
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or(uri.path());
self.request_model = endpoint
.split('/')
.find(|s| s.starts_with("models/"))
.and_then(|s| s.strip_prefix("models/"))
.map(|s| s.split(':').next().unwrap_or(s))
.unwrap_or("unknown")
.to_string();
self
}
/// 创建 RequestForwarder
///
/// 使用共享的 ProviderRouter,确保熔断器状态跨请求保持
///
/// 配置生效规则:
/// - 故障转移开启:超时配置正常生效(0 表示禁用超时)
/// - 故障转移关闭:超时配置不生效(全部传入 0)
pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder {
let (non_streaming_timeout, first_byte_timeout, idle_timeout) =
if self.app_config.auto_failover_enabled {
// 故障转移开启:使用配置的值(0 = 禁用超时)
(
self.app_config.non_streaming_timeout as u64,
self.app_config.streaming_first_byte_timeout as u64,
self.app_config.streaming_idle_timeout as u64,
)
} else {
// 故障转移关闭:不启用超时配置
log::debug!(
"[{}] Failover disabled, timeout configs are bypassed",
self.tag
);
(0, 0, 0)
};
RequestForwarder::new(
state.provider_router.clone(),
non_streaming_timeout,
state.status.clone(),
state.current_providers.clone(),
state.failover_manager.clone(),
state.app_handle.clone(),
self.current_provider_id.clone(),
first_byte_timeout,
idle_timeout,
)
}
/// 获取 Provider 列表(用于故障转移)
///
/// 返回在创建上下文时已选择的 providers,避免重复调用 select_providers()
pub fn get_providers(&self) -> Vec<Provider> {
self.providers.clone()
}
/// 计算请求延迟(毫秒)
#[inline]
pub fn latency_ms(&self) -> u64 {
self.start_time.elapsed().as_millis() as u64
}
/// 获取流式超时配置
///
/// 配置生效规则:
/// - 故障转移开启:返回配置的值(0 表示禁用超时检查)
/// - 故障转移关闭:返回 0(禁用超时检查)
#[inline]
pub fn streaming_timeout_config(&self) -> StreamingTimeoutConfig {
if self.app_config.auto_failover_enabled {
// 故障转移开启:使用配置的值(0 = 禁用超时)
StreamingTimeoutConfig {
first_byte_timeout: self.app_config.streaming_first_byte_timeout as u64,
idle_timeout: self.app_config.streaming_idle_timeout as u64,
}
} else {
// 故障转移关闭:禁用流式超时检查
StreamingTimeoutConfig {
first_byte_timeout: 0,
idle_timeout: 0,
}
}
}
}
+493
View File
@@ -0,0 +1,493 @@
//! 请求处理器
//!
//! 处理各种API端点的HTTP请求
//!
//! 重构后的结构:
//! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块
//! - 各 handler 只保留独特的业务逻辑
//! - Claude 的格式转换逻辑保留在此文件(用于 OpenRouter 旧接口回退)
use super::{
error_mapper::{get_error_message, map_proxy_error_to_status},
handler_config::{
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
},
handler_context::RequestContext,
providers::{get_adapter, streaming::create_anthropic_sse_stream, transform},
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
server::ProxyState,
types::*,
usage::parser::TokenUsage,
ProxyError,
};
use crate::app_config::AppType;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use rust_decimal::Decimal;
use serde_json::{json, Value};
use std::str::FromStr;
// ============================================================================
// 健康检查和状态查询(简单端点)
// ============================================================================
/// 健康检查
pub async fn health_check() -> (StatusCode, Json<Value>) {
(
StatusCode::OK,
Json(json!({
"status": "healthy",
"timestamp": chrono::Utc::now().to_rfc3339(),
})),
)
}
/// 获取服务状态
pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxyStatus>, ProxyError> {
let status = state.status.read().await.clone();
Ok(Json(status))
}
// ============================================================================
// Claude API 处理器(包含格式转换逻辑)
// ============================================================================
/// 处理 /v1/messages 请求(Claude API
///
/// Claude 处理器包含独特的格式转换逻辑:
/// - 过去用于 OpenRouter 的 OpenAI Chat Completions 兼容接口(Anthropic ↔ OpenAI 转换)
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
pub async fn handle_messages(
State(state): State<ProxyState>,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
let is_stream = body
.get("stream")
.and_then(|s| s.as_bool())
.unwrap_or(false);
// 转发请求
let forwarder = ctx.create_forwarder(&state);
let result = match forwarder
.forward_with_retry(
&AppType::Claude,
"/v1/messages",
body.clone(),
headers,
ctx.get_providers(),
)
.await
{
Ok(result) => result,
Err(mut err) => {
if let Some(provider) = err.provider.take() {
ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
}
};
ctx.provider = result.provider;
let response = result.response;
// 检查是否需要格式转换(OpenRouter 等中转服务)
let adapter = get_adapter(&AppType::Claude);
let needs_transform = adapter.needs_transform(&ctx.provider);
// Claude 特有:格式转换处理
if needs_transform {
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
}
// 通用响应处理(透传模式)
process_response(response, &ctx, &state, &CLAUDE_PARSER_CONFIG).await
}
/// Claude 格式转换处理(独有逻辑)
///
/// 处理 OpenRouter 旧 OpenAI 兼容接口的回退方案(当前默认不启用)
async fn handle_claude_transform(
response: reqwest::Response,
ctx: &RequestContext,
state: &ProxyState,
_original_body: &Value,
is_stream: bool,
) -> Result<axum::response::Response, ProxyError> {
let status = response.status();
if is_stream {
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
let stream = response.bytes_stream();
let sse_stream = create_anthropic_sse_stream(stream);
// 创建使用量收集器
let usage_collector = {
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let model = ctx.request_model.clone();
let status_code = status.as_u16();
let start_time = ctx.start_time;
SseUsageCollector::new(start_time, move |events, first_token_ms| {
if let Some(usage) = TokenUsage::from_claude_stream_events(&events) {
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let model = model.clone();
tokio::spawn(async move {
log_usage(
&state,
&provider_id,
"claude",
&model,
usage,
latency_ms,
first_token_ms,
true,
status_code,
)
.await;
});
} else {
log::debug!("[Claude] OpenRouter 流式响应缺少 usage 统计,跳过消费记录");
}
})
};
// 获取流式超时配置
let timeout_config = ctx.streaming_timeout_config();
let logged_stream = create_logged_passthrough_stream(
sse_stream,
"Claude/OpenRouter",
Some(usage_collector),
timeout_config,
);
let mut headers = axum::http::HeaderMap::new();
headers.insert(
"Content-Type",
axum::http::HeaderValue::from_static("text/event-stream"),
);
headers.insert(
"Cache-Control",
axum::http::HeaderValue::from_static("no-cache"),
);
headers.insert(
"Connection",
axum::http::HeaderValue::from_static("keep-alive"),
);
let body = axum::body::Body::from_stream(logged_stream);
return Ok((headers, body).into_response());
}
// 非流式响应转换 (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);
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}"))
})?;
let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| {
log::error!("[Claude] 转换响应失败: {e}");
e
})?;
// 记录使用量
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;
}
});
}
// 构建响应
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}"))
})?;
let body = axum::body::Body::from(response_body);
builder.body(body).map_err(|e| {
log::error!("[Claude] 构建响应失败: {e}");
ProxyError::Internal(format!("Failed to build response: {e}"))
})
}
// ============================================================================
// 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> {
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let is_stream = body
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let forwarder = ctx.create_forwarder(&state);
let result = match forwarder
.forward_with_retry(
&AppType::Codex,
"/v1/chat/completions",
body,
headers,
ctx.get_providers(),
)
.await
{
Ok(result) => result,
Err(mut err) => {
if let Some(provider) = err.provider.take() {
ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
}
};
ctx.provider = result.provider;
let response = result.response;
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
}
/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传)
pub async fn handle_responses(
State(state): State<ProxyState>,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let is_stream = body
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let forwarder = ctx.create_forwarder(&state);
let result = match forwarder
.forward_with_retry(
&AppType::Codex,
"/v1/responses",
body,
headers,
ctx.get_providers(),
)
.await
{
Ok(result) => result,
Err(mut err) => {
if let Some(provider) = err.provider.take() {
ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
}
};
ctx.provider = result.provider;
let response = result.response;
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
}
// ============================================================================
// Gemini API 处理器
// ============================================================================
/// 处理 Gemini API 请求(透传,包括查询参数)
pub async fn handle_gemini(
State(state): State<ProxyState>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
// Gemini 的模型名称在 URI 中
let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini")
.await?
.with_model_from_uri(&uri);
// 提取完整的路径和查询参数
let endpoint = uri
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or(uri.path());
let is_stream = body
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let forwarder = ctx.create_forwarder(&state);
let result = match forwarder
.forward_with_retry(
&AppType::Gemini,
endpoint,
body,
headers,
ctx.get_providers(),
)
.await
{
Ok(result) => result,
Err(mut err) => {
if let Some(provider) = err.provider.take() {
ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
}
};
ctx.provider = result.provider;
let response = result.response;
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
}
// ============================================================================
// 使用量记录(保留用于 Claude 转换逻辑)
// ============================================================================
fn log_forward_error(
state: &ProxyState,
ctx: &RequestContext,
is_streaming: bool,
error: &ProxyError,
) {
use super::usage::logger::UsageLogger;
let logger = UsageLogger::new(&state.db);
let status_code = map_proxy_error_to_status(error);
let error_message = get_error_message(error);
let request_id = uuid::Uuid::new_v4().to_string();
if let Err(e) = logger.log_error_with_context(
request_id,
ctx.provider.id.clone(),
ctx.app_type_str.to_string(),
ctx.request_model.clone(),
status_code,
error_message,
ctx.latency_ms(),
is_streaming,
Some(ctx.session_id.clone()),
None,
) {
log::warn!("记录失败请求日志失败: {e}");
}
}
/// 记录请求使用量
#[allow(clippy::too_many_arguments)]
async fn log_usage(
state: &ProxyState,
provider_id: &str,
app_type: &str,
model: &str,
usage: TokenUsage,
latency_ms: u64,
first_token_ms: Option<u64>,
is_streaming: bool,
status_code: u16,
) {
use super::usage::logger::UsageLogger;
let logger = UsageLogger::new(&state.db);
// 获取 provider 的 cost_multiplier
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
Ok(Some(p)) => {
if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier {
Decimal::from_str(&cm).unwrap_or_else(|e| {
log::warn!(
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
);
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!("[USG-001] 记录使用量失败: {e}");
}
}
+7
View File
@@ -0,0 +1,7 @@
//! 健康检查器
//!
//! 负责定期检查Provider健康状态(占位实现)
// 占位实现,稍后添加完整逻辑
#[allow(dead_code)]
pub struct HealthChecker;
+301
View File
@@ -0,0 +1,301 @@
//! 全局 HTTP 客户端模块
//!
//! 提供支持全局代理配置的 HTTP 客户端。
//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。
use once_cell::sync::OnceCell;
use reqwest::Client;
use std::sync::RwLock;
use std::time::Duration;
/// 全局 HTTP 客户端实例
static GLOBAL_CLIENT: OnceCell<RwLock<Client>> = OnceCell::new();
/// 当前代理 URL(用于日志和状态查询)
static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
/// 初始化全局 HTTP 客户端
///
/// 应在应用启动时调用一次。
///
/// # Arguments
/// * `proxy_url` - 代理 URL,如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080`
/// 传入 None 或空字符串表示直连
pub fn init(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
let client = build_client(effective_url)?;
// 尝试初始化全局客户端,如果已存在则记录警告并使用 apply_proxy 更新
if GLOBAL_CLIENT.set(RwLock::new(client.clone())).is_err() {
log::warn!(
"[GlobalProxy] [GP-003] Already initialized, updating instead: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
// 已初始化,改用 apply_proxy 更新
return apply_proxy(proxy_url);
}
// 初始化代理 URL 记录
let _ = CURRENT_PROXY_URL.set(RwLock::new(effective_url.map(|s| s.to_string())));
log::info!(
"[GlobalProxy] Initialized: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 验证代理配置(不应用)
///
/// 只验证代理 URL 是否有效,不实际更新全局客户端。
/// 用于在持久化之前验证配置的有效性。
///
/// # Arguments
/// * `proxy_url` - 代理 URLNone 或空字符串表示直连
///
/// # Returns
/// 验证成功返回 Ok(()),失败返回错误信息
pub fn validate_proxy(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
// 只调用 build_client 来验证,但不应用
build_client(effective_url)?;
Ok(())
}
/// 应用代理配置(假设已验证)
///
/// 直接应用代理配置到全局客户端,不做额外验证。
/// 应在 validate_proxy 成功后调用。
///
/// # Arguments
/// * `proxy_url` - 代理 URLNone 或空字符串表示直连
pub fn apply_proxy(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
let new_client = build_client(effective_url)?;
// 更新客户端
if let Some(lock) = GLOBAL_CLIENT.get() {
let mut client = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
"Failed to update proxy: lock poisoned".to_string()
})?;
*client = new_client;
} else {
// 如果还没初始化,则初始化
return init(proxy_url);
}
// 更新代理 URL 记录
if let Some(lock) = CURRENT_PROXY_URL.get() {
let mut url = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
"Failed to update proxy URL record: lock poisoned".to_string()
})?;
*url = effective_url.map(|s| s.to_string());
}
log::info!(
"[GlobalProxy] Applied: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 更新代理配置(热更新)
///
/// 可在运行时调用以更改代理设置,无需重启应用。
/// 注意:此函数同时验证和应用,如果需要先验证后持久化再应用,
/// 请使用 validate_proxy + apply_proxy 组合。
///
/// # Arguments
/// * `proxy_url` - 新的代理 URLNone 或空字符串表示直连
#[allow(dead_code)]
pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> {
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
let new_client = build_client(effective_url)?;
// 更新客户端
if let Some(lock) = GLOBAL_CLIENT.get() {
let mut client = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
"Failed to update proxy: lock poisoned".to_string()
})?;
*client = new_client;
} else {
// 如果还没初始化,则初始化
return init(proxy_url);
}
// 更新代理 URL 记录
if let Some(lock) = CURRENT_PROXY_URL.get() {
let mut url = lock.write().map_err(|e| {
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
"Failed to update proxy URL record: lock poisoned".to_string()
})?;
*url = effective_url.map(|s| s.to_string());
}
log::info!(
"[GlobalProxy] Updated: {}",
effective_url
.map(mask_url)
.unwrap_or_else(|| "direct connection".to_string())
);
Ok(())
}
/// 获取全局 HTTP 客户端
///
/// 返回配置了代理的客户端(如果已配置代理),否则返回直连客户端。
pub fn get() -> Client {
GLOBAL_CLIENT
.get()
.and_then(|lock| lock.read().ok())
.map(|c| c.clone())
.unwrap_or_else(|| {
// 如果还没初始化,创建一个默认客户端(配置与 build_client 一致)
log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback");
Client::builder()
.timeout(Duration::from_secs(600))
.connect_timeout(Duration::from_secs(30))
.pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(60))
.no_proxy()
.build()
.unwrap_or_default()
})
}
/// 获取当前代理 URL
///
/// 返回当前配置的代理 URL,None 表示直连。
pub fn get_current_proxy_url() -> Option<String> {
CURRENT_PROXY_URL
.get()
.and_then(|lock| lock.read().ok())
.and_then(|url| url.clone())
}
/// 检查是否正在使用代理
#[allow(dead_code)]
pub fn is_proxy_enabled() -> bool {
get_current_proxy_url().is_some()
}
/// 构建 HTTP 客户端
fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
let mut builder = Client::builder()
.timeout(Duration::from_secs(600))
.connect_timeout(Duration::from_secs(30))
.pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(60));
// 有代理地址则使用代理,否则直连
if let Some(url) = proxy_url {
// 先验证 URL 格式和 scheme
let parsed = url::Url::parse(url)
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
let scheme = parsed.scheme();
if !["http", "https", "socks5", "socks5h"].contains(&scheme) {
return Err(format!(
"Invalid proxy scheme '{}' in URL '{}'. Supported: http, https, socks5, socks5h",
scheme,
mask_url(url)
));
}
let proxy = reqwest::Proxy::all(url)
.map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?;
builder = builder.proxy(proxy);
log::debug!("[GlobalProxy] Proxy configured: {}", mask_url(url));
} else {
builder = builder.no_proxy();
log::debug!("[GlobalProxy] Direct connection (no proxy)");
}
builder
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))
}
/// 隐藏 URL 中的敏感信息(用于日志)
pub fn mask_url(url: &str) -> String {
if let Ok(parsed) = url::Url::parse(url) {
// 隐藏用户名和密码,保留 scheme、host 和端口
let host = parsed.host_str().unwrap_or("?");
match parsed.port() {
Some(port) => format!("{}://{}:{}", parsed.scheme(), host, port),
None => format!("{}://{}", parsed.scheme(), host),
}
} else {
// URL 解析失败,返回部分内容
if url.len() > 20 {
format!("{}...", &url[..20])
} else {
url.to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mask_url() {
assert_eq!(mask_url("http://127.0.0.1:7890"), "http://127.0.0.1:7890");
assert_eq!(
mask_url("http://user:pass@127.0.0.1:7890"),
"http://127.0.0.1:7890"
);
assert_eq!(
mask_url("socks5://admin:secret@proxy.example.com:1080"),
"socks5://proxy.example.com:1080"
);
// 无端口的 URL 不应显示 ":?"
assert_eq!(
mask_url("http://proxy.example.com"),
"http://proxy.example.com"
);
assert_eq!(
mask_url("https://user:pass@proxy.example.com"),
"https://proxy.example.com"
);
}
#[test]
fn test_build_client_direct() {
let result = build_client(None);
assert!(result.is_ok());
}
#[test]
fn test_build_client_with_http_proxy() {
let result = build_client(Some("http://127.0.0.1:7890"));
assert!(result.is_ok());
}
#[test]
fn test_build_client_with_socks5_proxy() {
let result = build_client(Some("socks5://127.0.0.1:1080"));
assert!(result.is_ok());
}
#[test]
fn test_build_client_invalid_url() {
// reqwest::Proxy::all 对某些无效 URL 不会立即报错
// 使用明确无效的 scheme 来触发错误
let result = build_client(Some("invalid-scheme://127.0.0.1:7890"));
assert!(result.is_err(), "Should reject invalid proxy scheme");
}
}
+59
View File
@@ -0,0 +1,59 @@
//! 代理模块日志错误码定义
//!
//! 格式: [模块-编号] 消息
//! - CB: Circuit Breaker (熔断器)
//! - SRV: Server (服务器)
//! - FWD: Forwarder (转发器)
//! - FO: Failover (故障转移)
//! - RSP: Response (响应处理)
//! - USG: Usage (使用量)
#![allow(dead_code)]
/// 熔断器日志码
pub mod cb {
pub const OPEN_TO_HALF_OPEN: &str = "CB-001";
pub const HALF_OPEN_TO_CLOSED: &str = "CB-002";
pub const HALF_OPEN_PROBE_FAILED: &str = "CB-003";
pub const TRIGGERED_FAILURES: &str = "CB-004";
pub const TRIGGERED_ERROR_RATE: &str = "CB-005";
pub const MANUAL_RESET: &str = "CB-006";
}
/// 服务器日志码
pub mod srv {
pub const STARTED: &str = "SRV-001";
pub const STOPPED: &str = "SRV-002";
pub const STOP_TIMEOUT: &str = "SRV-003";
pub const TASK_ERROR: &str = "SRV-004";
}
/// 转发器日志码
pub mod fwd {
pub const PROVIDER_FAILED_RETRY: &str = "FWD-001";
pub const ALL_PROVIDERS_FAILED: &str = "FWD-002";
}
/// 故障转移日志码
pub mod fo {
pub const SWITCH_SUCCESS: &str = "FO-001";
pub const CONFIG_READ_ERROR: &str = "FO-002";
pub const LIVE_BACKUP_ERROR: &str = "FO-003";
pub const ALL_CIRCUIT_OPEN: &str = "FO-004";
pub const NO_PROVIDERS: &str = "FO-005";
}
/// 响应处理日志码
pub mod rsp {
pub const BUILD_STREAM_ERROR: &str = "RSP-001";
pub const READ_BODY_ERROR: &str = "RSP-002";
pub const BUILD_RESPONSE_ERROR: &str = "RSP-003";
pub const STREAM_TIMEOUT: &str = "RSP-004";
pub const STREAM_ERROR: &str = "RSP-005";
}
/// 使用量日志码
pub mod usg {
pub const LOG_FAILED: &str = "USG-001";
pub const PRICING_NOT_FOUND: &str = "USG-002";
}
+48
View File
@@ -0,0 +1,48 @@
//! 代理服务器模块
//!
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
pub mod body_filter;
pub mod circuit_breaker;
pub mod error;
pub mod error_mapper;
pub(crate) mod failover_switch;
mod forwarder;
pub mod handler_config;
pub mod handler_context;
mod handlers;
mod health;
pub mod http_client;
pub mod log_codes;
pub mod model_mapper;
pub mod provider_router;
pub mod providers;
pub mod response_handler;
pub mod response_processor;
pub(crate) mod server;
pub mod session;
pub(crate) mod types;
pub mod usage;
// 公开导出给外部使用(commands, services等模块需要)
#[allow(unused_imports)]
pub use circuit_breaker::{
CircuitBreaker, CircuitBreakerConfig, CircuitBreakerStats, CircuitState,
};
#[allow(unused_imports)]
pub use error::ProxyError;
#[allow(unused_imports)]
pub use provider_router::ProviderRouter;
#[allow(unused_imports)]
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
#[allow(unused_imports)]
pub use session::{
extract_session_id, ClientFormat, ProxySession, SessionIdResult, SessionIdSource,
};
#[allow(unused_imports)]
pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};
// 内部模块间共享(供子模块使用)
// 注意:这个导出用于模块内部,编译器可能警告未使用但实际被子模块使用
#[allow(unused_imports)]
pub(crate) use types::*;
+311
View File
@@ -0,0 +1,311 @@
//! 模型映射模块
//!
//! 在请求转发前,根据 Provider 配置替换请求中的模型名称
use crate::provider::Provider;
use serde_json::Value;
/// 模型映射配置
pub struct ModelMapping {
pub haiku_model: Option<String>,
pub sonnet_model: Option<String>,
pub opus_model: Option<String>,
pub default_model: Option<String>,
pub reasoning_model: Option<String>,
}
impl ModelMapping {
/// 从 Provider 配置中提取模型映射
pub fn from_provider(provider: &Provider) -> Self {
let env = provider.settings_config.get("env");
Self {
haiku_model: env
.and_then(|e| e.get("ANTHROPIC_DEFAULT_HAIKU_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
sonnet_model: env
.and_then(|e| e.get("ANTHROPIC_DEFAULT_SONNET_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
opus_model: env
.and_then(|e| e.get("ANTHROPIC_DEFAULT_OPUS_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
default_model: env
.and_then(|e| e.get("ANTHROPIC_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
reasoning_model: env
.and_then(|e| e.get("ANTHROPIC_REASONING_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
}
}
/// 检查是否配置了任何模型映射
pub fn has_mapping(&self) -> bool {
self.haiku_model.is_some()
|| self.sonnet_model.is_some()
|| self.opus_model.is_some()
|| self.default_model.is_some()
|| self.reasoning_model.is_some()
}
/// 根据原始模型名称获取映射后的模型
pub fn map_model(&self, original_model: &str, has_thinking: bool) -> String {
let model_lower = original_model.to_lowercase();
// 1. thinking 模式优先使用推理模型
if has_thinking {
if let Some(ref m) = self.reasoning_model {
return m.clone();
}
}
// 2. 按模型类型匹配
if model_lower.contains("haiku") {
if let Some(ref m) = self.haiku_model {
return m.clone();
}
}
if model_lower.contains("opus") {
if let Some(ref m) = self.opus_model {
return m.clone();
}
}
if model_lower.contains("sonnet") {
if let Some(ref m) = self.sonnet_model {
return m.clone();
}
}
// 3. 默认模型
if let Some(ref m) = self.default_model {
return m.clone();
}
// 4. 无映射,保持原样
original_model.to_string()
}
}
/// 检测请求是否启用了 thinking 模式
pub fn has_thinking_enabled(body: &Value) -> bool {
body.get("thinking")
.and_then(|v| v.as_object())
.and_then(|o| o.get("type"))
.and_then(|t| t.as_str())
== Some("enabled")
}
/// 对请求体应用模型映射
///
/// 返回 (映射后的请求体, 原始模型名, 映射后模型名)
pub fn apply_model_mapping(
mut body: Value,
provider: &Provider,
) -> (Value, Option<String>, Option<String>) {
let mapping = ModelMapping::from_provider(provider);
// 如果没有配置映射,直接返回
if !mapping.has_mapping() {
let original = body.get("model").and_then(|m| m.as_str()).map(String::from);
return (body, original, None);
}
// 提取原始模型名
let original_model = body.get("model").and_then(|m| m.as_str()).map(String::from);
if let Some(ref original) = original_model {
let has_thinking = has_thinking_enabled(&body);
let mapped = mapping.map_model(original, has_thinking);
if mapped != *original {
log::debug!("[ModelMapper] 模型映射: {original} → {mapped}");
body["model"] = serde_json::json!(mapped);
return (body, Some(original.clone()), Some(mapped));
}
}
(body, original_model, None)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider_with_mapping() -> Provider {
Provider {
id: "test".to_string(),
name: "Test".to_string(),
settings_config: json!({
"env": {
"ANTHROPIC_MODEL": "default-model",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped",
"ANTHROPIC_REASONING_MODEL": "reasoning-model"
}
}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
fn create_provider_without_mapping() -> Provider {
Provider {
id: "test".to_string(),
name: "Test".to_string(),
settings_config: json!({}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
fn create_provider_with_reasoning_only() -> Provider {
Provider {
id: "test".to_string(),
name: "Test".to_string(),
settings_config: json!({
"env": {
"ANTHROPIC_REASONING_MODEL": "reasoning-only-model"
}
}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_sonnet_mapping() {
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-sonnet-4-5-20250929"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(original, Some("claude-sonnet-4-5-20250929".to_string()));
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
fn test_haiku_mapping() {
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-haiku-4-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "haiku-mapped");
assert_eq!(mapped, Some("haiku-mapped".to_string()));
}
#[test]
fn test_opus_mapping() {
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-opus-4-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "opus-mapped");
assert_eq!(mapped, Some("opus-mapped".to_string()));
}
#[test]
fn test_thinking_mode() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "enabled"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "reasoning-model");
assert_eq!(mapped, Some("reasoning-model".to_string()));
}
#[test]
fn test_reasoning_only_mapping_in_thinking_mode() {
let provider = create_provider_with_reasoning_only();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "enabled"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "reasoning-only-model");
assert_eq!(mapped, Some("reasoning-only-model".to_string()));
}
#[test]
fn test_reasoning_only_mapping_does_not_affect_non_thinking() {
let provider = create_provider_with_reasoning_only();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "disabled"}
});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "claude-sonnet-4-5");
assert_eq!(original, Some("claude-sonnet-4-5".to_string()));
assert!(mapped.is_none());
}
#[test]
fn test_thinking_disabled() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "disabled"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
fn test_unknown_model_uses_default() {
let provider = create_provider_with_mapping();
let body = json!({"model": "some-unknown-model"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "default-model");
assert_eq!(mapped, Some("default-model".to_string()));
}
#[test]
fn test_no_mapping_configured() {
let provider = create_provider_without_mapping();
let body = json!({"model": "claude-sonnet-4-5"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "claude-sonnet-4-5");
assert_eq!(original, Some("claude-sonnet-4-5".to_string()));
assert!(mapped.is_none());
}
#[test]
fn test_case_insensitive() {
let provider = create_provider_with_mapping();
let body = json!({"model": "Claude-SONNET-4-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
}
+328
View File
@@ -0,0 +1,328 @@
//! 供应商路由器模块
//!
//! 负责选择和管理代理目标供应商,实现智能故障转移
use crate::database::Database;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::circuit_breaker::{AllowResult, CircuitBreaker, CircuitBreakerConfig};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
/// 供应商路由器
pub struct ProviderRouter {
/// 数据库连接
db: Arc<Database>,
/// 熔断器管理器 - key 格式: "app_type:provider_id"
circuit_breakers: Arc<RwLock<HashMap<String, Arc<CircuitBreaker>>>>,
}
impl ProviderRouter {
/// 创建新的供应商路由器
pub fn new(db: Arc<Database>) -> Self {
Self {
db,
circuit_breakers: Arc::new(RwLock::new(HashMap::new())),
}
}
/// 选择可用的供应商(支持故障转移)
///
/// 返回按优先级排序的可用供应商列表:
/// - 故障转移关闭时:仅返回当前供应商
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
let mut result = Vec::new();
let mut total_providers = 0usize;
let mut circuit_open_count = 0usize;
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
Ok(config) => config.auto_failover_enabled,
Err(e) => {
log::error!("[{app_type}] 读取 proxy_config 失败: {e},默认禁用故障转移");
false
}
};
if auto_failover_enabled {
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
let failover_providers = self.db.get_failover_providers(app_type)?;
total_providers = failover_providers.len();
for provider in failover_providers {
let circuit_key = format!("{}:{}", app_type, provider.id);
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
if breaker.is_available().await {
result.push(provider);
} else {
circuit_open_count += 1;
}
}
} else {
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
if let Some(current_id) = self.db.get_current_provider(app_type)? {
if let Some(current) = self.db.get_provider_by_id(&current_id, app_type)? {
total_providers = 1;
result.push(current);
}
}
}
if result.is_empty() {
if total_providers > 0 && circuit_open_count == total_providers {
log::warn!("[{app_type}] [FO-004] 所有供应商均已熔断");
return Err(AppError::AllProvidersCircuitOpen);
} else {
log::warn!("[{app_type}] [FO-005] 未配置供应商");
return Err(AppError::NoProvidersConfigured);
}
}
Ok(result)
}
/// 请求执行前获取熔断器“放行许可”
///
/// - Closed:直接放行
/// - Open:超时到达后切到 HalfOpen 并放行一次探测
/// - HalfOpen:按限流规则放行探测
///
/// 注意:调用方必须在请求结束后通过 `record_result()` 释放 HalfOpen 名额,
/// 否则会导致该 Provider 长时间无法进入探测状态。
pub async fn allow_provider_request(&self, provider_id: &str, app_type: &str) -> AllowResult {
let circuit_key = format!("{app_type}:{provider_id}");
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
breaker.allow_request().await
}
/// 记录供应商请求结果
pub async fn record_result(
&self,
provider_id: &str,
app_type: &str,
used_half_open_permit: bool,
success: bool,
error_msg: Option<String>,
) -> Result<(), AppError> {
// 1. 按应用独立获取熔断器配置
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
Ok(app_config) => app_config.circuit_failure_threshold,
Err(_) => 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;
} else {
breaker.record_failure(used_half_open_permit).await;
}
// 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) {
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;
}
/// 更新所有熔断器的配置(热更新)
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
let breakers = self.circuit_breakers.read().await;
for breaker in breakers.values() {
breaker.update_config(config.clone()).await;
}
}
/// 获取熔断器状态
#[allow(dead_code)]
pub async fn get_circuit_breaker_stats(
&self,
provider_id: &str,
app_type: &str,
) -> Option<crate::proxy::circuit_breaker::CircuitBreakerStats> {
let circuit_key = format!("{app_type}:{provider_id}");
let breakers = self.circuit_breakers.read().await;
if let Some(breaker) = breakers.get(&circuit_key) {
Some(breaker.get_stats().await)
} else {
None
}
}
/// 获取或创建熔断器
async fn get_or_create_circuit_breaker(&self, key: &str) -> Arc<CircuitBreaker> {
// 先尝试读锁获取
{
let breakers = self.circuit_breakers.read().await;
if let Some(breaker) = breakers.get(key) {
return breaker.clone();
}
}
// 如果不存在,获取写锁创建
let mut breakers = self.circuit_breakers.write().await;
// 双重检查,防止竞争条件
if let Some(breaker) = breakers.get(key) {
return breaker.clone();
}
// 从 key 中提取 app_type (格式: "app_type:provider_id")
let app_type = key.split(':').next().unwrap_or("claude");
// 按应用独立读取熔断器配置
let config = match self.db.get_proxy_config_for_app(app_type).await {
Ok(app_config) => crate::proxy::circuit_breaker::CircuitBreakerConfig {
failure_threshold: app_config.circuit_failure_threshold,
success_threshold: app_config.circuit_success_threshold,
timeout_seconds: app_config.circuit_timeout_seconds as u64,
error_rate_threshold: app_config.circuit_error_rate_threshold,
min_requests: app_config.circuit_min_requests,
},
Err(_) => crate::proxy::circuit_breaker::CircuitBreakerConfig::default(),
};
let breaker = Arc::new(CircuitBreaker::new(config));
breakers.insert(key.to_string(), breaker.clone());
breaker
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
use serde_json::json;
#[tokio::test]
async fn test_provider_router_creation() {
let db = Arc::new(Database::memory().unwrap());
let router = ProviderRouter::new(db);
let breaker = router.get_or_create_circuit_breaker("claude:test").await;
assert!(breaker.allow_request().await.allowed);
}
#[tokio::test]
async fn test_failover_disabled_uses_current_provider() {
let db = Arc::new(Database::memory().unwrap());
let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
let provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.set_current_provider("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
let router = ProviderRouter::new(db.clone());
let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 1);
assert_eq!(providers[0].id, "a");
}
#[tokio::test]
async fn test_failover_enabled_uses_queue_order() {
let db = Arc::new(Database::memory().unwrap());
// 设置 sort_index 来控制顺序:b=1, a=2
let mut provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
provider_a.sort_index = Some(2);
let mut provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
provider_b.sort_index = Some(1);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.set_current_provider("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
// 启用自动故障转移(使用新的 proxy_config API
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
config.auto_failover_enabled = true;
db.update_proxy_config_for_app(config).await.unwrap();
let router = ProviderRouter::new(db.clone());
let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 2);
// 按 sort_index 排序:b(1) 在前,a(2) 在后
assert_eq!(providers[0].id, "b");
assert_eq!(providers[1].id, "a");
}
#[tokio::test]
async fn test_select_providers_does_not_consume_half_open_permit() {
let db = Arc::new(Database::memory().unwrap());
db.update_circuit_breaker_config(&CircuitBreakerConfig {
failure_threshold: 1,
timeout_seconds: 0,
..Default::default()
})
.await
.unwrap();
let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
let provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
// 启用自动故障转移(使用新的 proxy_config API
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
config.auto_failover_enabled = true;
db.update_proxy_config_for_app(config).await.unwrap();
let router = ProviderRouter::new(db.clone());
router
.record_result("b", "claude", false, false, Some("fail".to_string()))
.await
.unwrap();
let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 2);
assert!(router.allow_provider_request("b", "claude").await.allowed);
}
}
+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 旧 OpenAI 兼容接口)才返回 `true`。
///
/// # Arguments
/// * `provider` - Provider 配置
fn needs_transform(&self, _provider: &Provider) -> bool {
false
}
/// 转换请求体
///
/// 将请求体从一种格式转换为另一种格式(如 Anthropic → OpenAI)。
/// 默认实现直接返回原始请求体(透传)。
///
/// # Arguments
/// * `body` - 原始请求体
/// * `provider` - Provider 配置(用于获取模型映射等)
///
/// # Returns
/// * `Ok(Value)` - 转换后的请求体
/// * `Err(ProxyError)` - 转换失败
fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> {
Ok(body)
}
/// 转换响应体
///
/// 将响应体从一种格式转换为另一种格式(如 OpenAI → Anthropic)。
/// 默认实现直接返回原始响应体(透传)。
///
/// # Arguments
/// * `body` - 原始响应体
///
/// # Returns
/// * `Ok(Value)` - 转换后的响应体
/// * `Err(ProxyError)` - 转换失败
///
/// Note: 响应转换将在 handler 层集成,目前预留接口
#[allow(dead_code)]
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
Ok(body)
}
}
+241
View File
@@ -0,0 +1,241 @@
//! 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位,则返回 `***`
#[allow(dead_code)]
pub fn masked_key(&self) -> String {
if self.api_key.chars().count() > 8 {
let prefix: String = self.api_key.chars().take(4).collect();
let suffix: String = self
.api_key
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{prefix}...{suffix}")
} else {
"***".to_string()
}
}
/// 返回遮蔽后的 access_token(用于日志输出)
#[allow(dead_code)]
pub fn masked_access_token(&self) -> Option<String> {
self.access_token.as_ref().map(|token| {
if token.chars().count() > 8 {
let prefix: String = token.chars().take(4).collect();
let suffix: String = token
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{prefix}...{suffix}")
} else {
"***".to_string()
}
})
}
}
/// 认证策略
///
/// 不同供应商使用不同的认证方式
#[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_masked_key_utf8_safe() {
let auth = AuthInfo::new("测试⚠️1234567890".to_string(), AuthStrategy::Bearer);
let masked = auth.masked_key();
assert!(!masked.is_empty());
}
#[test]
fn test_auth_strategy_equality() {
assert_eq!(AuthStrategy::Anthropic, AuthStrategy::Anthropic);
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_utf8_safe() {
let auth =
AuthInfo::with_access_token("refresh".to_string(), "令牌⚠️1234567890".to_string());
let masked = auth.masked_access_token().unwrap();
assert!(!masked.is_empty());
}
#[test]
fn test_masked_access_token_short() {
let auth = AuthInfo::with_access_token("refresh".to_string(), "short".to_string());
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);
}
}
}
}
}
+494
View File
@@ -0,0 +1,494 @@
//! Claude (Anthropic) Provider Adapter
//!
//! 支持透传模式和 OpenRouter 兼容模式
//!
//! ## 认证模式
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用)
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
/// Claude 适配器
pub struct ClaudeAdapter;
impl ClaudeAdapter {
pub fn new() -> Self {
Self
}
/// 获取供应商类型
///
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
/// - OpenRouter: base_url 包含 openrouter.ai
/// - ClaudeAuth: auth_mode 为 bearer_only
/// - Claude: 默认 Anthropic 官方
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
// 检测 OpenRouter
if self.is_openrouter(provider) {
return ProviderType::OpenRouter;
}
// 检测 ClaudeAuth (仅 Bearer 认证)
if self.is_bearer_only_mode(provider) {
return ProviderType::ClaudeAuth;
}
ProviderType::Claude
}
/// 检测是否使用 OpenRouter
fn is_openrouter(&self, provider: &Provider) -> bool {
if let Ok(base_url) = self.extract_base_url(provider) {
return base_url.contains("openrouter.ai");
}
false
}
/// 检测 OpenRouter 是否启用兼容模式
fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool {
if !self.is_openrouter(provider) {
return false;
}
let raw = provider.settings_config.get("openrouter_compat_mode");
match raw {
Some(serde_json::Value::Bool(enabled)) => *enabled,
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
Some(serde_json::Value::String(value)) => {
let normalized = value.trim().to_lowercase();
normalized == "true" || normalized == "1"
}
// OpenRouter now supports Claude Code compatible API, default to passthrough
_ => 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());
}
if let Some(key) = env
.get("ANTHROPIC_API_KEY")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 ANTHROPIC_API_KEY");
return Some(key.to_string());
}
// OpenRouter key
if let Some(key) = env
.get("OPENROUTER_API_KEY")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 OPENROUTER_API_KEY");
return Some(key.to_string());
}
// 备选 OpenAI key (用于 OpenRouter)
if let Some(key) = env
.get("OPENAI_API_KEY")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 OPENAI_API_KEY");
return Some(key.to_string());
}
}
// 尝试直接获取
if let Some(key) = provider
.settings_config
.get("apiKey")
.or_else(|| provider.settings_config.get("api_key"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 apiKey/api_key");
return Some(key.to_string());
}
log::warn!("[Claude] 未找到有效的 API Key");
None
}
}
impl Default for ClaudeAdapter {
fn default() -> Self {
Self::new()
}
}
impl ProviderAdapter for ClaudeAdapter {
fn name(&self) -> &'static str {
"Claude"
}
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
// 1. 从 env 中获取
if let Some(env) = provider.settings_config.get("env") {
if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
return Ok(url.trim_end_matches('/').to_string());
}
}
// 2. 尝试直接获取
if let Some(url) = provider
.settings_config
.get("base_url")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
if let Some(url) = provider
.settings_config
.get("baseURL")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
if let Some(url) = provider
.settings_config
.get("apiEndpoint")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
Err(ProxyError::ConfigError(
"Claude Provider 缺少 base_url 配置".to_string(),
))
}
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
let provider_type = self.provider_type(provider);
let strategy = match provider_type {
ProviderType::OpenRouter => AuthStrategy::Bearer,
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
_ => AuthStrategy::Anthropic,
};
self.extract_key(provider)
.map(|key| AuthInfo::new(key, strategy))
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
// NOTE:
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages`
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。
//
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
let base = format!(
"{}/{}",
base_url.trim_end_matches('/'),
endpoint.trim_start_matches('/')
);
// 为 /v1/messages 端点添加 ?beta=true 参数
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
if endpoint.contains("/v1/messages") && !endpoint.contains("?") {
format!("{base}?beta=true")
} else {
base
}
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
// 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
// 这里不再设置 anthropic-version,避免 header 重复
match auth.strategy {
// Anthropic 官方: Authorization Bearer + x-api-key
AuthStrategy::Anthropic => request
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("x-api-key", &auth.api_key),
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
AuthStrategy::ClaudeAuth => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
// OpenRouter: Bearer
AuthStrategy::Bearer => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
_ => request,
}
}
fn needs_transform(&self, _provider: &Provider) -> bool {
// NOTE:
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
// Anthropic ↔ OpenAI 的格式转换。
//
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
self.is_openrouter_compat_enabled(_provider)
}
fn transform_request(
&self,
body: serde_json::Value,
provider: &Provider,
) -> Result<serde_json::Value, ProxyError> {
super::transform::anthropic_to_openai(body, provider)
}
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
super::transform::openai_to_anthropic(body)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Claude".to_string(),
settings_config: config,
website_url: None,
category: Some("claude".to_string()),
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_extract_base_url_from_env() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}));
let url = adapter.extract_base_url(&provider).unwrap();
assert_eq!(url, "https://api.anthropic.com");
}
#[test]
fn test_extract_auth_anthropic() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test-key"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-ant-test-key");
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
}
#[test]
fn test_extract_auth_anthropic_api_key() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_API_KEY": "sk-ant-test-key"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-ant-test-key");
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
}
#[test]
fn test_extract_auth_openrouter() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"OPENROUTER_API_KEY": "sk-or-test-key"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-or-test-key");
assert_eq!(auth.strategy, AuthStrategy::Bearer);
}
#[test]
fn test_extract_auth_claude_auth_mode() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key"
},
"auth_mode": "bearer_only"
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-proxy-key");
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
}
#[test]
fn test_extract_auth_claude_auth_env_mode() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key",
"AUTH_MODE": "bearer_only"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-proxy-key");
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
}
#[test]
fn test_provider_type_detection() {
let adapter = ClaudeAdapter::new();
// Anthropic 官方
let anthropic = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
}
}));
assert_eq!(adapter.provider_type(&anthropic), ProviderType::Claude);
// OpenRouter
let openrouter = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"OPENROUTER_API_KEY": "sk-or-test"
}
}));
assert_eq!(adapter.provider_type(&openrouter), ProviderType::OpenRouter);
// ClaudeAuth
let claude_auth = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
"ANTHROPIC_AUTH_TOKEN": "sk-test"
},
"auth_mode": "bearer_only"
}));
assert_eq!(
adapter.provider_type(&claude_auth),
ProviderType::ClaudeAuth
);
}
#[test]
fn test_build_url_anthropic() {
let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
}
#[test]
fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true");
}
#[test]
fn test_build_url_no_beta_for_other_endpoints() {
let adapter = ClaudeAdapter::new();
// 非 /v1/messages 端点不添加 ?beta=true
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
assert_eq!(url, "https://api.anthropic.com/v1/complete");
}
#[test]
fn test_build_url_preserve_existing_query() {
let adapter = ClaudeAdapter::new();
// 已有查询参数时不重复添加
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
}
#[test]
fn test_needs_transform() {
let adapter = ClaudeAdapter::new();
let anthropic_provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}));
assert!(!adapter.needs_transform(&anthropic_provider));
// OpenRouter provider without explicit setting now defaults to passthrough (no transform)
let openrouter_provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
}
}));
assert!(!adapter.needs_transform(&openrouter_provider));
// OpenRouter provider with explicit compat mode enabled should transform
let openrouter_enabled = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
},
"openrouter_compat_mode": true
}));
assert!(adapter.needs_transform(&openrouter_enabled));
let openrouter_disabled = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
},
"openrouter_compat_mode": false
}));
assert!(!adapter.needs_transform(&openrouter_disabled));
}
}
+265
View File
@@ -0,0 +1,265 @@
//! Codex (OpenAI) Provider Adapter
//!
//! 仅透传模式,支持直连 OpenAI API
//!
//! ## 客户端检测
//! 支持检测官方 Codex 客户端 (codex_vscode, codex_cli_rs)
use super::{AuthInfo, AuthStrategy, ProviderAdapter};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use regex::Regex;
use reqwest::RequestBuilder;
use std::sync::LazyLock;
/// 官方 Codex 客户端 User-Agent 正则
#[allow(dead_code)]
static CODEX_CLIENT_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(codex_vscode|codex_cli_rs)/[\d.]+").unwrap());
/// Codex 适配器
pub struct CodexAdapter;
impl CodexAdapter {
pub fn new() -> Self {
Self
}
/// 检测是否为官方 Codex 客户端
///
/// 匹配 User-Agent 模式: `^(codex_vscode|codex_cli_rs)/[\d.]+`
#[allow(dead_code)]
pub fn is_official_client(user_agent: &str) -> bool {
CODEX_CLIENT_REGEX.is_match(user_agent)
}
/// 从 Provider 配置中提取 API Key
fn extract_key(&self, provider: &Provider) -> Option<String> {
// 1. 尝试从 env 中获取
if let Some(env) = provider.settings_config.get("env") {
if let Some(key) = env.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
return Some(key.to_string());
}
}
// 2. 尝试从 auth 中获取 (Codex CLI 格式)
if let Some(auth) = provider.settings_config.get("auth") {
if let Some(key) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
return Some(key.to_string());
}
}
// 3. 尝试直接获取
if let Some(key) = provider
.settings_config
.get("apiKey")
.or_else(|| provider.settings_config.get("api_key"))
.and_then(|v| v.as_str())
{
return Some(key.to_string());
}
// 4. 尝试从 config 对象中获取
if let Some(config) = provider.settings_config.get("config") {
if let Some(key) = config
.get("api_key")
.or_else(|| config.get("apiKey"))
.and_then(|v| v.as_str())
{
return Some(key.to_string());
}
}
None
}
}
impl Default for CodexAdapter {
fn default() -> Self {
Self::new()
}
}
impl ProviderAdapter for CodexAdapter {
fn name(&self) -> &'static str {
"Codex"
}
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
// 1. 尝试直接获取 base_url 字段
if let Some(url) = provider
.settings_config
.get("base_url")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
// 2. 尝试 baseURL
if let Some(url) = provider
.settings_config
.get("baseURL")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
// 3. 尝试从 config 对象中获取
if let Some(config) = provider.settings_config.get("config") {
if let Some(url) = config.get("base_url").and_then(|v| v.as_str()) {
return Ok(url.trim_end_matches('/').to_string());
}
// 尝试解析 TOML 字符串格式
if let Some(config_str) = config.as_str() {
if let Some(start) = config_str.find("base_url = \"") {
let rest = &config_str[start + 12..];
if let Some(end) = rest.find('"') {
return Ok(rest[..end].trim_end_matches('/').to_string());
}
}
if let Some(start) = config_str.find("base_url = '") {
let rest = &config_str[start + 12..];
if let Some(end) = rest.find('\'') {
return Ok(rest[..end].trim_end_matches('/').to_string());
}
}
}
}
Err(ProxyError::ConfigError(
"Codex Provider 缺少 base_url 配置".to_string(),
))
}
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
self.extract_key(provider)
.map(|key| AuthInfo::new(key, AuthStrategy::Bearer))
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
let base_trimmed = base_url.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
// 去除重复的 /v1/v1
if url.contains("/v1/v1") {
url = url.replace("/v1/v1", "/v1");
}
url
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Codex".to_string(),
settings_config: config,
website_url: None,
category: Some("codex".to_string()),
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_extract_base_url_direct() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"base_url": "https://api.openai.com/v1"
}));
let url = adapter.extract_base_url(&provider).unwrap();
assert_eq!(url, "https://api.openai.com/v1");
}
#[test]
fn test_extract_auth_from_auth_field() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"auth": {
"OPENAI_API_KEY": "sk-test-key-12345678"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-test-key-12345678");
assert_eq!(auth.strategy, AuthStrategy::Bearer);
}
#[test]
fn test_extract_auth_from_env() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"env": {
"OPENAI_API_KEY": "sk-env-key-12345678"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-env-key-12345678");
}
#[test]
fn test_build_url() {
let adapter = CodexAdapter::new();
let url = adapter.build_url("https://api.openai.com/v1", "/responses");
assert_eq!(url, "https://api.openai.com/v1/responses");
}
#[test]
fn test_build_url_dedup_v1() {
let adapter = CodexAdapter::new();
// base_url 已包含 /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"
));
}
}
+422
View File
@@ -0,0 +1,422 @@
//! Gemini (Google) Provider Adapter
//!
//! 支持 API Key 和 OAuth 两种认证方式
//!
//! ## 认证模式
//! - **Gemini**: API Key 认证 (x-goog-api-key)
//! - **GeminiCli**: OAuth Bearer 认证 (用于 Gemini CLI)
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
/// Gemini 适配器
pub struct GeminiAdapter;
/// OAuth 凭证结构
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct OAuthCredentials {
pub access_token: String,
pub refresh_token: Option<String>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
}
#[allow(dead_code)]
impl OAuthCredentials {
/// 检查是否需要刷新 token(有 refresh_token 但没有有效的 access_token
pub fn needs_refresh(&self) -> bool {
self.refresh_token.is_some() && self.access_token.is_empty()
}
/// 检查是否可以刷新 token
pub fn can_refresh(&self) -> bool {
self.refresh_token.is_some() && self.client_id.is_some() && self.client_secret.is_some()
}
}
impl GeminiAdapter {
pub fn new() -> Self {
Self
}
/// 获取供应商类型
///
/// 根据 API Key 格式检测:
/// - GeminiCli: access_token (ya29. 开头) 或 JSON 格式凭证
/// - Gemini: 普通 API Key
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
if let Some(key) = self.extract_key_raw(provider) {
// OAuth access_token 以 ya29. 开头
if key.starts_with("ya29.") {
return ProviderType::GeminiCli;
}
// JSON 格式的 OAuth 凭证
if key.starts_with('{') {
return ProviderType::GeminiCli;
}
}
ProviderType::Gemini
}
/// 检测认证类型
pub fn detect_auth_type(&self, provider: &Provider) -> AuthStrategy {
match self.provider_type(provider) {
ProviderType::GeminiCli => AuthStrategy::GoogleOAuth,
_ => AuthStrategy::Google,
}
}
/// 解析 OAuth 凭证
pub fn parse_oauth_credentials(&self, key: &str) -> Option<OAuthCredentials> {
// 直接是 access_token
if key.starts_with("ya29.") {
return Some(OAuthCredentials {
access_token: key.to_string(),
refresh_token: None,
client_id: None,
client_secret: None,
});
}
// JSON 格式
if key.starts_with('{') {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(key) {
let access_token = json
.get("access_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
let refresh_token = json
.get("refresh_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let client_id = json
.get("client_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let client_secret = json
.get("client_secret")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// 如果有 access_token 或 refresh_token,返回凭证
if !access_token.is_empty() || refresh_token.is_some() {
return Some(OAuthCredentials {
access_token,
refresh_token,
client_id,
client_secret,
});
}
}
}
None
}
/// 从 Provider 配置中提取原始 API Key
fn extract_key_raw(&self, provider: &Provider) -> Option<String> {
if let Some(env) = provider.settings_config.get("env") {
// 使用 GEMINI_API_KEY
if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
return Some(key.to_string());
}
}
// 尝试直接获取
if let Some(key) = provider
.settings_config
.get("apiKey")
.or_else(|| provider.settings_config.get("api_key"))
.and_then(|v| v.as_str())
{
return Some(key.to_string());
}
None
}
}
impl Default for GeminiAdapter {
fn default() -> Self {
Self::new()
}
}
impl ProviderAdapter for GeminiAdapter {
fn name(&self) -> &'static str {
"Gemini"
}
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
// 从 env 中获取
if let Some(env) = provider.settings_config.get("env") {
if let Some(url) = env.get("GOOGLE_GEMINI_BASE_URL").and_then(|v| v.as_str()) {
return Ok(url.trim_end_matches('/').to_string());
}
}
// 尝试直接获取
if let Some(url) = provider
.settings_config
.get("base_url")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
if let Some(url) = provider
.settings_config
.get("baseURL")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
Err(ProxyError::ConfigError(
"Gemini Provider 缺少 base_url 配置".to_string(),
))
}
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
let key = self.extract_key_raw(provider)?;
let strategy = self.detect_auth_type(provider);
match strategy {
AuthStrategy::GoogleOAuth => {
// 解析 OAuth 凭证
if let Some(creds) = self.parse_oauth_credentials(&key) {
Some(AuthInfo::with_access_token(key, creds.access_token))
} else {
// 回退到普通 API Key
Some(AuthInfo::new(key, AuthStrategy::Google))
}
}
_ => Some(AuthInfo::new(key, AuthStrategy::Google)),
}
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
let base_trimmed = base_url.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
// 处理 /v1beta 路径去重
let version_patterns = ["/v1beta", "/v1"];
for pattern in &version_patterns {
let duplicate = format!("{pattern}{pattern}");
if url.contains(&duplicate) {
url = url.replace(&duplicate, pattern);
}
}
url
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
match auth.strategy {
// OAuth Bearer 认证
AuthStrategy::GoogleOAuth => {
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
request
.header("Authorization", format!("Bearer {token}"))
.header("x-goog-api-client", "GeminiCLI/1.0")
}
// API Key 认证
_ => request.header("x-goog-api-key", &auth.api_key),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Gemini".to_string(),
settings_config: config,
website_url: None,
category: Some("gemini".to_string()),
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_extract_base_url_from_env() {
let adapter = GeminiAdapter::new();
let provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com/v1beta"
}
}));
let url = adapter.extract_base_url(&provider).unwrap();
assert_eq!(url, "https://generativelanguage.googleapis.com/v1beta");
}
#[test]
fn test_extract_auth_api_key() {
let adapter = GeminiAdapter::new();
let provider = create_provider(json!({
"env": {
"GEMINI_API_KEY": "AIza-test-key-12345678"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "AIza-test-key-12345678");
assert_eq!(auth.strategy, AuthStrategy::Google);
assert!(auth.access_token.is_none());
}
#[test]
fn test_extract_auth_oauth_access_token() {
let adapter = GeminiAdapter::new();
let provider = create_provider(json!({
"env": {
"GEMINI_API_KEY": "ya29.test-access-token-12345"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
assert_eq!(
auth.access_token,
Some("ya29.test-access-token-12345".to_string())
);
}
#[test]
fn test_extract_auth_oauth_json() {
let adapter = GeminiAdapter::new();
let provider = create_provider(json!({
"env": {
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test-token\",\"refresh_token\":\"1//refresh\"}"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
assert_eq!(auth.access_token, Some("ya29.test-token".to_string()));
}
#[test]
fn test_provider_type_detection() {
let adapter = GeminiAdapter::new();
// API Key
let api_key_provider = create_provider(json!({
"env": {
"GEMINI_API_KEY": "AIza-test-key"
}
}));
assert_eq!(
adapter.provider_type(&api_key_provider),
ProviderType::Gemini
);
// OAuth access_token
let oauth_provider = create_provider(json!({
"env": {
"GEMINI_API_KEY": "ya29.test-token"
}
}));
assert_eq!(
adapter.provider_type(&oauth_provider),
ProviderType::GeminiCli
);
// OAuth JSON
let oauth_json_provider = create_provider(json!({
"env": {
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test\"}"
}
}));
assert_eq!(
adapter.provider_type(&oauth_json_provider),
ProviderType::GeminiCli
);
}
#[test]
fn test_extract_auth_fallback() {
let adapter = GeminiAdapter::new();
let provider = create_provider(json!({
"env": {
"GEMINI_API_KEY": "AIza-fallback-key"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "AIza-fallback-key");
}
#[test]
fn test_build_url_dedup() {
let adapter = GeminiAdapter::new();
// 模拟 base_url 已包含 /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());
}
}
+428
View File
@@ -0,0 +1,428 @@
//! Provider Adapters Module
//!
//! 供应商适配器模块,提供统一的接口抽象不同上游供应商的处理逻辑。
//!
//! ## 模块结构
//! - `adapter`: 定义 `ProviderAdapter` trait
//! - `auth`: 认证类型和策略
//! - `claude`: Claude (Anthropic) 适配器
//! - `codex`: Codex (OpenAI) 适配器
//! - `gemini`: Gemini (Google) 适配器
//! - `models`: API 数据模型
//! - `transform`: 格式转换
mod adapter;
mod auth;
mod claude;
mod codex;
mod gemini;
pub mod models;
pub mod streaming;
pub mod transform;
use crate::app_config::AppType;
use crate::provider::Provider;
use serde::{Deserialize, Serialize};
// 公开导出
pub use adapter::ProviderAdapter;
pub use auth::{AuthInfo, AuthStrategy};
pub use claude::ClaudeAdapter;
pub use codex::CodexAdapter;
pub use gemini::GeminiAdapter;
/// 供应商类型枚举
///
/// 区分不同供应商的具体实现方式,决定认证和请求处理逻辑。
/// 比 AppType 更细粒度,支持同一 AppType 下的多种变体。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderType {
/// Anthropic 官方 API (x-api-key + anthropic-version)
Claude,
/// Claude 中转服务 (仅 Bearer 认证,无 x-api-key)
ClaudeAuth,
/// OpenAI Codex Response API
Codex,
/// Google Gemini API (x-goog-api-key)
Gemini,
/// Google Gemini CLI (OAuth Bearer)
GeminiCli,
/// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用)
OpenRouter,
}
impl ProviderType {
/// 是否需要格式转换
///
/// 过去 OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式;
/// 现在默认关闭转换(因为 OpenRouter 已支持 Claude Code 兼容接口)。
#[allow(dead_code)]
pub fn needs_transform(&self) -> bool {
match self {
ProviderType::OpenRouter => false,
_ => false,
}
}
/// 获取默认端点
#[allow(dead_code)]
pub fn default_endpoint(&self) -> &'static str {
match self {
ProviderType::Claude | ProviderType::ClaudeAuth => "https://api.anthropic.com",
ProviderType::Codex => "https://api.openai.com",
ProviderType::Gemini | ProviderType::GeminiCli => {
"https://generativelanguage.googleapis.com"
}
ProviderType::OpenRouter => "https://openrouter.ai/api",
}
}
/// 从 AppType 和 Provider 配置推断供应商类型
///
/// 根据配置中的 base_url、auth_mode、api_key 格式等信息推断具体的供应商类型
#[allow(dead_code)]
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
match app_type {
AppType::Claude => {
// 检测是否为 OpenRouter
let adapter = ClaudeAdapter::new();
if let Ok(base_url) = adapter.extract_base_url(provider) {
if base_url.contains("openrouter.ai") {
return ProviderType::OpenRouter;
}
}
// 检测是否为中转服务(仅 Bearer 认证)
// 注意:ProviderMeta 没有直接的 auth_mode 字段,
// 我们通过检查 settings_config 中的配置来判断
// 检查 settings_config 中的 auth_mode
if let Some(auth_mode) = provider
.settings_config
.get("auth_mode")
.and_then(|v| v.as_str())
{
if auth_mode == "bearer_only" {
return ProviderType::ClaudeAuth;
}
}
// 检查 env 中的 auth_mode
if let Some(env) = provider.settings_config.get("env") {
if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) {
if auth_mode == "bearer_only" {
return ProviderType::ClaudeAuth;
}
}
}
ProviderType::Claude
}
AppType::Codex => ProviderType::Codex,
AppType::Gemini => {
// 检测是否为 CLI 模式(OAuth
let adapter = GeminiAdapter::new();
if let Some(auth) = adapter.extract_auth(provider) {
let key = &auth.api_key;
// OAuth access_token 以 ya29. 开头
if key.starts_with("ya29.") {
return ProviderType::GeminiCli;
}
// JSON 格式的 OAuth 凭证
if key.starts_with('{') {
return ProviderType::GeminiCli;
}
}
ProviderType::Gemini
}
}
}
/// 转换为字符串表示
pub fn as_str(&self) -> &'static str {
match self {
ProviderType::Claude => "claude",
ProviderType::ClaudeAuth => "claude_auth",
ProviderType::Codex => "codex",
ProviderType::Gemini => "gemini",
ProviderType::GeminiCli => "gemini_cli",
ProviderType::OpenRouter => "openrouter",
}
}
}
impl std::fmt::Display for ProviderType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for ProviderType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"claude" => Ok(ProviderType::Claude),
"claude_auth" | "claude-auth" => Ok(ProviderType::ClaudeAuth),
"codex" => Ok(ProviderType::Codex),
"gemini" => Ok(ProviderType::Gemini),
"gemini_cli" | "gemini-cli" => Ok(ProviderType::GeminiCli),
"openrouter" => Ok(ProviderType::OpenRouter),
_ => Err(format!("Invalid provider type: {s}")),
}
}
}
/// 根据 AppType 获取对应的适配器
pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
match app_type {
AppType::Claude => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
}
}
/// 根据 ProviderType 获取对应的适配器
#[allow(dead_code)]
pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn ProviderAdapter> {
match provider_type {
ProviderType::Claude | ProviderType::ClaudeAuth | ProviderType::OpenRouter => {
Box::new(ClaudeAdapter::new())
}
ProviderType::Codex => Box::new(CodexAdapter::new()),
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Provider".to_string(),
settings_config: config,
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_provider_type_needs_transform() {
assert!(!ProviderType::Claude.needs_transform());
assert!(!ProviderType::ClaudeAuth.needs_transform());
assert!(!ProviderType::Codex.needs_transform());
assert!(!ProviderType::Gemini.needs_transform());
assert!(!ProviderType::GeminiCli.needs_transform());
assert!(!ProviderType::OpenRouter.needs_transform());
}
#[test]
fn test_provider_type_default_endpoint() {
assert_eq!(
ProviderType::Claude.default_endpoint(),
"https://api.anthropic.com"
);
assert_eq!(
ProviderType::ClaudeAuth.default_endpoint(),
"https://api.anthropic.com"
);
assert_eq!(
ProviderType::Codex.default_endpoint(),
"https://api.openai.com"
);
assert_eq!(
ProviderType::Gemini.default_endpoint(),
"https://generativelanguage.googleapis.com"
);
assert_eq!(
ProviderType::GeminiCli.default_endpoint(),
"https://generativelanguage.googleapis.com"
);
assert_eq!(
ProviderType::OpenRouter.default_endpoint(),
"https://openrouter.ai/api"
);
}
#[test]
fn test_provider_type_from_str() {
assert_eq!(
"claude".parse::<ProviderType>().unwrap(),
ProviderType::Claude
);
assert_eq!(
"claude_auth".parse::<ProviderType>().unwrap(),
ProviderType::ClaudeAuth
);
assert_eq!(
"claude-auth".parse::<ProviderType>().unwrap(),
ProviderType::ClaudeAuth
);
assert_eq!(
"codex".parse::<ProviderType>().unwrap(),
ProviderType::Codex
);
assert_eq!(
"gemini".parse::<ProviderType>().unwrap(),
ProviderType::Gemini
);
assert_eq!(
"gemini_cli".parse::<ProviderType>().unwrap(),
ProviderType::GeminiCli
);
assert_eq!(
"gemini-cli".parse::<ProviderType>().unwrap(),
ProviderType::GeminiCli
);
assert_eq!(
"openrouter".parse::<ProviderType>().unwrap(),
ProviderType::OpenRouter
);
assert!("invalid".parse::<ProviderType>().is_err());
}
#[test]
fn test_provider_type_as_str() {
assert_eq!(ProviderType::Claude.as_str(), "claude");
assert_eq!(ProviderType::ClaudeAuth.as_str(), "claude_auth");
assert_eq!(ProviderType::Codex.as_str(), "codex");
assert_eq!(ProviderType::Gemini.as_str(), "gemini");
assert_eq!(ProviderType::GeminiCli.as_str(), "gemini_cli");
assert_eq!(ProviderType::OpenRouter.as_str(), "openrouter");
}
#[test]
fn test_provider_type_serde() {
// Test serialization
let claude = ProviderType::Claude;
let serialized = serde_json::to_string(&claude).unwrap();
assert_eq!(serialized, "\"claude\"");
let claude_auth = ProviderType::ClaudeAuth;
let serialized = serde_json::to_string(&claude_auth).unwrap();
assert_eq!(serialized, "\"claude_auth\"");
// Test deserialization
let deserialized: ProviderType = serde_json::from_str("\"claude\"").unwrap();
assert_eq!(deserialized, ProviderType::Claude);
let deserialized: ProviderType = serde_json::from_str("\"gemini_cli\"").unwrap();
assert_eq!(deserialized, ProviderType::GeminiCli);
}
#[test]
fn test_from_app_type_claude_direct() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
assert_eq!(provider_type, ProviderType::Claude);
}
#[test]
fn test_from_app_type_claude_openrouter() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"OPENROUTER_API_KEY": "sk-or-test"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
assert_eq!(provider_type, ProviderType::OpenRouter);
}
#[test]
fn test_from_app_type_claude_auth() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
"ANTHROPIC_AUTH_TOKEN": "sk-test"
},
"auth_mode": "bearer_only"
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
assert_eq!(provider_type, ProviderType::ClaudeAuth);
}
#[test]
fn test_from_app_type_codex() {
let provider = create_provider(json!({
"env": {
"OPENAI_API_KEY": "sk-test"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Codex, &provider);
assert_eq!(provider_type, ProviderType::Codex);
}
#[test]
fn test_from_app_type_gemini_api_key() {
let provider = create_provider(json!({
"env": {
"GEMINI_API_KEY": "AIza-test-key"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
assert_eq!(provider_type, ProviderType::Gemini);
}
#[test]
fn test_from_app_type_gemini_cli_oauth() {
let provider = create_provider(json!({
"env": {
"GEMINI_API_KEY": "ya29.test-access-token"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
assert_eq!(provider_type, ProviderType::GeminiCli);
}
#[test]
fn test_from_app_type_gemini_cli_json() {
let provider = create_provider(json!({
"env": {
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//test\"}"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
assert_eq!(provider_type, ProviderType::GeminiCli);
}
#[test]
fn test_get_adapter_for_provider_type() {
let adapter = get_adapter_for_provider_type(&ProviderType::Claude);
assert_eq!(adapter.name(), "Claude");
let adapter = get_adapter_for_provider_type(&ProviderType::ClaudeAuth);
assert_eq!(adapter.name(), "Claude");
let adapter = get_adapter_for_provider_type(&ProviderType::OpenRouter);
assert_eq!(adapter.name(), "Claude");
let adapter = get_adapter_for_provider_type(&ProviderType::Codex);
assert_eq!(adapter.name(), "Codex");
let adapter = get_adapter_for_provider_type(&ProviderType::Gemini);
assert_eq!(adapter.name(), "Gemini");
let adapter = get_adapter_for_provider_type(&ProviderType::GeminiCli);
assert_eq!(adapter.name(), "Gemini");
}
}
@@ -0,0 +1,104 @@
//! Anthropic API 数据模型
//!
//! 用于 Anthropic Messages API 的请求/响应格式转换
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Anthropic 请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicRequest {
pub model: String,
pub messages: Vec<AnthropicMessage>,
pub max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<Value>, // 可以是 String 或 Vec<SystemBlock>
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<AnthropicTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>,
}
/// Anthropic 消息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicMessage {
pub role: String,
pub content: Value, // String 或 Vec<ContentBlock>
}
/// Anthropic 内容块
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AnthropicContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image")]
Image { source: ImageSource },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: Value,
},
#[serde(rename = "tool_result")]
ToolResult { tool_use_id: String, content: Value },
}
/// 图片来源
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageSource {
#[serde(rename = "type")]
pub source_type: String,
pub media_type: String,
pub data: String,
}
/// Anthropic 工具定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicTool {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub input_schema: Value,
}
/// Anthropic 响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicResponse {
pub id: String,
#[serde(rename = "type")]
pub response_type: String,
pub role: String,
pub content: Vec<AnthropicResponseContent>,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_sequence: Option<String>,
pub usage: AnthropicUsage,
}
/// Anthropic 响应内容
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AnthropicResponseContent {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: Value,
},
}
/// Anthropic 使用量
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicUsage {
pub input_tokens: u32,
pub output_tokens: u32,
}
@@ -0,0 +1,6 @@
//! API 数据模型
//!
//! 定义 Anthropic 和 OpenAI API 的请求/响应结构
pub mod anthropic;
pub mod openai;
@@ -0,0 +1,113 @@
//! OpenAI API 数据模型
//!
//! 用于 OpenAI Chat Completions API 的请求/响应格式转换
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// OpenAI 请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIRequest {
pub model: String,
pub messages: Vec<OpenAIMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<OpenAITool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>,
}
/// OpenAI 消息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIMessage {
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<Value>, // String 或 Vec<ContentPart>
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<OpenAIToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
/// OpenAI 内容部分
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum OpenAIContentPart {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image_url")]
ImageUrl { image_url: ImageUrl },
}
/// 图片 URL
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageUrl {
pub url: String,
}
/// OpenAI 工具调用
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
pub function: OpenAIFunction,
}
/// OpenAI 函数
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIFunction {
pub name: String,
pub arguments: String, // JSON 字符串
}
/// OpenAI 工具定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAITool {
#[serde(rename = "type")]
pub tool_type: String,
pub function: OpenAIFunctionDef,
}
/// OpenAI 函数定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIFunctionDef {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub parameters: Value,
}
/// OpenAI 响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIResponse {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<OpenAIChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<OpenAIUsage>,
}
/// OpenAI 选择
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIChoice {
pub index: u32,
pub message: OpenAIMessage,
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>,
}
/// OpenAI 使用量
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
}
+329
View File
@@ -0,0 +1,329 @@
//! 流式响应转换模块
//!
//! 实现 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;
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::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
let event = json!({"type": "message_stop"});
let sse_data = format!("event: message_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
yield Ok(Bytes::from(sse_data));
continue;
}
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
// 仅在 DEBUG 级别简短记录 SSE 事件
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
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()
})
}
+640
View File
@@ -0,0 +1,640 @@
//! 格式转换模块
//!
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
//! 参考: anthropic-proxy-rs
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use serde_json::{json, Value};
/// 从 Provider 配置中获取模型映射
fn get_model_from_provider(model: &str, provider: &Provider, body: &Value) -> String {
let env = provider.settings_config.get("env");
let model_lower = model.to_lowercase();
// 检测 thinking 参数
let has_thinking = body
.get("thinking")
.and_then(|v| v.as_object())
.and_then(|o| o.get("type"))
.and_then(|t| t.as_str())
== Some("enabled");
if let Some(env) = env {
// 如果启用 thinking,优先使用推理模型
if has_thinking {
if let Some(m) = env
.get("ANTHROPIC_REASONING_MODEL")
.and_then(|v| v.as_str())
{
log::debug!("[Transform] 使用推理模型: {m}");
return m.to_string();
}
}
// 根据模型类型选择配置模型
if model_lower.contains("haiku") {
if let Some(m) = env
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
.and_then(|v| v.as_str())
{
return m.to_string();
}
}
if model_lower.contains("opus") {
if let Some(m) = env
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
.and_then(|v| v.as_str())
{
return m.to_string();
}
}
if model_lower.contains("sonnet") {
if let Some(m) = env
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
.and_then(|v| v.as_str())
{
return m.to_string();
}
}
// 默认使用 ANTHROPIC_MODEL
if let Some(m) = env.get("ANTHROPIC_MODEL").and_then(|v| v.as_str()) {
return m.to_string();
}
}
model.to_string()
}
/// Anthropic 请求 → OpenAI 请求
pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result<Value, ProxyError> {
let mut result = json!({});
// 模型映射:使用 Provider 配置中的模型(支持 thinking 参数)
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
let mapped_model = get_model_from_provider(model, provider, &body);
result["model"] = json!(mapped_model);
}
let mut messages = Vec::new();
// 处理 system prompt
if let Some(system) = body.get("system") {
if let Some(text) = system.as_str() {
// 单个字符串
messages.push(json!({"role": "system", "content": text}));
} else if let Some(arr) = system.as_array() {
// 多个 system message
for msg in arr {
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
messages.push(json!({"role": "system", "content": text}));
}
}
}
}
// 转换 messages
if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) {
for msg in msgs {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
let content = msg.get("content");
let converted = convert_message_to_openai(role, content)?;
messages.extend(converted);
}
}
result["messages"] = json!(messages);
// 转换参数
if let Some(v) = body.get("max_tokens") {
result["max_tokens"] = v.clone();
}
if let Some(v) = body.get("temperature") {
result["temperature"] = v.clone();
}
if let Some(v) = body.get("top_p") {
result["top_p"] = v.clone();
}
if let Some(v) = body.get("stop_sequences") {
result["stop"] = v.clone();
}
if let Some(v) = body.get("stream") {
result["stream"] = v.clone();
}
// 转换 tools (过滤 BatchTool)
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
let openai_tools: Vec<Value> = tools
.iter()
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
.map(|t| {
json!({
"type": "function",
"function": {
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
"description": t.get("description"),
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
}
})
})
.collect();
if !openai_tools.is_empty() {
result["tools"] = json!(openai_tools);
}
}
if let Some(v) = body.get("tool_choice") {
result["tool_choice"] = v.clone();
}
Ok(result)
}
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
fn convert_message_to_openai(
role: &str,
content: Option<&Value>,
) -> Result<Vec<Value>, ProxyError> {
let mut result = Vec::new();
let content = match content {
Some(c) => c,
None => {
result.push(json!({"role": role, "content": null}));
return Ok(result);
}
};
// 字符串内容
if let Some(text) = content.as_str() {
result.push(json!({"role": role, "content": text}));
return Ok(result);
}
// 数组内容(多模态/工具调用)
if let Some(blocks) = content.as_array() {
let mut content_parts = Vec::new();
let mut tool_calls = Vec::new();
for block in blocks {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
content_parts.push(json!({"type": "text", "text": text}));
}
}
"image" => {
if let Some(source) = block.get("source") {
let media_type = source
.get("media_type")
.and_then(|m| m.as_str())
.unwrap_or("image/png");
let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
content_parts.push(json!({
"type": "image_url",
"image_url": {"url": format!("data:{};base64,{}", media_type, data)}
}));
}
}
"tool_use" => {
let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
let input = block.get("input").cloned().unwrap_or(json!({}));
tool_calls.push(json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": serde_json::to_string(&input).unwrap_or_default()
}
}));
}
"tool_result" => {
// tool_result 变成单独的 tool role 消息
let tool_use_id = block
.get("tool_use_id")
.and_then(|i| i.as_str())
.unwrap_or("");
let content_val = block.get("content");
let content_str = match content_val {
Some(Value::String(s)) => s.clone(),
Some(v) => serde_json::to_string(v).unwrap_or_default(),
None => String::new(),
};
result.push(json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": content_str
}));
}
"thinking" => {
// 跳过 thinking blocks
}
_ => {}
}
}
// 添加带内容和/或工具调用的消息
if !content_parts.is_empty() || !tool_calls.is_empty() {
let mut msg = json!({"role": role});
// 内容处理
if content_parts.is_empty() {
msg["content"] = Value::Null;
} else if content_parts.len() == 1 {
if let Some(text) = content_parts[0].get("text") {
msg["content"] = text.clone();
} else {
msg["content"] = json!(content_parts);
}
} else {
msg["content"] = json!(content_parts);
}
// 工具调用
if !tool_calls.is_empty() {
msg["tool_calls"] = json!(tool_calls);
}
result.push(msg);
}
return Ok(result);
}
// 其他情况直接透传
result.push(json!({"role": role, "content": content}));
Ok(result)
}
/// 清理 JSON schema(移除不支持的 format
fn clean_schema(mut schema: Value) -> Value {
if let Some(obj) = schema.as_object_mut() {
// 移除 "format": "uri"
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
obj.remove("format");
}
// 递归清理嵌套 schema
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
for (_, value) in properties.iter_mut() {
*value = clean_schema(value.clone());
}
}
if let Some(items) = obj.get_mut("items") {
*items = clean_schema(items.clone());
}
}
schema
}
/// OpenAI 响应 → Anthropic 响应
pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
let choices = body
.get("choices")
.and_then(|c| c.as_array())
.ok_or_else(|| ProxyError::TransformError("No choices in response".to_string()))?;
let choice = choices
.first()
.ok_or_else(|| ProxyError::TransformError("Empty choices array".to_string()))?;
let message = choice
.get("message")
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
let mut content = Vec::new();
// 文本内容
if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
if !text.is_empty() {
content.push(json!({"type": "text", "text": text}));
}
}
// 工具调用
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
for tc in tool_calls {
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
let empty_obj = json!({});
let func = tc.get("function").unwrap_or(&empty_obj);
let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("");
let args_str = func
.get("arguments")
.and_then(|a| a.as_str())
.unwrap_or("{}");
let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
content.push(json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input
}));
}
}
// 映射 finish_reason → stop_reason
let stop_reason = choice
.get("finish_reason")
.and_then(|r| r.as_str())
.map(|r| match r {
"stop" => "end_turn",
"length" => "max_tokens",
"tool_calls" => "tool_use",
other => other,
});
// usage
let usage = body.get("usage").cloned().unwrap_or(json!({}));
let input_tokens = usage
.get("prompt_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let output_tokens = usage
.get("completion_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let result = json!({
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
"type": "message",
"role": "assistant",
"content": content,
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
"stop_reason": stop_reason,
"stop_sequence": null,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens
}
});
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_provider(env_config: Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Provider".to_string(),
settings_config: json!({"env": env_config}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
fn create_openrouter_provider() -> Provider {
create_provider(json!({
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"ANTHROPIC_MODEL": "anthropic/claude-sonnet-4.5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4.5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-4.5",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-4.5"
}))
}
#[test]
fn test_anthropic_to_openai_simple() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
// opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL
assert_eq!(result["model"], "anthropic/claude-opus-4.5");
assert_eq!(result["max_tokens"], 1024);
assert_eq!(result["messages"][0]["role"], "user");
assert_eq!(result["messages"][0]["content"], "Hello");
}
#[test]
fn test_anthropic_to_openai_with_system() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": "You are a helpful assistant.",
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
"You are a helpful assistant."
);
assert_eq!(result["messages"][1]["role"], "user");
}
#[test]
fn test_anthropic_to_openai_with_tools() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "What's the weather?"}],
"tools": [{
"name": "get_weather",
"description": "Get weather info",
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
}
#[test]
fn test_anthropic_to_openai_tool_use() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{
"role": "assistant",
"content": [
{"type": "text", "text": "Let me check"},
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
]
}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "assistant");
assert!(msg.get("tool_calls").is_some());
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
}
#[test]
fn test_anthropic_to_openai_tool_result() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_123", "content": "Sunny, 25°C"}
]
}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "tool");
assert_eq!(msg["tool_call_id"], "call_123");
assert_eq!(msg["content"], "Sunny, 25°C");
}
#[test]
fn test_openai_to_anthropic_simple() {
let input = json!({
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["id"], "chatcmpl-123");
assert_eq!(result["type"], "message");
assert_eq!(result["content"][0]["type"], "text");
assert_eq!(result["content"][0]["text"], "Hello!");
assert_eq!(result["stop_reason"], "end_turn");
assert_eq!(result["usage"]["input_tokens"], 10);
assert_eq!(result["usage"]["output_tokens"], 5);
}
#[test]
fn test_openai_to_anthropic_with_tool_calls() {
let input = json!({
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_123",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"location\": \"Tokyo\"}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["content"][0]["type"], "tool_use");
assert_eq!(result["content"][0]["id"], "call_123");
assert_eq!(result["content"][0]["name"], "get_weather");
assert_eq!(result["content"][0]["input"]["location"], "Tokyo");
assert_eq!(result["stop_reason"], "tool_use");
}
#[test]
fn test_model_mapping_from_provider() {
let provider = create_openrouter_provider();
let body = json!({"model": "test"});
// sonnet 模型
assert_eq!(
get_model_from_provider("claude-sonnet-4-5-20250929", &provider, &body),
"anthropic/claude-sonnet-4.5"
);
// haiku 模型
assert_eq!(
get_model_from_provider("claude-haiku-4-5-20250929", &provider, &body),
"anthropic/claude-haiku-4.5"
);
// opus 模型
assert_eq!(
get_model_from_provider("claude-opus-4-5", &provider, &body),
"anthropic/claude-opus-4.5"
);
}
#[test]
fn test_anthropic_to_openai_model_mapping() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
}
#[test]
fn test_thinking_parameter_detection() {
let mut provider = create_openrouter_provider();
// 添加推理模型配置
if let Some(env) = provider.settings_config.get_mut("env") {
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
}
let input = json!({
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"thinking": {"type": "enabled"},
"messages": [{"role": "user", "content": "Solve this problem"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
// 应该使用推理模型
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5:extended");
}
#[test]
fn test_thinking_parameter_disabled() {
let mut provider = create_openrouter_provider();
if let Some(env) = provider.settings_config.get_mut("env") {
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
}
let input = json!({
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"thinking": {"type": "disabled"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
// 应该使用普通模型
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
}
}

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