Compare commits

..

21 Commits

Author SHA1 Message Date
YoVinchen 5b5b3efad4 refactor(ui): simplify UpdateBadge to minimal dot indicator 2026-01-15 01:08:42 +08:00
杨永安 07d022ba9f feat(usage): improve custom template system with variable hints and validation fixes (#628)
* feat(usage): improve custom template with variables display and explicit type detection

Combine two feature improvements:
1. Display supported variables ({{baseUrl}}, {{apiKey}}) with actual values in custom template mode
2. Add explicit templateType field for accurate template mode detection

## Changes

### Frontend
- Display template variables with actual values extracted from provider settings
- Add templateType field to UsageScript for explicit mode detection
- Support template mode persistence across sessions

### Backend
- Add template_type field to UsageScript struct
- Improve validation logic based on explicit template type
- Maintain backward compatibility with type inference

### I18n
- Add "Supported Variables" section translation (zh/en/ja)

### Benefits
- More accurate template mode detection (no more guessing)
- Better user experience with variable hints
- Clearer validation rules per template type

* fix(usage): resolve custom template cache and validation issues

Combine three bug fixes to make custom template mode work correctly:

1. **Update cache after test**: Testing usage script successfully now updates the main list cache immediately
2. **Fix same-origin check**: Custom template mode can now access different domains (SSRF protection still active)
3. **Fix field naming**: Unified to use autoQueryInterval consistently between frontend and backend

## Problems Solved

- Main provider list showing "Query failed" after successful test
- Custom templates blocked by overly strict same-origin validation
- Auto-query intervals not saved correctly due to inconsistent naming

## Changes

### Frontend (UsageScriptModal)
- Import useQueryClient and update cache after successful test
- Invalidate usage cache when saving script configuration
- Use standardized autoQueryInterval field name

### Backend (usage_script.rs)
- Allow custom template mode to bypass same-origin checks
- Maintain SSRF protection for all modes

### Hooks (useProviderActions)
- Invalidate usage query cache when saving script

## Impact

Users can now use custom templates freely while security validations remain intact for general templates.

* fix(usage): correct provider credential field names

- Claude: support both ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN
- Gemini: use GEMINI_API_KEY instead of GOOGLE_GEMINI_API_KEY
- Codex: use OPENAI_API_KEY and parse base_url from TOML config string

Addresses review feedback from PR #628

* style: format code

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-01-14 15:42:05 +08:00
Dex Miller f3343992f2 feat(proxy): add thinking signature rectifier for Claude API (#595)
* feat(proxy): add thinking signature rectifier for Claude API

Add automatic request rectification when Anthropic API returns signature
validation errors. This improves compatibility when switching between
different Claude providers or when historical messages contain incompatible
thinking block signatures.

- Add thinking_rectifier.rs module with trigger detection and rectification
- Integrate rectifier into forwarder error handling flow
- Remove thinking/redacted_thinking blocks and signature fields on retry
- Delete top-level thinking field when assistant message lacks thinking prefix

* fix(proxy): complete rectifier retry path with failover switch and chain continuation

- Add failover switch trigger on rectifier retry success when provider differs from start
- Replace direct error return with error categorization on rectifier retry failure
- Continue failover chain for retryable errors instead of terminating early

* feat(proxy): add rectifier config with master switch

- Add RectifierConfig struct with enabled and requestThinkingSignature fields
- Update should_rectify_thinking_signature to check master switch first
- Add tests for master switch functionality

* feat(db): add rectifier config storage in settings table

Store rectifier config as JSON in single key for extensibility

* feat(commands): add get/set rectifier config commands

* feat(ui): add rectifier config panel in advanced settings

- Add RectifierConfigPanel component with master switch and thinking signature toggle
- Add API wrapper for rectifier config
- Add i18n translations for zh/en/ja

* feat(proxy): integrate rectifier config into request forwarding

- Load rectifier config from database in RequestContext
- Pass config to RequestForwarder for runtime checking
- Use should_rectify_thinking_signature with config parameter

* test(proxy): add nested JSON error detection test for thinking rectifier

* fix(proxy): resolve HalfOpen permit leak and RectifierConfig default values

- Fix RectifierConfig::default() to return enabled=true (was false due to derive)
- Add release_permit_neutral() for releasing permits without affecting health stats
- Fix 3 permit leak points in rectifier retry branches
- Add unit tests for default values and permit release

* style(ui): format ProviderCard style attribute

* fix(rectifier): add detection for signature field required error

Add support for detecting "signature: Field required" error pattern
in the thinking signature rectifier. This enables automatic request
rectification when upstream API returns this specific validation error.
2026-01-14 00:12:13 +08:00
Jason 53f40b2d7a refactor(ui): unify pricing edit modal with FullScreenPanel
Replace Dialog component with FullScreenPanel in PricingEditModal
to match the UI style of other edit dialogs (provider, MCP).

Changes:
- Switch from small centered Dialog to full-screen panel
- Add back button in header and fixed footer for actions
- Add Save/Plus icons to submit button
2026-01-13 15:13:33 +08:00
Dex Miller 1393f89797 feat(misc): add WSL tool version detection with security hardening (#… (#627)
* feat(misc): add WSL tool version detection with security hardening (#608)

- Add WSL distro detection from UNC path (wsl$/wsl.localhost)
- Add distro name validation to prevent command injection
- Add defensive assertion for tool parameter
- Unify cfg macros to target_os = "windows"
- Standardize error message format to [WSL:{distro}]

Closes #608

* fix(misc): add CREATE_NO_WINDOW flag and unify error messages to English

- Add CREATE_NO_WINDOW flag to wsl.exe command to prevent console window flash
- Standardize error messages from Chinese to English for consistency

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-01-13 12:11:54 +08:00
Dex Miller f9d80b8dc3 feat(stream-check): enhance health check with configurable prompt and CLI-compatible requests (#623)
- Add configurable test prompt field to StreamCheckConfig
- Implement Claude CLI-compatible request format with proper headers:
  - Authorization + x-api-key dual auth
  - anthropic-beta, anthropic-version headers
  - x-stainless-* SDK headers with dynamic OS/arch detection
  - URL with ?beta=true parameter
- Implement Codex CLI-compatible Responses API format:
  - /v1/responses endpoint
  - input array format with reasoning effort support
  - codex_cli_rs user-agent and originator headers
- Add dynamic OS name and CPU architecture detection
- Internationalize error messages (Chinese -> English)
- Add test prompt Textarea UI component with i18n support
- Remove obsolete testPromptDesc translation key
2026-01-13 11:36:19 +08:00
Dex Miller 8b92982112 Feature/global proxy (#596)
* 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

* 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.

* 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

* 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

* fix(proxy): allow localhost input in proxy address field

* 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

* style: format code with prettier

* 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]

* 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)

* 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-13 10:55:53 +08:00
Dex Miller 74b4d4ecbb fix(ui): auto-adapt usage block offset based on action buttons width (#613) 2026-01-12 23:07:02 +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
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
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
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
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
tianrking debe4232bc Update misc.rs 2025-12-29 23:47:30 +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
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
59 changed files with 3887 additions and 1243 deletions
+239 -776
View File
File diff suppressed because it is too large Load Diff
+97 -2
View File
@@ -65,6 +65,30 @@ 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 {
@@ -371,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() {
@@ -397,8 +426,10 @@ pub fn set_mcp_servers_map(
obj.remove("homepage");
obj.remove("docs");
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
wrap_command_for_windows(&mut obj);
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式WSL 路径除外)
if !is_wsl_target {
wrap_command_for_windows(&mut obj);
}
out.insert(id.clone(), Value::Object(obj));
}
@@ -545,4 +576,68 @@ mod tests {
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")));
}
}
}
+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()
}
+418 -9
View File
@@ -1,9 +1,14 @@
#![allow(non_snake_case)]
use crate::app_config::AppType;
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
use crate::services::ProviderService;
use once_cell::sync::Lazy;
use regex::Regex;
use std::path::Path;
use std::str::FromStr;
use tauri::AppHandle;
use tauri::State;
use tauri_plugin_opener::OpenerExt;
#[cfg(target_os = "windows")]
@@ -87,15 +92,14 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
let tools = vec!["claude", "codex", "gemini"];
let mut results = Vec::new();
// 用于获取远程版本的 client
let client = reqwest::Client::builder()
.user_agent("cc-switch/1.0")
.build()
.map_err(|e| e.to_string())?;
// 使用全局 HTTP 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
for tool in tools {
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
let (local_version, local_error) = {
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
try_get_version_wsl(tool, &distro)
} else {
// 先尝试直接执行
let direct_result = try_get_version(tool);
@@ -183,7 +187,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() {
(None, Some("未安装或无法执行".to_string()))
(None, Some("not installed or not executable".to_string()))
} else {
(Some(extract_version(raw)), None)
}
@@ -192,7 +196,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
(
None,
Some(if err.is_empty() {
"未安装或无法执行".to_string()
"not installed or not executable".to_string()
} else {
err
}),
@@ -203,6 +207,88 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
}
}
/// 校验 WSL 发行版名称是否合法
/// WSL 发行版名称只允许字母、数字、连字符和下划线
#[cfg(target_os = "windows")]
fn is_valid_wsl_distro_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
#[cfg(target_os = "windows")]
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
use std::process::Command;
// 防御性断言:tool 只能是预定义的值
debug_assert!(
["claude", "codex", "gemini"].contains(&tool),
"unexpected tool name: {tool}"
);
// 校验 distro 名称,防止命令注入
if !is_valid_wsl_distro_name(distro) {
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
}
let output = Command::new("wsl.exe")
.args([
"-d",
distro,
"--",
"sh",
"-lc",
&format!("{tool} --version"),
])
.creation_flags(CREATE_NO_WINDOW)
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() {
(
None,
Some(format!("[WSL:{distro}] not installed or not executable")),
)
} else {
(Some(extract_version(raw)), None)
}
} else {
let err = if stderr.is_empty() { stdout } else { stderr };
(
None,
Some(format!(
"[WSL:{distro}] {}",
if err.is_empty() {
"not installed or not executable".to_string()
} else {
err
}
)),
)
}
}
Err(e) => (None, Some(format!("[WSL:{distro}] exec failed: {e}"))),
}
}
/// 非 Windows 平台的 WSL 版本检测存根
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
#[cfg(not(target_os = "windows"))]
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
(
None,
Some("WSL check not supported on this platform".to_string()),
)
}
/// 扫描常见路径查找 CLI
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
use std::process::Command;
@@ -298,5 +384,328 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
}
}
(None, Some("未安装或无法执行".to_string()))
(None, Some("not installed or not executable".to_string()))
}
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
let override_dir = match tool {
"claude" => crate::settings::get_claude_override_dir(),
"codex" => crate::settings::get_codex_override_dir(),
"gemini" => crate::settings::get_gemini_override_dir(),
_ => None,
}?;
wsl_distro_from_path(&override_dir)
}
/// 从 UNC 路径中提取 WSL 发行版名称
/// 支持 `\\wsl$\Ubuntu\...` 和 `\\wsl.localhost\Ubuntu\...` 两种格式
#[cfg(target_os = "windows")]
fn wsl_distro_from_path(path: &Path) -> Option<String> {
use std::path::{Component, Prefix};
let Some(Component::Prefix(prefix)) = path.components().next() else {
return None;
};
match prefix.kind() {
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
let server_name = server.to_string_lossy();
if server_name.eq_ignore_ascii_case("wsl$")
|| server_name.eq_ignore_ascii_case("wsl.localhost")
{
let distro = share.to_string_lossy().to_string();
if !distro.is_empty() {
return Some(distro);
}
}
None
}
_ => None,
}
}
/// 非 Windows 平台不支持 WSL 路径解析
#[cfg(not(target_os = "windows"))]
fn wsl_distro_from_path(_path: &Path) -> Option<String> {
None
}
/// 打开指定提供商的终端
///
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
#[allow(non_snake_case)]
#[tauri::command]
pub async fn open_provider_terminal(
state: State<'_, crate::store::AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
// 获取提供商配置
let providers = ProviderService::list(state.inner(), app_type.clone())
.map_err(|e| format!("获取提供商列表失败: {e}"))?;
let provider = providers
.get(&providerId)
.ok_or_else(|| format!("提供商 {providerId} 不存在"))?;
// 从提供商配置中提取环境变量
let config = &provider.settings_config;
let env_vars = extract_env_vars_from_config(config, &app_type);
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
Ok(true)
}
/// 从提供商配置中提取环境变量
fn extract_env_vars_from_config(
config: &serde_json::Value,
app_type: &AppType,
) -> Vec<(String, String)> {
let mut env_vars = Vec::new();
let Some(obj) = config.as_object() else {
return env_vars;
};
// 处理 env 字段(Claude/Gemini 通用)
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
for (key, value) in env {
if let Some(str_val) = value.as_str() {
env_vars.push((key.clone(), str_val.to_string()));
}
}
// 处理 base_url: 根据应用类型添加对应的环境变量
let base_url_key = match app_type {
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
_ => None,
};
if let Some(key) = base_url_key {
if let Some(url_str) = env.get(key).and_then(|v| v.as_str()) {
env_vars.push((key.to_string(), url_str.to_string()));
}
}
}
// Codex 使用 auth 字段转换为 OPENAI_API_KEY
if *app_type == AppType::Codex {
if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) {
env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string()));
}
}
// Gemini 使用 api_key 字段转换为 GEMINI_API_KEY
if *app_type == AppType::Gemini {
if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) {
env_vars.push(("GEMINI_API_KEY".to_string(), api_key.to_string()));
}
}
env_vars
}
/// 创建临时配置文件并启动 claude 终端
/// 使用 --settings 参数传入提供商特定的 API 配置
fn launch_terminal_with_env(
env_vars: Vec<(String, String)>,
provider_id: &str,
) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let config_file = temp_dir.join(format!(
"claude_{}_{}.json",
provider_id,
std::process::id()
));
// 创建并写入配置文件
write_claude_config(&config_file, &env_vars)?;
// 转义配置文件路径用于 shell
let config_path_escaped = escape_shell_path(&config_file);
#[cfg(target_os = "macos")]
{
launch_macos_terminal(&config_file, &config_path_escaped)?;
Ok(())
}
#[cfg(target_os = "linux")]
{
launch_linux_terminal(&config_file, &config_path_escaped)?;
Ok(())
}
#[cfg(target_os = "windows")]
{
launch_windows_terminal(&temp_dir, &config_file)?;
return Ok(());
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("不支持的操作系统".to_string())
}
/// 写入 claude 配置文件
fn write_claude_config(
config_file: &std::path::Path,
env_vars: &[(String, String)],
) -> Result<(), String> {
let mut config_obj = serde_json::Map::new();
let mut env_obj = serde_json::Map::new();
for (key, value) in env_vars {
env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));
}
config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
let config_json =
serde_json::to_string_pretty(&config_obj).map_err(|e| format!("序列化配置失败: {e}"))?;
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
}
/// 转义 shell 路径
fn escape_shell_path(path: &std::path::Path) -> String {
path.to_string_lossy()
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace(' ', "\\ ")
}
/// 生成 bash 包装脚本,用于清理临时文件
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
format!(
"bash -c 'trap \"rm -f \\\"{config_path}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{escaped_path}\"; claude --settings \"{escaped_path}\"; exec bash --norc --noprofile'"
)
}
/// macOS: 使用 Terminal.app 启动
#[cfg(target_os = "macos")]
fn launch_macos_terminal(
config_file: &std::path::Path,
config_path_escaped: &str,
) -> Result<(), String> {
use std::process::Command;
let config_path_for_script = config_file.to_string_lossy().replace('\"', "\\\"");
let shell_script = generate_wrapper_script(&config_path_for_script, config_path_escaped);
let script = format!(
r#"tell application "Terminal"
activate
do script "{}"
end tell"#,
shell_script.replace('\"', "\\\"")
);
Command::new("osascript")
.arg("-e")
.arg(&script)
.spawn()
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;
Ok(())
}
/// Linux: 尝试使用常见终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(
config_file: &std::path::Path,
config_path_escaped: &str,
) -> Result<(), String> {
use std::process::Command;
let terminals = [
"gnome-terminal",
"konsole",
"xfce4-terminal",
"mate-terminal",
"lxterminal",
"alacritty",
"kitty",
];
let config_path_for_bash = config_file.to_string_lossy();
let shell_cmd = generate_wrapper_script(&config_path_for_bash, config_path_escaped);
let mut last_error = String::from("未找到可用的终端");
for terminal in terminals {
// 检查终端是否存在
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
{
let result = match terminal {
"gnome-terminal" | "mate-terminal" => Command::new(terminal)
.arg("--")
.arg("bash")
.arg("-c")
.arg(&shell_cmd)
.spawn(),
_ => Command::new(terminal)
.arg("-e")
.arg("bash")
.arg("-c")
.arg(&shell_cmd)
.spawn(),
};
match result {
Ok(_) => return Ok(()),
Err(e) => {
last_error = format!("启动 {} 失败: {}", terminal, e);
}
}
}
}
// 清理配置文件
let _ = std::fs::remove_file(config_file);
Err(last_error)
}
/// Windows: 创建临时批处理文件启动
#[cfg(target_os = "windows")]
fn launch_windows_terminal(
temp_dir: &std::path::Path,
config_file: &std::path::Path,
) -> Result<(), String> {
use std::process::Command;
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
let content = format!(
"@echo off
echo Using provider-specific claude config:
echo {}
claude --settings \"{}\"
del \"{}\" >nul 2>&1
del \"%~f0\" >nul 2>&1
if errorlevel 1 (
echo.
echo Press any key to close...
pause >nul
)",
config_path_for_batch, config_path_for_batch, config_path_for_batch
);
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
Command::new("cmd")
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy()])
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;
Ok(())
}
+2
View File
@@ -4,6 +4,7 @@ mod config;
mod deeplink;
mod env;
mod failover;
mod global_proxy;
mod import_export;
mod mcp;
mod misc;
@@ -20,6 +21,7 @@ pub use config::*;
pub use deeplink::*;
pub use env::*;
pub use failover::*;
pub use global_proxy::*;
pub use import_export::*;
pub use mcp::*;
pub use misc::*;
+2
View File
@@ -133,6 +133,7 @@ pub async fn testUsageScript(
#[allow(non_snake_case)] baseUrl: Option<String>,
#[allow(non_snake_case)] accessToken: Option<String>,
#[allow(non_snake_case)] userId: Option<String>,
#[allow(non_snake_case)] templateType: Option<String>,
) -> Result<crate::provider::UsageResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::test_usage_script(
@@ -145,6 +146,7 @@ pub async fn testUsageScript(
baseUrl.as_deref(),
accessToken.as_deref(),
userId.as_deref(),
templateType.as_deref(),
)
.await
.map_err(|e| e.to_string())
+21
View File
@@ -59,3 +59,24 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
pub async fn get_auto_launch_status() -> Result<bool, String> {
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
}
/// 获取整流器配置
#[tauri::command]
pub async fn get_rectifier_config(
state: tauri::State<'_, crate::AppState>,
) -> Result<crate::proxy::types::RectifierConfig, String> {
state.db.get_rectifier_config().map_err(|e| e.to_string())
}
/// 设置整流器配置
#[tauri::command]
pub async fn set_rectifier_config(
state: tauri::State<'_, crate::AppState>,
config: crate::proxy::types::RectifierConfig,
) -> Result<bool, String> {
state
.db
.set_rectifier_config(&config)
.map_err(|e| e.to_string())?;
Ok(true)
}
+58
View File
@@ -63,6 +63,41 @@ impl Database {
}
}
// --- 全局出站代理 ---
/// 全局代理 URL 的存储键名
const GLOBAL_PROXY_URL_KEY: &'static str = "global_proxy_url";
/// 获取全局出站代理 URL
///
/// 返回 None 表示未配置或已清除代理(直连)
/// 返回 Some(url) 表示已配置代理
pub fn get_global_proxy_url(&self) -> Result<Option<String>, AppError> {
self.get_setting(Self::GLOBAL_PROXY_URL_KEY)
}
/// 设置全局出站代理 URL
///
/// - 传入非空字符串:启用代理
/// - 传入空字符串或 None:清除代理设置(直连)
pub fn set_global_proxy_url(&self, url: Option<&str>) -> Result<(), AppError> {
match url {
Some(u) if !u.trim().is_empty() => {
self.set_setting(Self::GLOBAL_PROXY_URL_KEY, u.trim())
}
_ => {
// 清除代理设置
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM settings WHERE key = ?1",
params![Self::GLOBAL_PROXY_URL_KEY],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
}
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
/// 获取指定应用的代理接管状态
@@ -128,4 +163,27 @@ impl Database {
log::info!("已清除所有代理接管状态");
Ok(())
}
// --- 整流器配置 ---
/// 获取整流器配置
///
/// 返回整流器配置,如果不存在则返回默认值(全部启用)
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
match self.get_setting("rectifier_config")? {
Some(json) => serde_json::from_str(&json)
.map_err(|e| AppError::Database(format!("解析整流器配置失败: {e}"))),
None => Ok(crate::proxy::types::RectifierConfig::default()),
}
}
/// 更新整流器配置
pub fn set_rectifier_config(
&self,
config: &crate::proxy::types::RectifierConfig,
) -> Result<(), AppError> {
let json = serde_json::to_string(config)
.map_err(|e| AppError::Database(format!("序列化整流器配置失败: {e}")))?;
self.set_setting("rectifier_config", &json)
}
}
+1 -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
+6 -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}]"))?;
}
}
}
+68 -21
View File
@@ -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)?;
@@ -138,6 +168,16 @@ pub(crate) fn build_provider_from_request(
Ok(provider)
}
/// Get primary endpoint from request (first one if comma-separated)
fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
request
.endpoint
.as_ref()
.and_then(|ep| ep.split(',').next())
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Build provider meta with usage script configuration
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
// Check if any usage script fields are provided
@@ -165,6 +205,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
// Build UsageScript - use provider's API key and endpoint as defaults
// Note: use primary endpoint only (first one if comma-separated)
let usage_script = UsageScript {
enabled,
language: "javascript".to_string(),
@@ -174,12 +215,17 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
.usage_api_key
.clone()
.or_else(|| request.api_key.clone()),
base_url: request
.usage_base_url
.clone()
.or_else(|| request.endpoint.clone()),
base_url: request.usage_base_url.clone().or_else(|| {
let primary = get_primary_endpoint(request);
if primary.is_empty() {
None
} else {
Some(primary)
}
}),
access_token: request.usage_access_token.clone(),
user_id: request.usage_user_id.clone(),
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
auto_query_interval: request.usage_auto_interval,
};
@@ -198,7 +244,7 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
);
env.insert(
"ANTHROPIC_BASE_URL".to_string(),
json!(request.endpoint.clone().unwrap_or_default()),
json!(get_primary_endpoint(request)),
);
// Add default model if provided
@@ -271,11 +317,8 @@ fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
.unwrap_or("gpt-5-codex")
.to_string();
// Endpoint: normalize trailing slashes
let endpoint = request
.endpoint
.as_deref()
.unwrap_or("")
// Endpoint: normalize trailing slashes (use primary endpoint only)
let endpoint = get_primary_endpoint(request)
.trim()
.trim_end_matches('/')
.to_string();
@@ -309,7 +352,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
@@ -523,7 +566,11 @@ fn merge_gemini_config(
}
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
if let Some(base_url) = config
.get("GOOGLE_GEMINI_BASE_URL")
.or_else(|| config.get("GEMINI_BASE_URL"))
.and_then(|v| v.as_str())
{
request.endpoint = Some(base_url.to_string());
}
}
+54
View File
@@ -404,3 +404,57 @@ fn test_parse_skill_deeplink() {
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())
);
}
+42
View File
@@ -27,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;
@@ -643,6 +644,37 @@ pub fn run() {
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 {
@@ -702,6 +734,8 @@ pub fn run() {
commands::read_live_provider_settings,
commands::get_settings,
commands::save_settings,
commands::get_rectifier_config,
commands::set_rectifier_config,
commands::restart_app,
commands::check_for_updates,
commands::is_portable_mode,
@@ -832,12 +866,20 @@ pub fn run() {
commands::get_stream_check_config,
commands::save_stream_check_config,
commands::get_tool_versions,
// Provider terminal
commands::open_provider_terminal,
// Universal Provider management
commands::get_universal_providers,
commands::get_universal_provider,
commands::upsert_universal_provider,
commands::delete_universal_provider,
commands::sync_universal_provider,
// 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
+7
View File
@@ -98,6 +98,10 @@ pub struct UsageScript {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "userId")]
pub user_id: Option<String>,
/// 模板类型(用于后端判断验证规则)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "templateType")]
pub template_type: Option<String>,
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "autoQueryInterval")]
@@ -147,6 +151,9 @@ pub struct ProviderMeta {
/// 用量查询脚本配置
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<UsageScript>,
/// 请求地址管理:测速后自动选择最佳端点
#[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
pub endpoint_auto_select: Option<bool>,
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
pub is_partner: Option<bool>,
+5 -1
View File
@@ -319,7 +319,11 @@ impl CircuitBreaker {
}
}
fn release_half_open_permit(&self) {
/// 仅释放 HalfOpen permit,不影响健康统计
///
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
pub fn release_half_open_permit(&self) {
let mut current = self.half_open_requests.load(Ordering::SeqCst);
loop {
if current == 0 {
+231 -53
View File
@@ -7,15 +7,15 @@ use super::{
error::*,
failover_switch::FailoverSwitchManager,
provider_router::ProviderRouter,
providers::{get_adapter, ProviderAdapter},
types::ProxyStatus,
providers::{get_adapter, ProviderAdapter, ProviderType},
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
types::{ProxyStatus, RectifierConfig},
ProxyError,
};
use crate::{app_config::AppType, provider::Provider};
use reqwest::{Client, Response};
use reqwest::Response;
use serde_json::Value;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
/// Headers 黑名单 - 不透传到上游的 Headers
@@ -81,8 +81,6 @@ pub struct ForwardError {
}
pub struct RequestForwarder {
client: Option<Client>,
client_init_error: Option<String>,
/// 共享的 ProviderRouter(持有熔断器状态)
router: Arc<ProviderRouter>,
status: Arc<RwLock<ProxyStatus>>,
@@ -93,6 +91,10 @@ pub struct RequestForwarder {
app_handle: Option<tauri::AppHandle>,
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
current_provider_id_at_start: String,
/// 整流器配置
rectifier_config: RectifierConfig,
/// 非流式请求超时(秒)
non_streaming_timeout: std::time::Duration,
}
impl RequestForwarder {
@@ -107,52 +109,17 @@ impl RequestForwarder {
current_provider_id_at_start: String,
_streaming_first_byte_timeout: u64,
_streaming_idle_timeout: u64,
rectifier_config: RectifierConfig,
) -> Self {
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
// 参考 Claude Code Hub 的 undici 全局超时设计
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
let timeout_secs = if non_streaming_timeout > 0 {
non_streaming_timeout
} else {
GLOBAL_TIMEOUT_SECS
};
// 注意:这里不能用 expect/unwrap。
// release 配置为 panic=abort,一旦 build 失败会导致整个应用闪退。
// 常见原因:用户环境变量里存在不合法/不支持的代理(HTTP(S)_PROXY/ALL_PROXY 等)。
let (client, client_init_error) = match Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
{
Ok(client) => (Some(client), None),
Err(e) => {
// 降级:忽略系统/环境代理,避免因代理配置问题导致整个应用崩溃
match Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.no_proxy()
.build()
{
Ok(client) => (Some(client), Some(e.to_string())),
Err(fallback_err) => (
None,
Some(format!(
"Failed to create HTTP client: {e}; no_proxy fallback failed: {fallback_err}"
)),
),
}
}
};
Self {
client,
client_init_error,
router,
status,
current_providers,
failover_manager,
app_handle,
current_provider_id_at_start,
rectifier_config,
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
}
}
@@ -168,7 +135,7 @@ impl RequestForwarder {
&self,
app_type: &AppType,
endpoint: &str,
body: Value,
mut body: Value,
headers: axum::http::HeaderMap,
providers: Vec<Provider>,
) -> Result<ForwardResult, ForwardError> {
@@ -187,6 +154,9 @@ impl RequestForwarder {
let mut last_provider = None;
let mut attempted_providers = 0usize;
// 整流器重试标记:确保整流最多触发一次
let mut rectifier_retried = false;
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
let bypass_circuit_breaker = providers.len() == 1;
@@ -281,6 +251,205 @@ impl RequestForwarder {
});
}
Err(e) => {
// 检测是否需要触发整流器(仅 Claude/ClaudeAuth 供应商)
let provider_type = ProviderType::from_app_type_and_config(app_type, provider);
let is_anthropic_provider = matches!(
provider_type,
ProviderType::Claude | ProviderType::ClaudeAuth
);
if is_anthropic_provider {
let error_message = extract_error_message(&e);
if should_rectify_thinking_signature(
error_message.as_deref(),
&self.rectifier_config,
) {
// 已经重试过:直接返回错误(不可重试客户端错误)
if rectifier_retried {
log::warn!("[{app_type_str}] [RECT-005] 整流器已触发过,不再重试");
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
// 首次触发:整流请求体
let rectified = rectify_anthropic_request(&mut body);
// 整流未生效:直接返回错误(不可重试客户端错误)
if !rectified.applied {
log::warn!(
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
);
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
log::info!(
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
app_type_str,
rectified.removed_thinking_blocks,
rectified.removed_redacted_thinking_blocks,
rectified.removed_signature_fields
);
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
let _ = std::mem::replace(&mut rectifier_retried, true);
// 使用同一供应商重试(不计入熔断器)
match self
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
.await
{
Ok(response) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
// 记录成功
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await;
// 更新当前应用类型使用的 provider
{
let mut current_providers =
self.current_providers.write().await;
current_providers.insert(
app_type_str.to_string(),
(provider.id.clone(), provider.name.clone()),
);
}
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
status.last_error = None;
let should_switch =
self.current_provider_id_at_start.as_str()
!= provider.id.as_str();
if should_switch {
status.failover_count += 1;
// 异步触发供应商切换,更新 UI/托盘
let fm = self.failover_manager.clone();
let ah = self.app_handle.clone();
let pid = provider.id.clone();
let pname = provider.name.clone();
let at = app_type_str.to_string();
tokio::spawn(async move {
let _ = fm
.try_switch(ah.as_ref(), &at, &pid, &pname)
.await;
});
}
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
}
return Ok(ForwardResult {
response,
provider: provider.clone(),
});
}
Err(retry_err) => {
// 整流重试仍失败:区分错误类型决定是否记录熔断器
log::warn!(
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
);
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
let is_provider_error = match &retry_err {
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
true
}
ProxyError::UpstreamError { status, .. } => *status >= 500,
_ => false,
};
if is_provider_error {
// Provider 问题:记录失败到熔断器
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
false,
Some(retry_err.to_string()),
)
.await;
} else {
// 客户端问题:仅释放 permit,不记录熔断器
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
}
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(retry_err.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: retry_err,
provider: Some(provider.clone()),
});
}
}
}
}
// 失败:记录失败并更新熔断器
let _ = self
.router
@@ -416,16 +585,17 @@ impl RequestForwarder {
// 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
// 构建请求
let client = self.client.as_ref().ok_or_else(|| {
ProxyError::ForwardFailed(
self.client_init_error
.clone()
.unwrap_or_else(|| "HTTP client is not initialized".to_string()),
)
})?;
// 每次请求时获取最新的全局 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
@@ -541,3 +711,11 @@ impl RequestForwarder {
}
}
}
/// 从 ProxyError 中提取错误消息
fn extract_error_message(error: &ProxyError) -> Option<String> {
match error {
ProxyError::UpstreamError { body, .. } => body.clone(),
_ => Some(error.to_string()),
}
}
+11 -1
View File
@@ -5,7 +5,10 @@
use crate::app_config::AppType;
use crate::provider::Provider;
use crate::proxy::{
extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig,
extract_session_id,
forwarder::RequestForwarder,
server::ProxyState,
types::{AppProxyConfig, RectifierConfig},
ProxyError,
};
use axum::http::HeaderMap;
@@ -54,6 +57,8 @@ pub struct RequestContext {
pub app_type: AppType,
/// Session ID(从客户端请求提取或新生成)
pub session_id: String,
/// 整流器配置
pub rectifier_config: RectifierConfig,
}
impl RequestContext {
@@ -86,6 +91,9 @@ impl RequestContext {
.await
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
// 从数据库读取整流器配置
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
let current_provider_id =
crate::settings::get_current_provider(&app_type).unwrap_or_default();
@@ -147,6 +155,7 @@ impl RequestContext {
app_type_str,
app_type,
session_id,
rectifier_config,
})
}
@@ -206,6 +215,7 @@ impl RequestContext {
self.current_provider_id.clone(),
first_byte_timeout,
idle_timeout,
self.rectifier_config.clone(),
)
}
+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");
}
}
+2
View File
@@ -12,6 +12,7 @@ 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;
@@ -20,6 +21,7 @@ pub mod response_handler;
pub mod response_processor;
pub(crate) mod server;
pub mod session;
pub mod thinking_rectifier;
pub(crate) mod types;
pub mod usage;
+69
View File
@@ -151,6 +151,24 @@ impl ProviderRouter {
self.reset_circuit_breaker(&circuit_key).await;
}
/// 仅释放 HalfOpen permit,不影响健康统计(neutral 接口)
///
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
pub async fn release_permit_neutral(
&self,
provider_id: &str,
app_type: &str,
used_half_open_permit: bool,
) {
if !used_half_open_permit {
return;
}
let circuit_key = format!("{app_type}:{provider_id}");
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
breaker.release_half_open_permit();
}
/// 更新所有熔断器的配置(热更新)
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
let breakers = self.circuit_breakers.read().await;
@@ -325,4 +343,55 @@ mod tests {
assert!(router.allow_provider_request("b", "claude").await.allowed);
}
#[tokio::test]
async fn test_release_permit_neutral_frees_half_open_slot() {
let db = Arc::new(Database::memory().unwrap());
// 配置熔断器:1 次失败即熔断,0 秒超时立即进入 HalfOpen
db.update_circuit_breaker_config(&CircuitBreakerConfig {
failure_threshold: 1,
timeout_seconds: 0,
..Default::default()
})
.await
.unwrap();
let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
// 启用自动故障转移
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
config.auto_failover_enabled = true;
db.update_proxy_config_for_app(config).await.unwrap();
let router = ProviderRouter::new(db.clone());
// 触发熔断:1 次失败
router
.record_result("a", "claude", false, false, Some("fail".to_string()))
.await
.unwrap();
// 第一次请求:获取 HalfOpen 探测名额
let first = router.allow_provider_request("a", "claude").await;
assert!(first.allowed);
assert!(first.used_half_open_permit);
// 第二次请求应被拒绝(名额已被占用)
let second = router.allow_provider_request("a", "claude").await;
assert!(!second.allowed);
// 使用 release_permit_neutral 释放名额(不影响健康统计)
router
.release_permit_neutral("a", "claude", first.used_half_open_permit)
.await;
// 第三次请求应被允许(名额已释放)
let third = router.allow_provider_request("a", "claude").await;
assert!(third.allowed);
assert!(third.used_half_open_permit);
}
}
+421
View File
@@ -0,0 +1,421 @@
//! Thinking Signature 整流器
//!
//! 用于自动修复 Anthropic API 中因签名校验失败导致的请求错误。
//! 当上游 API 返回签名相关错误时,系统会自动移除有问题的签名字段并重试请求。
use super::types::RectifierConfig;
use serde_json::Value;
/// 整流结果
#[derive(Debug, Clone, Default)]
pub struct RectifyResult {
/// 是否应用了整流
pub applied: bool,
/// 移除的 thinking block 数量
pub removed_thinking_blocks: usize,
/// 移除的 redacted_thinking block 数量
pub removed_redacted_thinking_blocks: usize,
/// 移除的 signature 字段数量
pub removed_signature_fields: usize,
}
/// 检测是否需要触发 thinking 签名整流器
///
/// 返回 `true` 表示需要触发整流器,`false` 表示不需要。
/// 会检查配置开关。
pub fn should_rectify_thinking_signature(
error_message: Option<&str>,
config: &RectifierConfig,
) -> bool {
// 检查总开关
if !config.enabled {
return false;
}
// 检查子开关
if !config.request_thinking_signature {
return false;
}
// 检测错误类型
let Some(msg) = error_message else {
return false;
};
let lower = msg.to_lowercase();
// 场景1: thinking block 中的签名无效
// 错误示例: "Invalid 'signature' in 'thinking' block"
if lower.contains("invalid")
&& lower.contains("signature")
&& lower.contains("thinking")
&& lower.contains("block")
{
return true;
}
// 场景2: assistant 消息必须以 thinking block 开头
// 错误示例: "must start with a thinking block"
if lower.contains("must start with a thinking block") {
return true;
}
// 场景3: expected thinking or redacted_thinking, found tool_use
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
if lower.contains("expected")
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
&& lower.contains("found")
{
return true;
}
// 场景4: signature 字段必需但缺失
// 错误示例: "signature: Field required"
if lower.contains("signature") && lower.contains("field required") {
return true;
}
false
}
/// 对 Anthropic 请求体做最小侵入整流
///
/// - 移除 messages[*].content 中的 thinking/redacted_thinking block
/// - 移除非 thinking block 上遗留的 signature 字段
/// - 特定条件下删除顶层 thinking 字段
///
/// 注意:该函数会原地修改 body 对象
pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
let mut result = RectifyResult::default();
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
Some(m) => m,
None => return result,
};
// 遍历所有消息
for msg in messages.iter_mut() {
let content = match msg.get_mut("content").and_then(|c| c.as_array_mut()) {
Some(c) => c,
None => continue,
};
let mut new_content = Vec::with_capacity(content.len());
let mut content_modified = false;
for block in content.iter() {
let block_type = block.get("type").and_then(|t| t.as_str());
match block_type {
Some("thinking") => {
result.removed_thinking_blocks += 1;
content_modified = true;
continue;
}
Some("redacted_thinking") => {
result.removed_redacted_thinking_blocks += 1;
content_modified = true;
continue;
}
_ => {}
}
// 移除非 thinking block 上的 signature 字段
if block.get("signature").is_some() {
let mut block_clone = block.clone();
if let Some(obj) = block_clone.as_object_mut() {
obj.remove("signature");
result.removed_signature_fields += 1;
content_modified = true;
new_content.push(Value::Object(obj.clone()));
continue;
}
}
new_content.push(block.clone());
}
if content_modified {
result.applied = true;
*content = new_content;
}
}
// 兜底处理:thinking 启用 + 工具调用链路中最后一条 assistant 消息未以 thinking 开头
let messages_snapshot: Vec<Value> = body
.get("messages")
.and_then(|m| m.as_array())
.map(|a| a.to_vec())
.unwrap_or_default();
if should_remove_top_level_thinking(body, &messages_snapshot) {
if let Some(obj) = body.as_object_mut() {
obj.remove("thinking");
result.applied = true;
}
}
result
}
/// 判断是否需要删除顶层 thinking 字段
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
// 检查 thinking 是否启用
let thinking_enabled = body
.get("thinking")
.and_then(|t| t.get("type"))
.and_then(|t| t.as_str())
== Some("enabled");
if !thinking_enabled {
return false;
}
// 找到最后一条 assistant 消息
let last_assistant = messages
.iter()
.rev()
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"));
let last_assistant_content = match last_assistant
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
{
Some(c) if !c.is_empty() => c,
_ => return false,
};
// 检查首块是否为 thinking/redacted_thinking
let first_block_type = last_assistant_content
.first()
.and_then(|b| b.get("type"))
.and_then(|t| t.as_str());
let missing_thinking_prefix =
first_block_type != Some("thinking") && first_block_type != Some("redacted_thinking");
if !missing_thinking_prefix {
return false;
}
// 检查是否存在 tool_use
last_assistant_content
.iter()
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn enabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
}
}
fn disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: false,
}
}
fn master_disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: false,
request_thinking_signature: true,
}
}
// ==================== should_rectify_thinking_signature 测试 ====================
#[test]
fn test_detect_invalid_signature() {
assert!(should_rectify_thinking_signature(
Some("messages.1.content.0: Invalid `signature` in `thinking` block"),
&enabled_config()
));
}
#[test]
fn test_detect_invalid_signature_no_backticks() {
assert!(should_rectify_thinking_signature(
Some("Messages.1.Content.0: invalid signature in thinking block"),
&enabled_config()
));
}
#[test]
fn test_detect_invalid_signature_nested_json() {
// 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式)
let nested_error = r#"{"error":{"message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_xxx\"}"}}"#;
assert!(should_rectify_thinking_signature(
Some(nested_error),
&enabled_config()
));
}
#[test]
fn test_detect_thinking_expected() {
assert!(should_rectify_thinking_signature(
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `tool_use`."),
&enabled_config()
));
}
#[test]
fn test_detect_must_start_with_thinking() {
assert!(should_rectify_thinking_signature(
Some("a final `assistant` message must start with a thinking block"),
&enabled_config()
));
}
#[test]
fn test_no_trigger_for_unrelated_error() {
assert!(!should_rectify_thinking_signature(
Some("Request timeout"),
&enabled_config()
));
assert!(!should_rectify_thinking_signature(
Some("Connection refused"),
&enabled_config()
));
assert!(!should_rectify_thinking_signature(None, &enabled_config()));
}
#[test]
fn test_detect_signature_field_required() {
// 场景4: signature 字段缺失
assert!(should_rectify_thinking_signature(
Some("***.***.***.***.***.signature: Field required"),
&enabled_config()
));
// 嵌套 JSON 格式
let nested_error = r#"{"error":{"type":"<nil>","message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.***.***.***.***.signature: Field required\"},\"request_id\":\"req_xxx\"}"}}"#;
assert!(should_rectify_thinking_signature(
Some(nested_error),
&enabled_config()
));
}
#[test]
fn test_disabled_config() {
// 即使错误匹配,配置关闭时也不触发
assert!(!should_rectify_thinking_signature(
Some("Invalid `signature` in `thinking` block"),
&disabled_config()
));
}
#[test]
fn test_master_disabled() {
// 总开关关闭时,即使子开关开启也不触发
assert!(!should_rectify_thinking_signature(
Some("Invalid `signature` in `thinking` block"),
&master_disabled_config()
));
}
// ==================== rectify_anthropic_request 测试 ====================
#[test]
fn test_rectify_removes_thinking_blocks() {
let mut body = json!({
"model": "claude-test",
"messages": [{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "t", "signature": "sig" },
{ "type": "text", "text": "hello", "signature": "sig_text" },
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {}, "signature": "sig_tool" },
{ "type": "redacted_thinking", "data": "r", "signature": "sig_redacted" }
]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(result.applied);
assert_eq!(result.removed_thinking_blocks, 1);
assert_eq!(result.removed_redacted_thinking_blocks, 1);
assert_eq!(result.removed_signature_fields, 2);
let content = body["messages"][0]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert!(content[0].get("signature").is_none());
assert_eq!(content[1]["type"], "tool_use");
assert!(content[1].get("signature").is_none());
}
#[test]
fn test_rectify_removes_top_level_thinking() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 1024 },
"messages": [{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
]
}, {
"role": "user",
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(result.applied);
assert!(body.get("thinking").is_none());
}
#[test]
fn test_rectify_no_change_when_no_issues() {
let mut body = json!({
"model": "claude-test",
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(result.removed_thinking_blocks, 0);
}
#[test]
fn test_rectify_no_messages() {
let mut body = json!({ "model": "claude-test" });
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
}
#[test]
fn test_rectify_preserves_thinking_when_prefix_exists() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled" },
"messages": [{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "some thought" },
{ "type": "tool_use", "id": "toolu_1", "name": "Test", "input": {} }
]
}]
});
let result = rectify_anthropic_request(&mut body);
// thinking block 被移除,但顶层 thinking 不应被移除(因为原本有 thinking 前缀)
assert!(result.applied);
assert_eq!(result.removed_thinking_blocks, 1);
// 注意:由于 thinking block 被移除后,首块变成了 tool_use,
// 此时会触发删除顶层 thinking 的逻辑
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
}
}
+64
View File
@@ -191,3 +191,67 @@ pub struct AppProxyConfig {
/// 计算错误率的最小请求数
pub circuit_min_requests: u32,
}
/// 整流器配置
///
/// 存储在 settings 表中
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RectifierConfig {
/// 总开关:是否启用整流器
#[serde(default = "default_true")]
pub enabled: bool,
/// 请求整流:启用 thinking 签名整流器
///
/// 处理错误:Invalid 'signature' in 'thinking' block
#[serde(default = "default_true")]
pub request_thinking_signature: bool,
}
impl Default for RectifierConfig {
fn default() -> Self {
Self {
enabled: true,
request_thinking_signature: true,
}
}
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rectifier_config_default_enabled() {
// 验证 RectifierConfig::default() 返回全启用状态
// 防止回归:#[derive(Default)] 会使 bool 默认为 false
let config = RectifierConfig::default();
assert!(config.enabled, "整流器总开关默认应为 true");
assert!(
config.request_thinking_signature,
"thinking 签名整流器默认应为 true"
);
}
#[test]
fn test_rectifier_config_serde_default() {
// 验证反序列化缺字段时使用 default_true
let json = "{}";
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert!(config.request_thinking_signature);
}
#[test]
fn test_rectifier_config_serde_explicit_false() {
// 验证显式设置 false 时正确反序列化
let json = r#"{"enabled": false, "requestThinkingSignature": false}"#;
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(!config.enabled);
assert!(!config.request_thinking_signature);
}
}
+1 -1
View File
@@ -152,7 +152,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// Skill sync
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
log::warn!("同步 Skill 到 {:?} 失败: {}", app_type, e);
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
// Continue syncing other apps, don't abort
}
}
+2
View File
@@ -615,6 +615,7 @@ impl ProviderService {
base_url: Option<&str>,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
usage::test_usage_script(
state,
@@ -626,6 +627,7 @@ impl ProviderService {
base_url,
access_token,
user_id,
template_type,
)
.await
}
+7 -1
View File
@@ -17,6 +17,7 @@ pub(crate) async fn execute_and_format_usage_result(
timeout: u64,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
match usage_script::execute_usage_script(
script_code,
@@ -25,6 +26,7 @@ pub(crate) async fn execute_and_format_usage_result(
timeout,
access_token,
user_id,
template_type,
)
.await
{
@@ -113,7 +115,7 @@ pub async fn query_usage(
app_type: AppType,
provider_id: &str,
) -> Result<UsageResult, AppError> {
let (script_code, timeout, api_key, base_url, access_token, user_id) = {
let (script_code, timeout, api_key, base_url, access_token, user_id, template_type) = {
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers.get(provider_id).ok_or_else(|| {
AppError::localized(
@@ -164,6 +166,7 @@ pub async fn query_usage(
base_url,
usage_script.access_token.clone(),
usage_script.user_id.clone(),
usage_script.template_type.clone(),
)
};
@@ -174,6 +177,7 @@ pub async fn query_usage(
timeout,
access_token.as_deref(),
user_id.as_deref(),
template_type.as_deref(),
)
.await
}
@@ -190,6 +194,7 @@ pub async fn test_usage_script(
base_url: Option<&str>,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
// Use provided credential parameters directly for testing
execute_and_format_usage_result(
@@ -199,6 +204,7 @@ pub async fn test_usage_script(
timeout,
access_token,
user_id,
template_type,
)
.await
}
+4 -12
View File
@@ -7,7 +7,6 @@
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
@@ -143,9 +142,7 @@ pub struct SkillMetadata {
// ========== SkillService ==========
pub struct SkillService {
http_client: Client,
}
pub struct SkillService;
impl Default for SkillService {
fn default() -> Self {
@@ -155,13 +152,7 @@ impl Default for SkillService {
impl SkillService {
pub fn new() -> Self {
Self {
http_client: Client::builder()
.user_agent("cc-switch")
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
}
Self
}
// ========== 路径管理 ==========
@@ -863,7 +854,8 @@ impl SkillService {
/// 下载并解压 ZIP
async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> {
let response = self.http_client.get(url).send().await?;
let client = crate::proxy::http_client::get();
let response = client.get(url).send().await?;
if !response.status().is_success() {
let status = response.status().as_u16().to_string();
return Err(anyhow::anyhow!(format_skill_error(
+13 -17
View File
@@ -1,7 +1,7 @@
use futures::future::join_all;
use reqwest::{Client, Url};
use serde::Serialize;
use std::time::{Duration, Instant};
use std::time::Instant;
use crate::error::AppError;
@@ -65,17 +65,21 @@ impl SpeedtestService {
}
let timeout = Self::sanitize_timeout(timeout_secs);
let client = Self::build_client(timeout)?;
let (client, request_timeout) = Self::build_client(timeout)?;
let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
let client = client.clone();
async move {
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
let _ = client.get(parsed_url.clone()).send().await;
let _ = client
.get(parsed_url.clone())
.timeout(request_timeout)
.send()
.await;
// 第二次请求开始计时,并将其作为结果返回。
let start = Instant::now();
let latency = match client.get(parsed_url).send().await {
let latency = match client.get(parsed_url).timeout(request_timeout).send().await {
Ok(resp) => EndpointLatency {
url: trimmed,
latency: Some(start.elapsed().as_millis()),
@@ -112,19 +116,11 @@ impl SpeedtestService {
Ok(results.into_iter().flatten().collect::<Vec<_>>())
}
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.redirect(reqwest::redirect::Policy::limited(5))
.user_agent("cc-switch-speedtest/1.0")
.build()
.map_err(|e| {
AppError::localized(
"speedtest.client_create_failed",
format!("创建 HTTP 客户端失败: {e}"),
format!("Failed to create HTTP client: {e}"),
)
})
fn build_client(timeout_secs: u64) -> Result<(Client, std::time::Duration), AppError> {
// 使用全局 HTTP 客户端(已包含代理配置)
// 返回 timeout Duration 供请求级别使用
let timeout = std::time::Duration::from_secs(timeout_secs);
Ok((crate::proxy::http_client::get(), timeout))
}
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
+177 -46
View File
@@ -7,7 +7,7 @@ use regex::Regex;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::time::{Duration, Instant};
use std::time::Instant;
use crate::app_config::AppType;
use crate::error::AppError;
@@ -36,6 +36,13 @@ pub struct StreamCheckConfig {
pub codex_model: String,
/// Gemini 测试模型
pub gemini_model: String,
/// 检查提示词
#[serde(default = "default_test_prompt")]
pub test_prompt: String,
}
fn default_test_prompt() -> String {
"Who are you?".to_string()
}
impl Default for StreamCheckConfig {
@@ -47,6 +54,7 @@ impl Default for StreamCheckConfig {
claude_model: "claude-haiku-4-5-20251001".to_string(),
codex_model: "gpt-5.1-codex@low".to_string(),
gemini_model: "gemini-3-pro-preview".to_string(),
test_prompt: default_test_prompt(),
}
}
}
@@ -110,7 +118,7 @@ impl StreamCheckService {
Ok(last_result.unwrap_or_else(|| StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: "检查失败".to_string(),
message: "Check failed".to_string(),
response_time_ms: None,
http_status: None,
model_used: String::new(),
@@ -130,29 +138,52 @@ impl StreamCheckService {
let base_url = adapter
.extract_base_url(provider)
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?;
let auth = adapter
.extract_auth(provider)
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.user_agent("cc-switch/1.0")
.build()
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
// 使用全局 HTTP 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
let model_to_test = Self::resolve_test_model(app_type, provider, config);
let test_prompt = &config.test_prompt;
let result = match app_type {
AppType::Claude => {
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
Self::check_claude_stream(
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
AppType::Codex => {
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
Self::check_codex_stream(
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
AppType::Gemini => {
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
Self::check_gemini_stream(
&client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
};
@@ -166,7 +197,7 @@ impl StreamCheckService {
Ok(StreamCheckResult {
status: health_status,
success: true,
message: "检查成功".to_string(),
message: "Check succeeded".to_string(),
response_time_ms: Some(response_time),
http_status: Some(status_code),
model_used: model,
@@ -188,31 +219,69 @@ impl StreamCheckService {
}
/// Claude 流式检查
///
/// 严格按照 Claude CLI 真实请求格式构建请求
async fn check_claude_stream(
client: &Client,
base_url: &str,
auth: &AuthInfo,
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
// URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源)
let url = if base.ends_with("/v1") {
format!("{base}/messages")
format!("{base}/messages?beta=true")
} else {
format!("{base}/v1/messages")
format!("{base}/v1/messages?beta=true")
};
let body = json!({
"model": model,
"max_tokens": 1,
"messages": [{ "role": "user", "content": "hi" }],
"messages": [{ "role": "user", "content": test_prompt }],
"stream": true
});
// 获取本地系统信息
let os_name = Self::get_os_name();
let arch_name = Self::get_arch_name();
// 严格按照 Claude CLI 请求格式设置 headers
let response = client
.post(&url)
// 认证 headers(双重认证)
.header("authorization", format!("Bearer {}", auth.api_key))
.header("x-api-key", &auth.api_key)
// Anthropic 必需 headers
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.header(
"anthropic-beta",
"claude-code-20250219,interleaved-thinking-2025-05-14",
)
.header("anthropic-dangerous-direct-browser-access", "true")
// 内容类型 headers
.header("content-type", "application/json")
.header("accept", "application/json")
.header("accept-encoding", "identity")
.header("accept-language", "*")
// 客户端标识 headers
.header("user-agent", "claude-cli/2.1.2 (external, cli)")
.header("x-app", "cli")
// x-stainless SDK headers(动态获取本地系统信息)
.header("x-stainless-lang", "js")
.header("x-stainless-package-version", "0.70.0")
.header("x-stainless-os", os_name)
.header("x-stainless-arch", arch_name)
.header("x-stainless-runtime", "node")
.header("x-stainless-runtime-version", "v22.20.0")
.header("x-stainless-retry-count", "0")
.header("x-stainless-timeout", "600")
// 其他 headers
.header("sec-fetch-mode", "cors")
.header("connection", "keep-alive")
.timeout(timeout)
.json(&body)
.send()
.await
@@ -230,51 +299,64 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
}
} else {
Err(AppError::Message("未收到响应数据".to_string()))
Err(AppError::Message("No response data received".to_string()))
}
}
/// Codex 流式检查
///
/// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API)
async fn check_codex_stream(
client: &Client,
base_url: &str,
auth: &AuthInfo,
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
let url = if base.ends_with("/v1") {
format!("{base}/chat/completions")
format!("{base}/responses")
} else {
format!("{base}/v1/chat/completions")
format!("{base}/v1/responses")
};
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
// 获取本地系统信息
let os_name = Self::get_os_name();
let arch_name = Self::get_arch_name();
// Responses API 请求体格式 (input 必须是数组)
let mut body = json!({
"model": actual_model,
"messages": [
{ "role": "system", "content": "" },
{ "role": "assistant", "content": "" },
{ "role": "user", "content": "hi" }
],
"max_tokens": 1,
"temperature": 0,
"input": [{ "role": "user", "content": test_prompt }],
"stream": true
});
// 如果是推理模型,添加 reasoning_effort
if let Some(effort) = reasoning_effort {
body["reasoning_effort"] = json!(effort);
body["reasoning"] = json!({ "effort": effort });
}
// 严格按照 Codex CLI 请求格式设置 headers
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json")
.header("authorization", format!("Bearer {}", auth.api_key))
.header("content-type", "application/json")
.header("accept", "text/event-stream")
.header("accept-encoding", "identity")
.header(
"user-agent",
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
)
.header("originator", "codex_cli_rs")
.timeout(timeout)
.json(&body)
.send()
.await
@@ -291,10 +373,10 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
}
} else {
Err(AppError::Message("未收到响应数据".to_string()))
Err(AppError::Message("No response data received".to_string()))
}
}
@@ -304,13 +386,15 @@ impl StreamCheckService {
base_url: &str,
auth: &AuthInfo,
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
let url = format!("{base}/v1/chat/completions");
let body = json!({
"model": model,
"messages": [{ "role": "user", "content": "hi" }],
"messages": [{ "role": "user", "content": test_prompt }],
"max_tokens": 1,
"temperature": 0,
"stream": true
@@ -320,6 +404,7 @@ impl StreamCheckService {
.post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json")
.timeout(timeout)
.json(&body)
.send()
.await
@@ -336,10 +421,10 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await {
match chunk {
Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
}
} else {
Err(AppError::Message("未收到响应数据".to_string()))
Err(AppError::Message("No response data received".to_string()))
}
}
@@ -354,7 +439,6 @@ impl StreamCheckService {
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
/// 返回 (实际模型名, Option<推理等级>)
fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
// 查找 @ 或 # 分隔符
if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
let actual_model = model[..pos].to_string();
let effort = model[pos + 1..].to_string();
@@ -367,17 +451,14 @@ impl StreamCheckService {
fn should_retry(msg: &str) -> bool {
let lower = msg.to_lowercase();
lower.contains("timeout")
|| lower.contains("abort")
|| lower.contains("中断")
|| lower.contains("超时")
lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out")
}
fn map_request_error(e: reqwest::Error) -> AppError {
if e.is_timeout() {
AppError::Message("请求超时".to_string())
AppError::Message("Request timeout".to_string())
} else if e.is_connect() {
AppError::Message(format!("连接失败: {e}"))
AppError::Message(format!("Connection failed: {e}"))
} else {
AppError::Message(e.to_string())
}
@@ -424,6 +505,26 @@ impl StreamCheckService {
.map(|m| m.as_str().trim().to_string())
.filter(|value| !value.is_empty())
}
/// 获取操作系统名称(映射为 Claude CLI 使用的格式)
fn get_os_name() -> &'static str {
match std::env::consts::OS {
"macos" => "MacOS",
"linux" => "Linux",
"windows" => "Windows",
other => other,
}
}
/// 获取 CPU 架构名称(映射为 Claude CLI 使用的格式)
fn get_arch_name() -> &'static str {
match std::env::consts::ARCH {
"aarch64" => "arm64",
"x86_64" => "x86_64",
"x86" => "x86",
other => other,
}
}
}
#[cfg(test)]
@@ -448,9 +549,10 @@ mod tests {
#[test]
fn test_should_retry() {
assert!(StreamCheckService::should_retry("请求超时"));
assert!(StreamCheckService::should_retry("request timeout"));
assert!(!StreamCheckService::should_retry("API Key 无效"));
assert!(StreamCheckService::should_retry("Request timeout"));
assert!(StreamCheckService::should_retry("request timed out"));
assert!(StreamCheckService::should_retry("connection abort"));
assert!(!StreamCheckService::should_retry("API Key invalid"));
}
#[test]
@@ -478,4 +580,33 @@ mod tests {
assert_eq!(model, "gpt-4o-mini");
assert_eq!(effort, None);
}
#[test]
fn test_get_os_name() {
let os_name = StreamCheckService::get_os_name();
// 确保返回非空字符串
assert!(!os_name.is_empty());
// 在 macOS 上应该返回 "MacOS"
#[cfg(target_os = "macos")]
assert_eq!(os_name, "MacOS");
// 在 Linux 上应该返回 "Linux"
#[cfg(target_os = "linux")]
assert_eq!(os_name, "Linux");
// 在 Windows 上应该返回 "Windows"
#[cfg(target_os = "windows")]
assert_eq!(os_name, "Windows");
}
#[test]
fn test_get_arch_name() {
let arch_name = StreamCheckService::get_arch_name();
// 确保返回非空字符串
assert!(!arch_name.is_empty());
// 在 ARM64 上应该返回 "arm64"
#[cfg(target_arch = "aarch64")]
assert_eq!(arch_name, "arm64");
// 在 x86_64 上应该返回 "x86_64"
#[cfg(target_arch = "x86_64")]
assert_eq!(arch_name, "x86_64");
}
}
+99 -77
View File
@@ -1,8 +1,6 @@
use reqwest::Client;
use rquickjs::{Context, Function, Runtime};
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
use url::{Host, Url};
use crate::error::AppError;
@@ -15,13 +13,21 @@ pub async fn execute_usage_script(
timeout_secs: u64,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<Value, AppError> {
// 检测是否为自定义模板模式
// 优先使用前端传递的 template_type
let is_custom_template = template_type.map(|t| t == "custom").unwrap_or(false);
// 1. 替换模板变量,避免泄露敏感信息
let script_with_vars =
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
// 2. 验证 base_url 的安全性
validate_base_url(base_url)?;
// 2. 验证 base_url 的安全性(仅当提供了 base_url 时)
// 自定义模板模式下,用户可能不使用模板变量,而是直接在脚本中写完整 URL
if !base_url.is_empty() {
validate_base_url(base_url)?;
}
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
let request_config = {
@@ -99,7 +105,8 @@ pub async fn execute_usage_script(
})?;
// 5. 验证请求 URL 是否安全(防止 SSRF)
validate_request_url(&request.url, base_url)?;
// 如果提供了 base_url,则验证同源;否则只做基本安全检查
validate_request_url(&request.url, base_url, is_custom_template)?;
// 6. 发送 HTTP 请求
let response_data = send_http_request(&request, timeout_secs).await?;
@@ -215,18 +222,10 @@ struct RequestConfig {
/// 发送 HTTP 请求
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, AppError> {
// 约束超时范围,防止异常配置导致长时间阻塞
let timeout = timeout_secs.clamp(2, 30);
let client = Client::builder()
.timeout(Duration::from_secs(timeout))
.build()
.map_err(|e| {
AppError::localized(
"usage_script.client_create_failed",
format!("创建客户端失败: {e}"),
format!("Failed to create client: {e}"),
)
})?;
// 使用全局 HTTP 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
// 约束超时范围,防止异常配置导致长时间阻塞(最小 2 秒,最大 30 秒)
let request_timeout = std::time::Duration::from_secs(timeout_secs.clamp(2, 30));
// 严格校验 HTTP 方法,非法值不回退为 GET
let method: reqwest::Method = config.method.parse().map_err(|_| {
@@ -237,7 +236,9 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<
)
})?;
let mut req = client.request(method.clone(), &config.url);
let mut req = client
.request(method.clone(), &config.url)
.timeout(request_timeout);
// 添加请求头
for (k, v) in &config.headers {
@@ -480,7 +481,11 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> {
}
/// 验证请求 URL 是否安全(防止 SSRF)
fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppError> {
fn validate_request_url(
request_url: &str,
base_url: &str,
is_custom_template: bool,
) -> Result<(), AppError> {
// 解析请求 URL
let parsed_request = Url::parse(request_url).map_err(|e| {
AppError::localized(
@@ -490,19 +495,11 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro
)
})?;
// 解析 base URL
let parsed_base = Url::parse(base_url).map_err(|e| {
AppError::localized(
"usage_script.base_url_invalid",
format!("无效的 base_url: {e}"),
format!("Invalid base_url: {e}"),
)
})?;
let is_request_loopback = is_loopback_host(&parsed_request);
// 必须使用 HTTPS(允许 localhost 用于开发)
if parsed_request.scheme() != "https" && !is_request_loopback {
// 自定义模板模式下,允许用户自行决定是否使用 HTTP(用户需自行承担安全风险)
if !is_custom_template && parsed_request.scheme() != "https" && !is_request_loopback {
return Err(AppError::localized(
"usage_script.request_https_required",
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
@@ -510,60 +507,85 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro
));
}
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
if parsed_request.host_str() != parsed_base.host_str() {
return Err(AppError::localized(
"usage_script.request_host_mismatch",
format!(
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
format!(
"Request host {} must match base_url host {} (same-origin required)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
));
}
// 如果提供了 base_url(非空),则进行同源检查
// 🔧 自定义模板模式下,用户可以自由访问任意 HTTPS 域名,跳过同源检查
if !base_url.is_empty() && !is_custom_template {
// 解析 base URL
let parsed_base = Url::parse(base_url).map_err(|e| {
AppError::localized(
"usage_script.base_url_invalid",
format!("无效的 base_url: {e}"),
format!("Invalid base_url: {e}"),
)
})?;
// 检查端口是否匹配(考虑默认端口)
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443
match (
parsed_request.port_or_known_default(),
parsed_base.port_or_known_default(),
) {
(Some(request_port), Some(base_port)) if request_port == base_port => {
// 端口匹配,继续执行
}
(Some(request_port), Some(base_port)) => {
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
if parsed_request.host_str() != parsed_base.host_str() {
return Err(AppError::localized(
"usage_script.request_port_mismatch",
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
format!("Request port {request_port} must match base_url port {base_port}"),
"usage_script.request_host_mismatch",
format!(
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
format!(
"Request host {} must match base_url host {} (same-origin required)",
parsed_request.host_str().unwrap_or("unknown"),
parsed_base.host_str().unwrap_or("unknown")
),
));
}
_ => {
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
return Err(AppError::localized(
"usage_script.request_port_unknown",
"无法确定端口号",
"Unable to determine port number",
));
// 检查端口是否匹配(考虑默认端口)
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443
match (
parsed_request.port_or_known_default(),
parsed_base.port_or_known_default(),
) {
(Some(request_port), Some(base_port)) if request_port == base_port => {
// 端口匹配,继续执行
}
(Some(request_port), Some(base_port)) => {
return Err(AppError::localized(
"usage_script.request_port_mismatch",
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
format!("Request port {request_port} must match base_url port {base_port}"),
));
}
_ => {
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
return Err(AppError::localized(
"usage_script.request_port_unknown",
"无法确定端口号",
"Unable to determine port number",
));
}
}
}
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
if let Some(host) = parsed_request.host_str() {
let base_host = parsed_base.host_str().unwrap_or("");
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
if let Some(host) = parsed_request.host_str() {
let base_host = parsed_base.host_str().unwrap_or("");
// 如果 base_url 不是私有地址,则禁止访问私有IP
if !is_private_ip(base_host) && is_private_ip(host) {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址",
"Access to private IP addresses is blocked",
));
// 如果 base_url 不是私有地址,则禁止访问私有IP
if !is_private_ip(base_host) && is_private_ip(host) {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址",
"Access to private IP addresses is blocked",
));
}
}
} else {
// 自定义模板模式:没有 base_url,需要额外的安全检查
// 禁止访问私有 IP 地址(SSRF 防护)
if let Some(host) = parsed_request.host_str() {
if is_private_ip(host) && !is_request_loopback {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址(localhost 除外)",
"Access to private IP addresses is blocked (localhost allowed)",
));
}
}
}
@@ -851,7 +873,7 @@ mod tests {
];
for (base_url, request_url, should_match) in test_cases {
let result = validate_request_url(request_url, base_url);
let result = validate_request_url(request_url, base_url, false);
if should_match {
assert!(
+39 -15
View File
@@ -382,6 +382,26 @@ function App() {
await addProvider(duplicatedProvider);
};
// 打开提供商终端
const handleOpenTerminal = async (provider: Provider) => {
try {
await providersApi.openTerminal(provider.id, activeApp);
toast.success(
t("provider.terminalOpened", {
defaultValue: "终端已打开",
}),
);
} catch (error) {
console.error("[App] Failed to open terminal", error);
const errorMessage = extractErrorMessage(error);
toast.error(
t("provider.terminalOpenFailed", {
defaultValue: "打开终端失败",
}) + (errorMessage ? `: ${errorMessage}` : ""),
);
}
};
// 导入配置成功后刷新
const handleImportSuccess = async () => {
try {
@@ -482,6 +502,9 @@ function App() {
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onOpenTerminal={
activeApp === "claude" ? handleOpenTerminal : undefined
}
onCreate={() => setIsAddOpen(true)}
/>
</motion.div>
@@ -596,8 +619,8 @@ function App() {
</h1>
</div>
) : (
<>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<div className="relative inline-flex items-center">
<a
href="https://github.com/farion1231/cc-switch"
target="_blank"
@@ -611,26 +634,27 @@ function App() {
>
CC Switch
</a>
<Button
variant="ghost"
size="icon"
<UpdateBadge
onClick={() => {
setSettingsDefaultTab("general");
setSettingsDefaultTab("about");
setCurrentView("settings");
}}
title={t("common.settings")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="w-4 h-4" />
</Button>
className="absolute -top-4 -right-4"
/>
</div>
<UpdateBadge
<Button
variant="ghost"
size="icon"
onClick={() => {
setSettingsDefaultTab("about");
setSettingsDefaultTab("general");
setCurrentView("settings");
}}
/>
</>
title={t("common.settings")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="w-4 h-4" />
</Button>
</div>
)}
</div>
+19 -4
View File
@@ -389,12 +389,27 @@ export function DeepLinkImportDialog() {
</div>
{/* API Endpoint */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
<div className="grid grid-cols-3 items-start gap-4">
<div className="font-medium text-sm text-muted-foreground pt-0.5">
{t("deeplink.endpoint")}
</div>
<div className="col-span-2 text-sm break-all">
{request.endpoint}
<div className="col-span-2 text-sm break-all space-y-1">
{request.endpoint?.split(",").map((ep, idx) => (
<div
key={idx}
className={
idx === 0 ? "font-medium" : "text-muted-foreground"
}
>
{idx === 0 ? "🔹 " : "└ "}
{ep.trim()}
{idx === 0 && request.endpoint?.includes(",") && (
<span className="text-xs text-muted-foreground ml-2">
({t("deeplink.primaryEndpoint")})
</span>
)}
</div>
))}
</div>
</div>
+25 -42
View File
@@ -1,6 +1,6 @@
import { X, Download } from "lucide-react";
import { useUpdate } from "@/contexts/UpdateContext";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
interface UpdateBadgeProps {
className?: string;
@@ -8,56 +8,39 @@ interface UpdateBadgeProps {
}
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate();
const { hasUpdate, updateInfo } = useUpdate();
const { t } = useTranslation();
const isActive = hasUpdate && updateInfo;
const title = isActive
? t("settings.updateAvailable", {
version: updateInfo?.availableVersion ?? "",
})
: t("settings.checkForUpdates");
// 如果没有更新或已关闭,不显示
if (!hasUpdate || isDismissed || !updateInfo) {
if (!isActive) {
return null;
}
return (
<div
<Button
type="button"
variant="ghost"
size="icon"
title={title}
aria-label={title}
onClick={onClick}
className={`
flex items-center gap-1.5 px-2.5 py-1
bg-white dark:bg-gray-800
border border-border-default
rounded-lg text-xs
shadow-sm
transition-all duration-200
${onClick ? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750" : ""}
relative h-6 w-6 rounded-full
${isActive ? "text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-500/10" : "text-muted-foreground hover:bg-muted/60"}
${className}
`}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : -1}
onClick={onClick}
onKeyDown={(e) => {
if (!onClick) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
>
<Download className="w-3 h-3 text-blue-500 dark:text-blue-400" />
<span className="text-gray-700 dark:text-gray-300 font-medium">
{t("settings.updateBadge")}
</span>
<button
onClick={(e) => {
e.stopPropagation();
dismissUpdate();
}}
className="
ml-1 -mr-0.5 p-0.5 rounded
hover:bg-gray-100 dark:hover:bg-gray-700
transition-colors
focus:outline-none focus:ring-2 focus:ring-blue-500/20
"
aria-label={t("common.close")}
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
</div>
<span
className={`
absolute inset-0 m-auto h-2 w-2 rounded-full ring-1 ring-background
${isActive ? "bg-blue-500 dark:bg-blue-400" : "bg-blue-300/70 dark:bg-blue-300/60"}
`}
/>
</Button>
);
}
+154 -12
View File
@@ -2,8 +2,10 @@ import React, { useState } from "react";
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Provider, UsageScript, UsageData } from "@/types";
import { usageApi, type AppId } from "@/lib/api";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
import * as parserBabel from "prettier/parser-babel";
@@ -109,19 +111,67 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onSave,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// 生成带国际化的预设模板
const PRESET_TEMPLATES = generatePresetTemplates(t);
const [script, setScript] = useState<UsageScript>(() => {
return (
provider.meta?.usage_script || {
enabled: false,
language: "javascript",
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
timeout: 10,
// 从 provider 的 settingsConfig 中提取 API Key 和 Base URL
const getProviderCredentials = (): {
apiKey: string | undefined;
baseUrl: string | undefined;
} => {
try {
const config = provider.settingsConfig;
if (!config) return { apiKey: undefined, baseUrl: undefined };
// 处理不同应用的配置格式
if (appId === "claude") {
// Claude: { env: { ANTHROPIC_AUTH_TOKEN | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL } }
const env = (config as any).env || {};
return {
apiKey: env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY,
baseUrl: env.ANTHROPIC_BASE_URL,
};
} else if (appId === "codex") {
// Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url }
const auth = (config as any).auth || {};
const configToml = (config as any).config || "";
return {
apiKey: auth.OPENAI_API_KEY,
baseUrl: extractCodexBaseUrl(configToml),
};
} else if (appId === "gemini") {
// Gemini: { env: { GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL } }
const env = (config as any).env || {};
return {
apiKey: env.GEMINI_API_KEY,
baseUrl: env.GOOGLE_GEMINI_BASE_URL,
};
}
);
return { apiKey: undefined, baseUrl: undefined };
} catch (error) {
console.error("Failed to extract provider credentials:", error);
return { apiKey: undefined, baseUrl: undefined };
}
};
const providerCredentials = getProviderCredentials();
const [script, setScript] = useState<UsageScript>(() => {
const savedScript = provider.meta?.usage_script;
const defaultScript = {
enabled: false,
language: "javascript" as const,
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
timeout: 10,
};
if (!savedScript) {
return defaultScript;
}
return savedScript;
});
const [testing, setTesting] = useState(false);
@@ -176,6 +226,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
() => {
const existingScript = provider.meta?.usage_script;
// 优先使用保存的 templateType
if (existingScript?.templateType) {
return existingScript.templateType;
}
// 向后兼容:根据字段推断模板类型
// 检测 NEW_API 模板(有 accessToken 或 userId
if (existingScript?.accessToken || existingScript?.userId) {
return TEMPLATE_KEYS.NEW_API;
@@ -201,7 +256,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
return;
}
onSave(script);
// 保存时记录当前选择的模板类型
const scriptWithTemplate = {
...script,
templateType: selectedTemplate as
| "custom"
| "general"
| "newapi"
| undefined,
};
onSave(scriptWithTemplate);
onClose();
};
@@ -217,6 +281,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
script.baseUrl,
script.accessToken,
script.userId,
selectedTemplate as "custom" | "general" | "newapi" | undefined,
);
if (result.success && result.data && result.data.length > 0) {
const summary = result.data
@@ -229,6 +294,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
duration: 3000,
closeButton: true,
});
// 🔧 测试成功后,更新主界面列表的用量查询缓存
queryClient.setQueryData(["usage", provider.id, appId], result);
} else {
toast.error(
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
@@ -278,9 +346,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const preset = PRESET_TEMPLATES[presetName];
if (preset) {
if (presetName === TEMPLATE_KEYS.CUSTOM) {
// 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换
// 这样可以避免同源检查导致的问题
// 如果用户想使用变量,需要手动在配置中设置 baseUrl/apiKey
setScript({
...script,
code: preset,
// 清除凭证,用户可选择手动输入或保持空
apiKey: undefined,
baseUrl: undefined,
accessToken: undefined,
@@ -401,6 +473,74 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
})}
</div>
{/* 自定义模式:变量提示和具体值 */}
{selectedTemplate === TEMPLATE_KEYS.CUSTOM && (
<div className="space-y-2 border-t border-white/10 pt-3">
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.supportedVariables")}
</h4>
<div className="space-y-1 text-xs">
{/* baseUrl */}
<div className="flex items-center gap-2 py-1">
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
{"{{baseUrl}}"}
</code>
<span className="text-muted-foreground/50">=</span>
{providerCredentials.baseUrl ? (
<code className="text-foreground/70 break-all font-mono">
{providerCredentials.baseUrl}
</code>
) : (
<span className="text-muted-foreground/50 italic">
{t("common.notSet") || "未设置"}
</span>
)}
</div>
{/* apiKey */}
<div className="flex items-center gap-2 py-1">
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
{"{{apiKey}}"}
</code>
<span className="text-muted-foreground/50">=</span>
{providerCredentials.apiKey ? (
<>
{showApiKey ? (
<code className="text-foreground/70 break-all font-mono">
{providerCredentials.apiKey}
</code>
) : (
<code className="text-foreground/70 font-mono">
</code>
)}
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="text-muted-foreground hover:text-foreground transition-colors ml-1"
aria-label={
showApiKey
? t("apiKeyInput.hide")
: t("apiKeyInput.show")
}
>
{showApiKey ? (
<EyeOff size={12} />
) : (
<Eye size={12} />
)}
</button>
</>
) : (
<span className="text-muted-foreground/50 italic">
{t("common.notSet") || "未设置"}
</span>
)}
</div>
</div>
</div>
)}
{/* 凭证配置 */}
{shouldShowCredentialsConfig && (
<div className="space-y-4">
@@ -601,11 +741,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
type="number"
min={0}
max={1440}
value={script.autoIntervalMinutes ?? 0}
value={
script.autoQueryInterval ?? script.autoIntervalMinutes ?? 0
}
onChange={(e) =>
setScript({
...script,
autoIntervalMinutes: validateAndClampInterval(
autoQueryInterval: validateAndClampInterval(
e.target.value,
),
})
@@ -613,7 +755,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onBlur={(e) =>
setScript({
...script,
autoIntervalMinutes: validateAndClampInterval(
autoQueryInterval: validateAndClampInterval(
e.target.value,
),
})
@@ -6,6 +6,7 @@ import {
Loader2,
Play,
Plus,
Terminal,
TestTube2,
Trash2,
} from "lucide-react";
@@ -23,6 +24,7 @@ interface ProviderActionsProps {
onTest?: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
onOpenTerminal?: () => void;
// 故障转移相关
isAutoFailoverEnabled?: boolean;
isInFailoverQueue?: boolean;
@@ -39,6 +41,7 @@ export function ProviderActions({
onTest,
onConfigureUsage,
onDelete,
onOpenTerminal,
// 故障转移相关
isAutoFailoverEnabled = false,
isInFailoverQueue = false,
@@ -171,6 +174,21 @@ export function ProviderActions({
<BarChart3 className="h-4 w-4" />
</Button>
{onOpenTerminal && (
<Button
size="icon"
variant="ghost"
onClick={onOpenTerminal}
title={t("provider.openTerminal", "打开终端")}
className={cn(
iconButtonClass,
"hover:text-emerald-600 dark:hover:text-emerald-400",
)}
>
<Terminal className="h-4 w-4" />
</Button>
)}
<Button
size="icon"
variant="ghost"
+39 -6
View File
@@ -1,4 +1,4 @@
import { useMemo, useState, useEffect } from "react";
import { useMemo, useState, useEffect, useRef } from "react";
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import type {
@@ -33,6 +33,7 @@ interface ProviderCardProps {
onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void;
onTest?: (provider: Provider) => void;
onOpenTerminal?: (provider: Provider) => void;
isTesting?: boolean;
isProxyRunning: boolean;
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
@@ -91,6 +92,7 @@ export function ProviderCard({
onOpenWebsite,
onDuplicate,
onTest,
onOpenTerminal,
isTesting,
isProxyRunning,
isProxyTakeover = false,
@@ -147,6 +149,10 @@ export function ProviderCard({
// 多套餐默认展开
const [isExpanded, setIsExpanded] = useState(false);
// 操作按钮容器 ref,用于动态计算宽度
const actionsRef = useRef<HTMLDivElement>(null);
const [actionsWidth, setActionsWidth] = useState(0);
// 当检测到多套餐时自动展开
useEffect(() => {
if (hasMultiplePlans) {
@@ -154,6 +160,20 @@ export function ProviderCard({
}
}, [hasMultiplePlans]);
// 动态获取操作按钮宽度
useEffect(() => {
if (actionsRef.current) {
const updateWidth = () => {
const width = actionsRef.current?.offsetWidth || 0;
setActionsWidth(width);
};
updateWidth();
// 监听窗口大小变化
window.addEventListener("resize", updateWidth);
return () => window.removeEventListener("resize", updateWidth);
}
}, [onTest, onOpenTerminal]); // 按钮数量可能变化时重新计算
const handleOpenWebsite = () => {
if (!isClickableUrl) {
return;
@@ -279,10 +299,17 @@ export function ProviderCard({
</div>
</div>
<div className="relative flex items-center ml-auto min-w-0">
<div
className="relative flex items-center ml-auto min-w-0 gap-3"
style={
{
"--actions-width": `${actionsWidth || 320}px`,
} as React.CSSProperties
}
>
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[14.5rem] group-focus-within:-translate-x-[14.5rem] sm:group-hover:-translate-x-[16rem] sm:group-focus-within:-translate-x-[16rem]">
<div className="flex items-center gap-1">
<div className="ml-auto">
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]">
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
{hasMultiplePlans ? (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
@@ -327,8 +354,11 @@ export function ProviderCard({
</div>
</div>
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入 */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
<div
ref={actionsRef}
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
>
<ProviderActions
isCurrent={isCurrent}
isTesting={isTesting}
@@ -339,6 +369,9 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
onOpenTerminal={
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
}
// 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled}
isInFailoverQueue={isInFailoverQueue}
@@ -41,6 +41,7 @@ interface ProviderListProps {
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onOpenTerminal?: (provider: Provider) => void;
onCreate?: () => void;
isLoading?: boolean;
isProxyRunning?: boolean; // 代理服务运行状态
@@ -58,6 +59,7 @@ export function ProviderList({
onDuplicate,
onConfigureUsage,
onOpenWebsite,
onOpenTerminal,
onCreate,
isLoading = false,
isProxyRunning = false,
@@ -203,6 +205,7 @@ export function ProviderList({
onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={handleTest}
isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning}
@@ -311,6 +314,7 @@ interface SortableProviderCardProps {
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onOpenTerminal?: (provider: Provider) => void;
onTest: (provider: Provider) => void;
isTesting: boolean;
isProxyRunning: boolean;
@@ -333,6 +337,7 @@ function SortableProviderCard({
onDuplicate,
onConfigureUsage,
onOpenWebsite,
onOpenTerminal,
onTest,
isTesting,
isProxyRunning,
@@ -371,6 +376,7 @@ function SortableProviderCard({
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={onTest}
isTesting={isTesting}
isProxyRunning={isProxyRunning}
@@ -36,6 +36,8 @@ interface ClaudeFormFieldsProps {
isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Model Selector
shouldShowModelSelector: boolean;
@@ -83,6 +85,8 @@ export function ClaudeFormFields({
isEndpointModalOpen,
onEndpointModalToggle,
onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
shouldShowModelSelector,
claudeModel,
reasoningModel,
@@ -170,6 +174,8 @@ export function ClaudeFormFields({
initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange}
/>
)}
@@ -25,6 +25,8 @@ interface CodexFormFieldsProps {
isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Model Name
shouldShowModelField?: boolean;
@@ -50,6 +52,8 @@ export function CodexFormFields({
isEndpointModalOpen,
onEndpointModalToggle,
onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
shouldShowModelField = true,
modelName = "",
onModelNameChange,
@@ -130,6 +134,8 @@ export function CodexFormFields({
initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange}
/>
)}
@@ -30,6 +30,8 @@ interface EndpointSpeedTestProps {
initialEndpoints: EndpointCandidate[];
visible?: boolean;
onClose: () => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目)
// 编辑模式:不使用此回调,端点直接保存到后端
onCustomEndpointsChange?: (urls: string[]) => void;
@@ -85,6 +87,8 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
initialEndpoints,
visible = true,
onClose,
autoSelect,
onAutoSelectChange,
onCustomEndpointsChange,
}) => {
const { t } = useTranslation();
@@ -93,7 +97,6 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
);
const [customUrl, setCustomUrl] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [isTesting, setIsTesting] = useState(false);
const [lastError, setLastError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
@@ -488,7 +491,9 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
<input
type="checkbox"
checked={autoSelect}
onChange={(event) => setAutoSelect(event.target.checked)}
onChange={(event) => {
onAutoSelectChange(event.target.checked);
}}
className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20"
/>
{t("endpointTest.autoSelect")}
@@ -29,6 +29,8 @@ interface GeminiFormFieldsProps {
isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Model
shouldShowModelField: boolean;
@@ -55,6 +57,8 @@ export function GeminiFormFields({
isEndpointModalOpen,
onEndpointModalToggle,
onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
shouldShowModelField,
model,
onModelChange,
@@ -142,6 +146,8 @@ export function GeminiFormFields({
initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange}
/>
)}
@@ -124,6 +124,9 @@ export function ProviderForm({
return [];
},
);
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
() => initialData?.meta?.endpointAutoSelect ?? true,
);
// 使用 category hook
const { category } = useProviderCategory({
@@ -141,6 +144,7 @@ export function ProviderForm({
if (!initialData) {
setDraftCustomEndpoints([]);
}
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
}, [appId, initialData]);
const defaultValues: ProviderFormData = useMemo(
@@ -647,6 +651,13 @@ export function ProviderForm({
}
}
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
payload.meta = {
...(baseMeta ?? {}),
endpointAutoSelect,
};
onSubmit(payload);
};
@@ -856,6 +867,8 @@ export function ProviderForm({
onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints
}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelSelector={category !== "official"}
claudeModel={claudeModel}
reasoningModel={reasoningModel}
@@ -889,6 +902,8 @@ export function ProviderForm({
onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints
}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelField={category !== "official"}
modelName={codexModelName}
onModelNameChange={handleCodexModelNameChange}
@@ -917,6 +932,8 @@ export function ProviderForm({
isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelField={true}
model={geminiModel}
onModelChange={handleGeminiModelChange}
+5 -9
View File
@@ -106,13 +106,11 @@ export function ProxyPanel() {
// 校验地址格式(简单的 IP 地址或 localhost 校验)
const addressTrimmed = listenAddress.trim();
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
// 规范化 localhost 为 127.0.0.1
const normalizedAddress =
addressTrimmed === "localhost" ? "127.0.0.1" : addressTrimmed;
const isValidAddress =
normalizedAddress === "0.0.0.0" ||
(ipv4Regex.test(normalizedAddress) &&
normalizedAddress.split(".").every((n) => {
addressTrimmed === "localhost" ||
addressTrimmed === "0.0.0.0" ||
(ipv4Regex.test(addressTrimmed) &&
addressTrimmed.split(".").every((n) => {
const num = parseInt(n);
return num >= 0 && num <= 255;
}));
@@ -148,11 +146,9 @@ export function ProxyPanel() {
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
listenAddress: normalizedAddress,
listenAddress: addressTrimmed,
listenPort: port,
});
// 同步更新本地状态为规范化后的值
setListenAddress(normalizedAddress);
toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
{ closeButton: true },
@@ -0,0 +1,275 @@
/**
* 全局出站代理设置组件
*
* 提供配置全局代理的输入界面,支持用户名密码认证。
*/
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Loader2, TestTube2, Search, Eye, EyeOff, X } from "lucide-react";
import {
useGlobalProxyUrl,
useSetGlobalProxyUrl,
useTestProxy,
useScanProxies,
type DetectedProxy,
} from "@/hooks/useGlobalProxy";
/** 从完整 URL 提取认证信息 */
function extractAuth(url: string): {
baseUrl: string;
username: string;
password: string;
} {
if (!url.trim()) return { baseUrl: "", username: "", password: "" };
try {
const parsed = new URL(url);
const username = decodeURIComponent(parsed.username || "");
const password = decodeURIComponent(parsed.password || "");
// 移除认证信息,获取基础 URL
parsed.username = "";
parsed.password = "";
return { baseUrl: parsed.toString(), username, password };
} catch {
return { baseUrl: url, username: "", password: "" };
}
}
/** 将认证信息合并到 URL */
function mergeAuth(
baseUrl: string,
username: string,
password: string,
): string {
if (!baseUrl.trim()) return "";
if (!username.trim()) return baseUrl;
try {
const parsed = new URL(baseUrl);
// URL 对象的 username/password setter 会自动进行 percent-encoding
// 不要使用 encodeURIComponent,否则会导致双重编码
parsed.username = username.trim();
if (password) {
parsed.password = password;
}
return parsed.toString();
} catch {
// URL 解析失败,尝试手动插入(此时需要手动编码)
const match = baseUrl.match(/^(\w+:\/\/)(.+)$/);
if (match) {
const auth = password
? `${encodeURIComponent(username.trim())}:${encodeURIComponent(password)}@`
: `${encodeURIComponent(username.trim())}@`;
return `${match[1]}${auth}${match[2]}`;
}
return baseUrl;
}
}
export function GlobalProxySettings() {
const { t } = useTranslation();
const { data: savedUrl, isLoading } = useGlobalProxyUrl();
const setMutation = useSetGlobalProxyUrl();
const testMutation = useTestProxy();
const scanMutation = useScanProxies();
const [url, setUrl] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [dirty, setDirty] = useState(false);
const [detected, setDetected] = useState<DetectedProxy[]>([]);
// 计算完整 URL(含认证信息)
const fullUrl = useMemo(
() => mergeAuth(url, username, password),
[url, username, password],
);
// 同步远程配置
useEffect(() => {
if (savedUrl !== undefined) {
const { baseUrl, username: u, password: p } = extractAuth(savedUrl || "");
setUrl(baseUrl);
setUsername(u);
setPassword(p);
setDirty(false);
}
}, [savedUrl]);
const handleSave = async () => {
await setMutation.mutateAsync(fullUrl);
setDirty(false);
};
const handleTest = async () => {
if (fullUrl) {
await testMutation.mutateAsync(fullUrl);
}
};
const handleScan = async () => {
const result = await scanMutation.mutateAsync();
setDetected(result);
};
const handleSelect = (proxyUrl: string) => {
const { baseUrl, username: u, password: p } = extractAuth(proxyUrl);
setUrl(baseUrl);
setUsername(u);
setPassword(p);
setDirty(true);
setDetected([]);
};
const handleClear = () => {
setUrl("");
setUsername("");
setPassword("");
setDirty(true);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && dirty && !setMutation.isPending) {
handleSave();
}
};
// 只在首次加载且无数据时显示加载状态
if (isLoading && savedUrl === undefined) {
return (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-3">
{/* 描述 */}
<p className="text-sm text-muted-foreground">
{t("settings.globalProxy.hint")}
</p>
{/* 代理地址输入框和按钮 */}
<div className="flex gap-2">
<Input
placeholder="http://127.0.0.1:7890 / socks5://127.0.0.1:1080"
value={url}
onChange={(e) => {
setUrl(e.target.value);
setDirty(true);
}}
onKeyDown={handleKeyDown}
className="font-mono text-sm flex-1"
/>
<Button
variant="outline"
size="icon"
disabled={scanMutation.isPending}
onClick={handleScan}
title={t("settings.globalProxy.scan")}
>
{scanMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="icon"
disabled={!fullUrl || testMutation.isPending}
onClick={handleTest}
title={t("settings.globalProxy.test")}
>
{testMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube2 className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="icon"
disabled={!url && !username && !password}
onClick={handleClear}
title={t("settings.globalProxy.clear")}
>
<X className="h-4 w-4" />
</Button>
<Button
onClick={handleSave}
disabled={!dirty || setMutation.isPending}
size="sm"
>
{setMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("common.save")}
</Button>
</div>
{/* 认证信息:用户名 + 密码(可选) */}
<div className="flex gap-2">
<Input
placeholder={t("settings.globalProxy.username")}
value={username}
onChange={(e) => {
setUsername(e.target.value);
setDirty(true);
}}
onKeyDown={handleKeyDown}
className="font-mono text-sm flex-1"
/>
<div className="relative flex-1">
<Input
type={showPassword ? "text" : "password"}
placeholder={t("settings.globalProxy.password")}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setDirty(true);
}}
onKeyDown={handleKeyDown}
className="font-mono text-sm pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
</div>
{/* 扫描结果 */}
{detected.length > 0 && (
<div className="flex flex-wrap gap-2">
{detected.map((p) => (
<Button
key={p.url}
variant="secondary"
size="sm"
onClick={() => handleSelect(p.url)}
className="font-mono text-xs"
>
{p.url}
</Button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,75 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { settingsApi, type RectifierConfig } from "@/lib/api/settings";
export function RectifierConfigPanel() {
const { t } = useTranslation();
const [config, setConfig] = useState<RectifierConfig>({
enabled: true,
requestThinkingSignature: true,
});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
settingsApi
.getRectifierConfig()
.then(setConfig)
.catch((e) => console.error("Failed to load rectifier config:", e))
.finally(() => setIsLoading(false));
}, []);
const handleChange = async (updates: Partial<RectifierConfig>) => {
const newConfig = { ...config, ...updates };
setConfig(newConfig);
try {
await settingsApi.setRectifierConfig(newConfig);
} catch (e) {
console.error("Failed to save rectifier config:", e);
toast.error(String(e));
setConfig(config);
}
};
if (isLoading) return null;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>{t("settings.advanced.rectifier.enabled")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings.advanced.rectifier.enabledDescription")}
</p>
</div>
<Switch
checked={config.enabled}
onCheckedChange={(checked) => handleChange({ enabled: checked })}
/>
</div>
<div className="space-y-4">
<h4 className="text-sm font-medium text-muted-foreground">
{t("settings.advanced.rectifier.requestGroup")}
</h4>
<div className="flex items-center justify-between pl-4">
<div className="space-y-0.5">
<Label>{t("settings.advanced.rectifier.thinkingSignature")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings.advanced.rectifier.thinkingSignatureDescription")}
</p>
</div>
<Switch
checked={config.requestThinkingSignature}
disabled={!config.enabled}
onCheckedChange={(checked) =>
handleChange({ requestThinkingSignature: checked })
}
/>
</div>
</div>
</div>
);
}
+48
View File
@@ -9,6 +9,8 @@ import {
Database,
Server,
ChevronDown,
Zap,
Globe,
} from "lucide-react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { toast } from "sonner";
@@ -34,12 +36,14 @@ import { WindowSettings } from "@/components/settings/WindowSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection";
import { AboutSection } from "@/components/settings/AboutSection";
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
import { ProxyPanel } from "@/components/proxy";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
import { UsageDashboard } from "@/components/usage/UsageDashboard";
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next";
@@ -495,6 +499,28 @@ export function SettingsPage({
</AccordionContent>
</AccordionItem>
<AccordionItem
value="globalProxy"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Globe className="h-5 w-5 text-cyan-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.globalProxy.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.globalProxy.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<GlobalProxySettings />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="data"
className="rounded-xl glass-card overflow-hidden"
@@ -526,6 +552,28 @@ export function SettingsPage({
/>
</AccordionContent>
</AccordionItem>
<AccordionItem
value="rectifier"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Zap className="h-5 w-5 text-purple-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.rectifier.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.rectifier.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<RectifierConfigPanel />
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="pt-4">
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Save, Loader2 } from "lucide-react";
@@ -25,6 +26,7 @@ export function ModelTestConfigPanel() {
claudeModel: "claude-haiku-4-5-20251001",
codexModel: "gpt-5.1-codex@low",
geminiModel: "gemini-3-pro-preview",
testPrompt: "Who are you?",
});
useEffect(() => {
@@ -43,6 +45,7 @@ export function ModelTestConfigPanel() {
claudeModel: data.claudeModel,
codexModel: data.codexModel,
geminiModel: data.geminiModel,
testPrompt: data.testPrompt || "Who are you?",
});
} catch (e) {
setError(String(e));
@@ -66,6 +69,7 @@ export function ModelTestConfigPanel() {
claudeModel: config.claudeModel,
codexModel: config.codexModel,
geminiModel: config.geminiModel,
testPrompt: config.testPrompt || "Who are you?",
};
await saveStreamCheckConfig(parsed);
toast.success(t("streamCheck.configSaved"), {
@@ -189,6 +193,21 @@ export function ModelTestConfigPanel() {
/>
</div>
</div>
{/* 检查提示词配置 */}
<div className="space-y-2">
<Label htmlFor="testPrompt">{t("streamCheck.testPrompt")}</Label>
<Textarea
id="testPrompt"
value={config.testPrompt}
onChange={(e) =>
setConfig({ ...config, testPrompt: e.target.value })
}
placeholder="Who are you?"
rows={2}
className="min-h-[60px]"
/>
</div>
</div>
<div className="flex justify-end">
@@ -202,6 +202,7 @@ export function PricingConfigPanel() {
{editingModel && (
<PricingEditModal
open={!!editingModel}
model={editingModel}
isNew={isAddingNew}
onClose={() => {
+127 -127
View File
@@ -1,13 +1,8 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Save, Plus } from "lucide-react";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -15,12 +10,14 @@ import { useUpdateModelPricing } from "@/lib/query/usage";
import type { ModelPricing } from "@/types/usage";
interface PricingEditModalProps {
open: boolean;
model: ModelPricing;
isNew?: boolean;
onClose: () => void;
}
export function PricingEditModal({
open,
model,
isNew = false,
onClose,
@@ -86,139 +83,142 @@ export function PricingEditModal({
};
return (
<Dialog open onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isNew
? t("usage.addPricing", "新增定价")
: `${t("usage.editPricing", "编辑定价")} - ${model.modelId}`}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{isNew && (
<div className="space-y-2">
<Label htmlFor="modelId">{t("usage.modelId", "模型 ID")}</Label>
<Input
id="modelId"
value={formData.modelId}
onChange={(e) =>
setFormData({ ...formData, modelId: e.target.value })
}
placeholder={t("usage.modelIdPlaceholder", {
defaultValue: "例如: claude-3-5-sonnet-20241022",
})}
required
/>
</div>
<FullScreenPanel
isOpen={open}
title={
isNew
? t("usage.addPricing", "新增定价")
: `${t("usage.editPricing", "编辑定价")} - ${model.modelId}`
}
onClose={onClose}
footer={
<Button
type="submit"
form="pricing-form"
disabled={updatePricing.isPending}
>
{isNew ? (
<Plus className="h-4 w-4 mr-2" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
{updatePricing.isPending
? t("common.saving", "保存中...")
: isNew
? t("common.add", "新增")
: t("common.save", "保存")}
</Button>
}
>
<form id="pricing-form" onSubmit={handleSubmit} className="space-y-6">
{isNew && (
<div className="space-y-2">
<Label htmlFor="displayName">
{t("usage.displayName", "显示名称")}
</Label>
<Label htmlFor="modelId">{t("usage.modelId", "模型 ID")}</Label>
<Input
id="displayName"
value={formData.displayName}
id="modelId"
value={formData.modelId}
onChange={(e) =>
setFormData({ ...formData, displayName: e.target.value })
setFormData({ ...formData, modelId: e.target.value })
}
placeholder={t("usage.displayNamePlaceholder", {
defaultValue: "例如: Claude 3.5 Sonnet",
placeholder={t("usage.modelIdPlaceholder", {
defaultValue: "例如: claude-3-5-sonnet-20241022",
})}
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="inputCost">
{t("usage.inputCostPerMillion", "输入成本 (每百万 tokens, USD)")}
</Label>
<Input
id="inputCost"
type="number"
step="0.01"
min="0"
value={formData.inputCost}
onChange={(e) =>
setFormData({ ...formData, inputCost: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="displayName">
{t("usage.displayName", "显示名称")}
</Label>
<Input
id="displayName"
value={formData.displayName}
onChange={(e) =>
setFormData({ ...formData, displayName: e.target.value })
}
placeholder={t("usage.displayNamePlaceholder", {
defaultValue: "例如: Claude 3.5 Sonnet",
})}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="outputCost">
{t("usage.outputCostPerMillion", "输成本 (每百万 tokens, USD)")}
</Label>
<Input
id="outputCost"
type="number"
step="0.01"
min="0"
value={formData.outputCost}
onChange={(e) =>
setFormData({ ...formData, outputCost: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="inputCost">
{t("usage.inputCostPerMillion", "输成本 (每百万 tokens, USD)")}
</Label>
<Input
id="inputCost"
type="number"
step="0.01"
min="0"
value={formData.inputCost}
onChange={(e) =>
setFormData({ ...formData, inputCost: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="cacheReadCost">
{t(
"usage.cacheReadCostPerMillion",
"缓存读取成本 (每百万 tokens, USD)",
)}
</Label>
<Input
id="cacheReadCost"
type="number"
step="0.01"
min="0"
value={formData.cacheReadCost}
onChange={(e) =>
setFormData({ ...formData, cacheReadCost: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="outputCost">
{t("usage.outputCostPerMillion", "输出成本 (每百万 tokens, USD)")}
</Label>
<Input
id="outputCost"
type="number"
step="0.01"
min="0"
value={formData.outputCost}
onChange={(e) =>
setFormData({ ...formData, outputCost: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="cacheCreationCost">
{t(
"usage.cacheCreationCostPerMillion",
"缓存写入成本 (每百万 tokens, USD)",
)}
</Label>
<Input
id="cacheCreationCost"
type="number"
step="0.01"
min="0"
value={formData.cacheCreationCost}
onChange={(e) =>
setFormData({ ...formData, cacheCreationCost: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="cacheReadCost">
{t(
"usage.cacheReadCostPerMillion",
"缓存读取成本 (每百万 tokens, USD)",
)}
</Label>
<Input
id="cacheReadCost"
type="number"
step="0.01"
min="0"
value={formData.cacheReadCost}
onChange={(e) =>
setFormData({ ...formData, cacheReadCost: e.target.value })
}
required
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel", "取消")}
</Button>
<Button type="submit" disabled={updatePricing.isPending}>
{updatePricing.isPending
? t("common.saving", "保存中...")
: isNew
? t("common.add", "新增")
: t("common.save", "保存")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<div className="space-y-2">
<Label htmlFor="cacheCreationCost">
{t(
"usage.cacheCreationCostPerMillion",
"缓存写入成本 (每百万 tokens, USD)",
)}
</Label>
<Input
id="cacheCreationCost"
type="number"
step="0.01"
min="0"
value={formData.cacheCreationCost}
onChange={(e) =>
setFormData({ ...formData, cacheCreationCost: e.target.value })
}
required
/>
</div>
</form>
</FullScreenPanel>
);
}
+109
View File
@@ -0,0 +1,109 @@
/**
* 全局出站代理 React Hooks
*
* 提供获取、设置和测试全局代理的 React Query hooks。
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
getGlobalProxyUrl,
setGlobalProxyUrl,
testProxyUrl,
getUpstreamProxyStatus,
scanLocalProxies,
type ProxyTestResult,
type UpstreamProxyStatus,
type DetectedProxy,
} from "@/lib/api/globalProxy";
/**
* 获取全局代理 URL
*/
export function useGlobalProxyUrl() {
return useQuery({
queryKey: ["globalProxyUrl"],
queryFn: getGlobalProxyUrl,
staleTime: 30 * 1000, // 30秒内不重新获取,避免展开时闪烁
});
}
/**
* 设置全局代理 URL
*/
export function useSetGlobalProxyUrl() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: setGlobalProxyUrl,
onSuccess: () => {
toast.success(t("settings.globalProxy.saved"));
queryClient.invalidateQueries({ queryKey: ["globalProxyUrl"] });
queryClient.invalidateQueries({ queryKey: ["upstreamProxyStatus"] });
},
onError: (error: unknown) => {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Unknown error";
toast.error(t("settings.globalProxy.saveFailed", { error: message }));
},
});
}
/**
* 测试代理连接
*/
export function useTestProxy() {
const { t } = useTranslation();
return useMutation({
mutationFn: testProxyUrl,
onSuccess: (result: ProxyTestResult) => {
if (result.success) {
toast.success(
t("settings.globalProxy.testSuccess", { latency: result.latencyMs }),
);
} else {
toast.error(
t("settings.globalProxy.testFailed", { error: result.error }),
);
}
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
/**
* 获取当前出站代理状态
*/
export function useUpstreamProxyStatus() {
return useQuery<UpstreamProxyStatus>({
queryKey: ["upstreamProxyStatus"],
queryFn: getUpstreamProxyStatus,
});
}
/**
* 扫描本地代理
*/
export function useScanProxies() {
const { t } = useTranslation();
return useMutation({
mutationFn: scanLocalProxies,
onError: (error: Error) => {
toast.error(
t("settings.globalProxy.scanFailed", { error: error.message }),
);
},
});
}
export type { DetectedProxy };
+5
View File
@@ -115,6 +115,11 @@ export function useProviderActions(activeApp: AppId) {
await queryClient.invalidateQueries({
queryKey: ["providers", activeApp],
});
// 🔧 保存用量脚本后,也应该失效该 provider 的用量查询缓存
// 这样主页列表会使用新配置重新查询,而不是使用测试时的缓存
await queryClient.invalidateQueries({
queryKey: ["usage", provider.id, activeApp],
});
toast.success(
t("provider.usageSaved", {
defaultValue: "用量查询配置已保存",
+33 -2
View File
@@ -183,9 +183,23 @@
"title": "Cost Pricing",
"description": "Manage token pricing rules for each model"
},
"globalProxy": {
"title": "Global Outbound Proxy",
"description": "Configure proxy for CC Switch to access external APIs"
},
"data": {
"title": "Data Management",
"description": "Import/export configurations and backup/restore"
},
"rectifier": {
"title": "Rectifier",
"description": "Automatically fix API request compatibility issues",
"enabled": "Enable Rectifier",
"enabledDescription": "Master switch, all rectification features will be disabled when turned off",
"requestGroup": "Request Rectification",
"responseGroup": "Response Rectification",
"thinkingSignature": "Thinking Signature Rectification",
"thinkingSignatureDescription": "Automatically fix Claude API errors caused by thinking signature validation failures"
}
},
"language": "Language",
@@ -271,7 +285,21 @@
"restartLater": "Restart Later",
"restartFailed": "Application restart failed, please manually close and reopen.",
"devModeRestartHint": "Dev Mode: Automatic restart not supported, please manually restart the application.",
"saving": "Saving..."
"saving": "Saving...",
"globalProxy": {
"label": "Global Proxy",
"hint": "Proxy all requests (API, Skills download, etc.). Leave empty for direct connection.",
"username": "Username (optional)",
"password": "Password (optional)",
"test": "Test Connection",
"scan": "Scan Local Proxies",
"clear": "Clear",
"scanFailed": "Scan failed: {{error}}",
"saved": "Proxy settings saved",
"saveFailed": "Save failed: {{error}}",
"testSuccess": "Connected! Latency {{latency}}ms",
"testFailed": "Connection failed: {{error}}"
}
},
"apps": {
"claude": "Claude Code",
@@ -556,6 +584,7 @@
"testFailed": "Test failed",
"formatSuccess": "Format successful",
"formatFailed": "Format failed",
"supportedVariables": "Supported Variables",
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
"scriptConfig": "Request configuration",
"extractorCode": "Extractor code",
@@ -958,6 +987,7 @@
"configDetails": "Config Details",
"configUrl": "Config File URL",
"configMergeError": "Failed to merge configuration file",
"primaryEndpoint": "Primary",
"mcp": {
"title": "Batch Import MCP Servers",
"targetApps": "Target Apps",
@@ -1173,7 +1203,8 @@
"checkParams": "Check Parameters",
"timeout": "Timeout (seconds)",
"maxRetries": "Max Retries",
"degradedThreshold": "Degraded Threshold (ms)"
"degradedThreshold": "Degraded Threshold (ms)",
"testPrompt": "Test Prompt"
},
"proxyConfig": {
"proxyEnabled": "Proxy Enabled",
+33 -2
View File
@@ -183,9 +183,23 @@
"title": "コスト計算",
"description": "各モデルのトークン料金ルールを管理"
},
"globalProxy": {
"title": "グローバル送信プロキシ",
"description": "CC Switch が外部 API にアクセスする際のプロキシを設定"
},
"data": {
"title": "データ管理",
"description": "設定のインポート/エクスポートとバックアップ/復元"
},
"rectifier": {
"title": "整流器",
"description": "API リクエストの互換性問題を自動修正",
"enabled": "整流器を有効化",
"enabledDescription": "マスタースイッチ、オフにするとすべての整流機能が無効になります",
"requestGroup": "リクエスト整流",
"responseGroup": "レスポンス整流",
"thinkingSignature": "Thinking 署名整流",
"thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正"
}
},
"language": "言語",
@@ -271,7 +285,21 @@
"restartLater": "後で再起動",
"restartFailed": "アプリの再起動に失敗しました。手動で閉じて再度開いてください。",
"devModeRestartHint": "開発モードでは自動再起動をサポートしていません。手動で再起動してください。",
"saving": "保存中..."
"saving": "保存中...",
"globalProxy": {
"label": "グローバルプロキシ",
"hint": "すべてのリクエスト(API、Skills ダウンロードなど)をプロキシ経由で送信します。空欄で直接接続。",
"username": "ユーザー名(任意)",
"password": "パスワード(任意)",
"test": "接続テスト",
"scan": "ローカルプロキシをスキャン",
"clear": "クリア",
"scanFailed": "スキャンに失敗しました: {{error}}",
"saved": "プロキシ設定を保存しました",
"saveFailed": "保存に失敗しました: {{error}}",
"testSuccess": "接続成功!遅延 {{latency}}ms",
"testFailed": "接続に失敗しました: {{error}}"
}
},
"apps": {
"claude": "Claude Code",
@@ -556,6 +584,7 @@
"testFailed": "テストに失敗しました",
"formatSuccess": "整形に成功しました",
"formatFailed": "整形に失敗しました",
"supportedVariables": "使用可能な変数",
"variablesHint": "使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます",
"scriptConfig": "リクエスト設定",
"extractorCode": "抽出コード",
@@ -958,6 +987,7 @@
"configDetails": "設定の詳細",
"configUrl": "設定ファイル URL",
"configMergeError": "設定ファイルのマージに失敗しました",
"primaryEndpoint": "メイン",
"mcp": {
"title": "MCP サーバーを一括インポート",
"targetApps": "ターゲットアプリ",
@@ -1167,7 +1197,8 @@
"checkParams": "チェックパラメーター",
"timeout": "タイムアウト(秒)",
"maxRetries": "最大リトライ回数",
"degradedThreshold": "劣化しきい値(ミリ秒)"
"degradedThreshold": "劣化しきい値(ミリ秒)",
"testPrompt": "テストプロンプト"
},
"proxyConfig": {
"proxyEnabled": "プロキシ有効",
+33 -2
View File
@@ -183,9 +183,23 @@
"title": "成本定价",
"description": "管理各模型 Token 计费规则"
},
"globalProxy": {
"title": "全局出站代理",
"description": "配置 CC Switch 访问外部 API 时使用的代理"
},
"data": {
"title": "数据管理",
"description": "导入导出配置与备份恢复"
},
"rectifier": {
"title": "整流器",
"description": "自动修复 API 请求中的兼容性问题",
"enabled": "启用整流器",
"enabledDescription": "总开关,关闭后所有整流功能将被禁用",
"requestGroup": "请求整流",
"responseGroup": "响应整流",
"thinkingSignature": "Thinking 签名整流",
"thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误"
}
},
"language": "界面语言",
@@ -271,7 +285,21 @@
"restartLater": "稍后重启",
"restartFailed": "应用重启失败,请手动关闭后重新打开。",
"devModeRestartHint": "开发模式下不支持自动重启,请手动重新启动应用。",
"saving": "正在保存..."
"saving": "正在保存...",
"globalProxy": {
"label": "全局代理",
"hint": "代理所有请求(API、Skills 下载等)。留空表示直连。",
"username": "用户名(可选)",
"password": "密码(可选)",
"test": "测试连接",
"scan": "扫描本地代理",
"clear": "清除",
"scanFailed": "扫描失败:{{error}}",
"saved": "代理设置已保存",
"saveFailed": "保存失败:{{error}}",
"testSuccess": "连接成功!延迟 {{latency}}ms",
"testFailed": "连接失败:{{error}}"
}
},
"apps": {
"claude": "Claude Code",
@@ -556,6 +584,7 @@
"testFailed": "测试失败",
"formatSuccess": "格式化成功",
"formatFailed": "格式化失败",
"supportedVariables": "支持的变量",
"variablesHint": "支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象",
"scriptConfig": "请求配置",
"extractorCode": "提取器代码",
@@ -958,6 +987,7 @@
"configDetails": "配置详情",
"configUrl": "配置文件 URL",
"configMergeError": "合并配置文件失败",
"primaryEndpoint": "主",
"mcp": {
"title": "批量导入 MCP Servers",
"targetApps": "目标应用",
@@ -1173,7 +1203,8 @@
"checkParams": "检查参数",
"timeout": "超时时间(秒)",
"maxRetries": "最大重试次数",
"degradedThreshold": "降级阈值(毫秒)"
"degradedThreshold": "降级阈值(毫秒)",
"testPrompt": "检查提示词"
},
"proxyConfig": {
"proxyEnabled": "代理总开关",
+85
View File
@@ -0,0 +1,85 @@
/**
* 全局出站代理 API
*
* 提供获取、设置和测试全局代理的功能。
*/
import { invoke } from "@tauri-apps/api/core";
/**
* 代理测试结果
*/
export interface ProxyTestResult {
success: boolean;
latencyMs: number;
error: string | null;
}
/**
* 出站代理状态
*/
export interface UpstreamProxyStatus {
enabled: boolean;
proxyUrl: string | null;
}
/**
* 检测到的代理
*/
export interface DetectedProxy {
url: string;
proxyType: string;
port: number;
}
/**
* 获取全局代理 URL
*
* @returns 代理 URLnull 表示未配置(直连)
*/
export async function getGlobalProxyUrl(): Promise<string | null> {
return invoke<string | null>("get_global_proxy_url");
}
/**
* 设置全局代理 URL
*
* @param url - 代理 URL(如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080
* 空字符串表示清除代理(直连)
*/
export async function setGlobalProxyUrl(url: string): Promise<void> {
try {
return await invoke("set_global_proxy_url", { url });
} catch (error) {
// Tauri invoke 错误可能是字符串
throw new Error(typeof error === "string" ? error : String(error));
}
}
/**
* 测试代理连接
*
* @param url - 要测试的代理 URL
* @returns 测试结果,包含是否成功、延迟和错误信息
*/
export async function testProxyUrl(url: string): Promise<ProxyTestResult> {
return invoke<ProxyTestResult>("test_proxy_url", { url });
}
/**
* 获取当前出站代理状态
*
* @returns 代理状态,包含是否启用和代理 URL
*/
export async function getUpstreamProxyStatus(): Promise<UpstreamProxyStatus> {
return invoke<UpstreamProxyStatus>("get_upstream_proxy_status");
}
/**
* 扫描本地代理
*
* @returns 检测到的代理列表
*/
export async function scanLocalProxies(): Promise<DetectedProxy[]> {
return invoke<DetectedProxy[]>("scan_local_proxies");
}
+1
View File
@@ -12,6 +12,7 @@ export interface StreamCheckConfig {
claudeModel: string;
codexModel: string;
geminiModel: string;
testPrompt: string;
}
export interface StreamCheckResult {
+9
View File
@@ -65,6 +65,15 @@ export const providersApi = {
handler(payload);
});
},
/**
* 打开指定提供商的终端
* 任何提供商都可以打开终端,不受是否为当前激活提供商的限制
* 终端会使用该提供商特定的 API 配置,不影响全局设置
*/
async openTerminal(providerId: string, appId: AppId): Promise<boolean> {
return await invoke("open_provider_terminal", { providerId, app: appId });
},
};
// ============================================================================
+13
View File
@@ -134,4 +134,17 @@ export const settingsApi = {
> {
return await invoke("get_tool_versions");
},
async getRectifierConfig(): Promise<RectifierConfig> {
return await invoke("get_rectifier_config");
},
async setRectifierConfig(config: RectifierConfig): Promise<boolean> {
return await invoke("set_rectifier_config", { config });
},
};
export interface RectifierConfig {
enabled: boolean;
requestThinkingSignature: boolean;
}
+2
View File
@@ -28,6 +28,7 @@ export const usageApi = {
baseUrl?: string,
accessToken?: string,
userId?: string,
templateType?: "custom" | "general" | "newapi",
): Promise<UsageResult> => {
return invoke("testUsageScript", {
providerId,
@@ -38,6 +39,7 @@ export const usageApi = {
baseUrl,
accessToken,
userId,
templateType,
});
},
+3
View File
@@ -52,6 +52,7 @@ export interface UsageScript {
language: "javascript"; // 脚本语言
code: string; // 脚本代码(JSON 格式配置)
timeout?: number; // 超时时间(秒,默认 10
templateType?: "custom" | "general" | "newapi"; // 模板类型(用于后端判断验证规则)
apiKey?: string; // 用量查询专用的 API Key(通用模板使用)
baseUrl?: string; // 用量查询专用的 Base URL(通用和 NewAPI 模板使用)
accessToken?: string; // 访问令牌(NewAPI 模板使用)
@@ -92,6 +93,8 @@ export interface ProviderMeta {
custom_endpoints?: Record<string, CustomEndpoint>;
// 用量查询脚本配置
usage_script?: UsageScript;
// 请求地址管理:测速后自动选择最佳端点
endpointAutoSelect?: boolean;
// 是否为官方合作伙伴
isPartner?: boolean;
// 合作伙伴促销 key(用于后端识别 PackyCode 等)