Compare commits

..

26 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-01-09 16:23:59 +08:00
57 changed files with 962 additions and 532 deletions
+3 -15
View File
@@ -56,7 +56,8 @@ jobs:
libssl-dev \
rpm \
flatpak \
flatpak-builder
flatpak-builder \
elfutils
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev \
@@ -283,19 +284,6 @@ jobs:
else
echo "No .rpm found (optional)"
fi
# 额外上传 .flatpak(跨发行版;不参与 Updater
if [ -n "$DEB" ]; then
echo "Building Flatpak bundle from .deb..."
cp "$DEB" flatpak/cc-switch.deb
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install -y --user flathub org.gnome.Platform//46 org.gnome.Sdk//46
flatpak-builder --force-clean --user --disable-cache --repo flatpak-repo flatpak-build flatpak/com.ccswitch.desktop.yml
NEW_FLATPAK="CC-Switch-${VERSION}-Linux.flatpak"
flatpak build-bundle --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo flatpak-repo "release-assets/$NEW_FLATPAK" com.ccswitch.desktop
echo "Flatpak bundle created: $NEW_FLATPAK"
else
echo "Skip Flatpak build: no .deb found"
fi
- name: List prepared assets
shell: bash
@@ -324,7 +312,7 @@ jobs:
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`Homebrew
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`Debian/Ubuntu)或 `CC-Switch-${{ github.ref_name }}-Linux.rpm`Fedora/RHEL/openSUSE或 `CC-Switch-${{ github.ref_name }}-Linux.flatpak`Flatpak
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`Debian/Ubuntu)或 `CC-Switch-${{ github.ref_name }}-Linux.rpm`Fedora/RHEL/openSUSE
---
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
+24
View File
@@ -9,6 +9,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [3.9.1] - 2026-01-09
### Bug Fix Release
This release focuses on stability improvements and crash prevention.
### Added
- **Crash Logging** - Panic hook captures crash info to `~/.cc-switch/crash.log` with full stack traces (#562)
- **Release Logging** - Enable logging for release builds with automatic rotation (keeps 2 most recent files)
- **AIGoCode Icon** - Added colored icon for AIGoCode provider preset
### Fixed
- **Proxy Panic Prevention** - Graceful degradation when HTTP client initialization fails due to invalid proxy settings; falls back to no_proxy mode (#560)
- **UTF-8 Safety** - Fix potential panic when masking API keys or truncating logs containing multi-byte characters (Chinese, emoji, etc.) (#560)
- **Default Proxy Port** - Change default port from 5000 to 15721 to avoid conflict with macOS AirPlay Receiver (#560)
- **Windows Title** - Display "CC Switch" instead of default "Tauri app" in window title
- **Windows/Linux Spacing** - Remove extra 28px blank space below native titlebar introduced in v3.9.0
- **Flatpak Tray Icon** - Bundle libayatana-appindicator for tray icon support on Flatpak (#556)
- **Provider Preset** - Correct casing from "AiGoCode" to "AIGoCode" to match official branding
---
## [3.9.0] - 2026-01-07
### Stable Release
+2 -2
View File
@@ -2,7 +2,7 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.9.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.9.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -52,7 +52,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
## Features
### Current Version: v3.9.0 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
### Current Version: v3.9.1 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
**v3.8.0 Major Update (2025-11-28)**
+2 -2
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
[![Version](https://img.shields.io/badge/version-3.9.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.9.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -52,7 +52,7 @@
## 特長
### 現在のバージョン:v3.9.0 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
### 現在のバージョン:v3.9.1 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
**v3.8.0 メジャーアップデート (2025-11-28)**
+2 -2
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.9.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.9.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -52,7 +52,7 @@
## 功能特性
### 当前版本:v3.9.0 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
### 当前版本:v3.9.1 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
**v3.8.0 重大更新(2025-11-28**
+10 -1
View File
@@ -22,6 +22,15 @@ finish-args:
- --filesystem=home
modules:
# Required for libdbusmenu build (intltool was removed from GNOME SDK since 2019)
- name: intltool
cleanup:
- "*"
sources:
- type: archive
url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz
sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd
# Required for tray icon support
- name: libayatana-ido
buildsystem: cmake-ninja
@@ -40,7 +49,7 @@ modules:
- --with-gtk=3
- --disable-dumper
- --disable-static
- --enable-tests=no
- --disable-nls
sources:
- type: archive
url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.9.0",
"version": "3.9.1",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
+1 -1
View File
@@ -701,7 +701,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.9.0"
version = "3.9.1"
dependencies = [
"anyhow",
"async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.9.0"
version = "3.9.1"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
+9 -1
View File
@@ -9,13 +9,21 @@ use serde_json::Value;
use std::fs;
use std::path::Path;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
return custom;
}
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
get_home_dir().join(".codex")
}
/// 获取 Codex auth.json 路径
+8 -3
View File
@@ -1,6 +1,8 @@
#![allow(non_snake_case)]
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
use once_cell::sync::Lazy;
use regex::Regex;
use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt;
@@ -142,11 +144,14 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
}
}
/// 预编译的版本号正则表达式
static VERSION_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
/// 从版本输出中提取纯版本号
fn extract_version(raw: &str) -> String {
// 匹配 semver 格式: x.y.z 或 x.y.z-xxx
let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").unwrap();
re.find(raw)
VERSION_RE
.find(raw)
.map(|m| m.as_str().to_string())
.unwrap_or_else(|| raw.to_string())
}
+10 -6
View File
@@ -5,22 +5,26 @@ use std::path::{Path, PathBuf};
use crate::error::AppError;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Claude Code 配置目录路径
pub fn get_claude_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_claude_override_dir() {
return custom;
}
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".claude")
get_home_dir().join(".claude")
}
/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
pub fn get_default_claude_mcp_path() -> PathBuf {
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".claude.json")
get_home_dir().join(".claude.json")
}
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
+5 -2
View File
@@ -73,11 +73,14 @@ impl Database {
params![
server.id,
server.name,
serde_json::to_string(&server.server).unwrap(),
serde_json::to_string(&server.server).map_err(|e| AppError::Database(format!(
"Failed to serialize server config: {e}"
)))?,
server.description,
server.homepage,
server.docs,
serde_json::to_string(&server.tags).unwrap(),
serde_json::to_string(&server.tags)
.map_err(|e| AppError::Database(format!("Failed to serialize tags: {e}")))?,
server.apps.claude,
server.apps.codex,
server.apps.gemini,
+13 -5
View File
@@ -220,7 +220,9 @@ impl Database {
WHERE id = ?13 AND app_type = ?14",
params![
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
serde_json::to_string(&provider.settings_config).map_err(|e| {
AppError::Database(format!("Failed to serialize settings_config: {e}"))
})?,
provider.website_url,
provider.category,
provider.created_at,
@@ -228,7 +230,9 @@ impl Database {
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
serde_json::to_string(&meta_clone).map_err(|e| AppError::Database(format!(
"Failed to serialize meta: {e}"
)))?,
is_current,
in_failover_queue,
provider.id,
@@ -247,7 +251,8 @@ impl Database {
provider.id,
app_type,
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
serde_json::to_string(&provider.settings_config)
.map_err(|e| AppError::Database(format!("Failed to serialize settings_config: {e}")))?,
provider.website_url,
provider.category,
provider.created_at,
@@ -255,7 +260,8 @@ impl Database {
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
serde_json::to_string(&meta_clone)
.map_err(|e| AppError::Database(format!("Failed to serialize meta: {e}")))?,
is_current,
in_failover_queue,
],
@@ -324,7 +330,9 @@ impl Database {
conn.execute(
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
params![
serde_json::to_string(settings_config).unwrap(),
serde_json::to_string(settings_config).map_err(|e| AppError::Database(format!(
"Failed to serialize settings_config: {e}"
)))?,
provider_id,
app_type
],
+24 -27
View File
@@ -409,27 +409,26 @@ fn merge_claude_config(
})?;
// Auto-fill API key if not provided in URL
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
request.api_key = Some(token.to_string());
}
}
// Auto-fill endpoint if not provided in URL
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
request.endpoint = Some(base_url.to_string());
}
}
// Auto-fill homepage from endpoint if not provided
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
&& request.endpoint.is_some()
&& !request.endpoint.as_ref().unwrap().is_empty()
{
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
if request.homepage.is_none() {
request.homepage = Some("https://anthropic.com".to_string());
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
request.homepage = infer_homepage_from_endpoint(endpoint);
if request.homepage.is_none() {
request.homepage = Some("https://anthropic.com".to_string());
}
}
}
@@ -468,7 +467,7 @@ fn merge_codex_config(
config: &serde_json::Value,
) -> Result<(), AppError> {
// Auto-fill API key from auth.OPENAI_API_KEY
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(api_key) = config
.get("auth")
.and_then(|v| v.get("OPENAI_API_KEY"))
@@ -483,7 +482,7 @@ fn merge_codex_config(
// Parse TOML config string to extract base_url and model
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
// Extract base_url from model_providers section
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = extract_codex_base_url(&toml_value) {
request.endpoint = Some(base_url);
}
@@ -499,13 +498,12 @@ fn merge_codex_config(
}
// Auto-fill homepage from endpoint
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
&& request.endpoint.is_some()
&& !request.endpoint.as_ref().unwrap().is_empty()
{
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
if request.homepage.is_none() {
request.homepage = Some("https://openai.com".to_string());
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
request.homepage = infer_homepage_from_endpoint(endpoint);
if request.homepage.is_none() {
request.homepage = Some("https://openai.com".to_string());
}
}
}
@@ -518,13 +516,13 @@ fn merge_gemini_config(
config: &serde_json::Value,
) -> Result<(), AppError> {
// Gemini uses flat env structure
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
request.api_key = Some(api_key.to_string());
}
}
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
if 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()) {
request.endpoint = Some(base_url.to_string());
}
@@ -538,13 +536,12 @@ fn merge_gemini_config(
}
// Auto-fill homepage from endpoint
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
&& request.endpoint.is_some()
&& !request.endpoint.as_ref().unwrap().is_empty()
{
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
if request.homepage.is_none() {
request.homepage = Some("https://ai.google.dev".to_string());
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
request.homepage = infer_homepage_from_endpoint(endpoint);
if request.homepage.is_none() {
request.homepage = Some("https://ai.google.dev".to_string());
}
}
}
+9 -3
View File
@@ -5,15 +5,21 @@ use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Gemini 配置目录路径(支持设置覆盖)
pub fn get_gemini_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_gemini_override_dir() {
return custom;
}
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".gemini")
get_home_dir().join(".gemini")
}
/// 获取 Gemini .env 文件路径
+27
View File
@@ -134,6 +134,33 @@ pub fn set_mcp_servers_map(
obj.remove("homepage");
obj.remove("docs");
// Timeout 转换:Claude/Codex 使用 startup_timeout_sec/tool_timeout_sec
// Gemini CLI 只支持 timeout(单位 ms
// 默认值:startup=10s, tool=60s
const DEFAULT_STARTUP_MS: u64 = 10_000;
const DEFAULT_TOOL_MS: u64 = 60_000;
let extract_timeout =
|obj: &mut Map<String, Value>, key: &str, multiplier: u64| -> Option<u64> {
obj.remove(key).and_then(|val| {
val.as_u64()
.map(|n| n * multiplier)
.or_else(|| val.as_f64().map(|f| (f * multiplier as f64) as u64))
})
};
// 分别收集 startup 和 tool timeout,未设置时使用默认值
let startup_ms = extract_timeout(&mut obj, "startup_timeout_sec", 1000)
.or_else(|| extract_timeout(&mut obj, "startup_timeout_ms", 1))
.unwrap_or(DEFAULT_STARTUP_MS);
let tool_ms = extract_timeout(&mut obj, "tool_timeout_sec", 1000)
.or_else(|| extract_timeout(&mut obj, "tool_timeout_ms", 1))
.unwrap_or(DEFAULT_TOOL_MS);
// 取最大值作为 Gemini timeout
let final_timeout = startup_ms.max(tool_ms);
obj.insert("timeout".to_string(), Value::Number(final_timeout.into()));
out.insert(id.clone(), Value::Object(obj));
}
+24 -52
View File
@@ -2,6 +2,7 @@
//!
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
use super::log_codes::cb as log_cb;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
@@ -106,7 +107,6 @@ impl CircuitBreaker {
/// 更新熔断器配置(热更新,不重置状态)
pub async fn update_config(&self, new_config: CircuitBreakerConfig) {
*self.config.write().await = new_config;
log::debug!("Circuit breaker config updated");
}
/// 判断当前 Provider 是否“可被纳入候选链路”
@@ -128,7 +128,8 @@ impl CircuitBreaker {
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态
log::info!(
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
"[{}] 熔断器 Open HalfOpen (超时恢复)",
log_cb::OPEN_TO_HALF_OPEN
);
self.transition_to_half_open().await;
return true;
@@ -155,7 +156,8 @@ impl CircuitBreaker {
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态
log::info!(
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
"[{}] 熔断器 Open HalfOpen (超时恢复)",
log_cb::OPEN_TO_HALF_OPEN
);
self.transition_to_half_open().await;
@@ -197,25 +199,17 @@ impl CircuitBreaker {
self.consecutive_failures.store(0, Ordering::SeqCst);
self.total_requests.fetch_add(1, Ordering::SeqCst);
match state {
CircuitState::HalfOpen => {
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
log::debug!(
"Circuit breaker HalfOpen: {} consecutive successes (threshold: {})",
successes,
config.success_threshold
);
if state == CircuitState::HalfOpen {
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
if successes >= config.success_threshold {
drop(config); // 释放读锁再转换状态
log::info!("Circuit breaker transitioning from HalfOpen to Closed (success threshold reached)");
self.transition_to_closed().await;
}
if successes >= config.success_threshold {
drop(config); // 释放读锁再转换状态
log::info!(
"[{}] 熔断器 HalfOpen → Closed (恢复正常)",
log_cb::HALF_OPEN_TO_CLOSED
);
self.transition_to_closed().await;
}
CircuitState::Closed => {
log::debug!("Circuit breaker Closed: request succeeded");
}
_ => {}
}
}
@@ -236,18 +230,14 @@ impl CircuitBreaker {
// 重置成功计数
self.consecutive_successes.store(0, Ordering::SeqCst);
log::debug!(
"Circuit breaker {:?}: {} consecutive failures (threshold: {})",
state,
failures,
config.failure_threshold
);
// 检查是否应该打开熔断器
match state {
CircuitState::HalfOpen => {
// HalfOpen 状态下失败,立即转为 Open
log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open");
log::warn!(
"[{}] 熔断器 HalfOpen 探测失败 → Open",
log_cb::HALF_OPEN_PROBE_FAILED
);
drop(config);
self.transition_to_open().await;
}
@@ -255,9 +245,8 @@ impl CircuitBreaker {
// 检查连续失败次数
if failures >= config.failure_threshold {
log::warn!(
"Circuit breaker opening due to {} consecutive failures (threshold: {})",
failures,
config.failure_threshold
"[{}] 熔断器触发: 连续失败 {failures} 次 → Open",
log_cb::TRIGGERED_FAILURES
);
drop(config); // 释放读锁再转换状态
self.transition_to_open().await;
@@ -268,18 +257,12 @@ impl CircuitBreaker {
if total >= config.min_requests {
let error_rate = failed as f64 / total as f64;
log::debug!(
"Circuit breaker error rate: {:.2}% ({}/{} requests)",
error_rate * 100.0,
failed,
total
);
if error_rate >= config.error_rate_threshold {
log::warn!(
"Circuit breaker opening due to high error rate: {:.2}% (threshold: {:.2}%)",
error_rate * 100.0,
config.error_rate_threshold * 100.0
"[{}] 熔断器触发: 错误率 {:.1}% → Open",
log_cb::TRIGGERED_ERROR_RATE,
error_rate * 100.0
);
drop(config); // 释放读锁再转换状态
self.transition_to_open().await;
@@ -312,22 +295,16 @@ impl CircuitBreaker {
/// 重置熔断器(手动恢复)
#[allow(dead_code)]
pub async fn reset(&self) {
log::info!("Circuit breaker manually reset to Closed state");
log::info!("[{}] 熔断器手动重置 → Closed", log_cb::MANUAL_RESET);
self.transition_to_closed().await;
}
fn allow_half_open_probe(&self) -> AllowResult {
// 半开状态限流:只允许有限请求通过进行探测
// 默认最多允许 1 个请求(可在配置中扩展)
let max_half_open_requests = 1u32;
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
if current < max_half_open_requests {
log::debug!(
"Circuit breaker HalfOpen: allowing probe request ({}/{})",
current + 1,
max_half_open_requests
);
AllowResult {
allowed: true,
used_half_open_permit: true,
@@ -335,9 +312,6 @@ impl CircuitBreaker {
} else {
// 超过限额,回退计数,拒绝请求
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
log::debug!(
"Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
);
AllowResult {
allowed: false,
used_half_open_permit: false,
@@ -349,8 +323,6 @@ impl CircuitBreaker {
let mut current = self.half_open_requests.load(Ordering::SeqCst);
loop {
if current == 0 {
// 理论上不应该发生:说明调用方传入的 used_half_open_permit 与实际占用不一致
log::debug!("Circuit breaker HalfOpen permit already released (counter=0)");
return;
}
+12
View File
@@ -17,6 +17,12 @@ pub enum ProxyError {
#[error("地址绑定失败: {0}")]
BindFailed(String),
#[error("停止超时")]
StopTimeout,
#[error("停止失败: {0}")]
StopFailed(String),
#[error("请求转发失败: {0}")]
ForwardFailed(String),
@@ -113,6 +119,12 @@ impl IntoResponse for ProxyError {
ProxyError::BindFailed(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::StopTimeout => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::StopFailed(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
ProxyError::NoAvailableProvider => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
+5 -7
View File
@@ -86,17 +86,17 @@ impl FailoverSwitchManager {
let app_enabled = match self.db.get_proxy_config_for_app(app_type).await {
Ok(config) => config.enabled,
Err(e) => {
log::warn!("[Failover] 无法读取 {app_type} 配置: {e},跳过切换");
log::warn!("[FO-002] 无法读取 {app_type} 配置: {e},跳过切换");
return Ok(false);
}
};
if !app_enabled {
log::info!("[Failover] {app_type} 未被代理接管(enabled=false,跳过切换");
log::debug!("[Failover] {app_type} 未启用代理,跳过切换");
return Ok(false);
}
log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})");
log::info!("[FO-001] 切换: {app_type} {provider_name}");
// 1. 更新数据库 is_current
self.db.set_current_provider(app_type, provider_id)?;
@@ -117,7 +117,7 @@ impl FailoverSwitchManager {
.update_live_backup_from_provider(app_type, &provider)
.await
{
log::warn!("[Failover] 更新 Live 备份失败: {e}");
log::warn!("[FO-003] Live 备份更新失败: {e}");
}
}
@@ -138,12 +138,10 @@ impl FailoverSwitchManager {
"source": "failover" // 标识来源是故障转移
});
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("[Failover] 发射供应商切换事件失败: {e}");
log::error!("[Failover] 发射事件失败: {e}");
}
}
log::info!("[Failover] 供应商切换完成: {app_type} -> {provider_name} ({provider_id})");
Ok(true)
}
}
+10
View File
@@ -305,6 +305,14 @@ impl RequestForwarder {
Some(format!("Provider {} 失败: {}", provider.name, e));
}
log::warn!(
"[{}] [FWD-001] Provider {} 失败,切换下一个 ({}/{})",
app_type_str,
provider.name,
attempted_providers,
providers.len()
);
last_error = Some(e);
last_provider = Some(provider.clone());
// 继续尝试下一个供应商
@@ -360,6 +368,8 @@ impl RequestForwarder {
}
}
log::warn!("[{app_type_str}] [FWD-002] 所有 Provider 均失败");
Err(ForwardError {
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
provider: last_provider,
+2 -3
View File
@@ -127,7 +127,7 @@ impl RequestContext {
.cloned()
.ok_or(ProxyError::NoAvailableProvider)?;
log::info!(
log::debug!(
"[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}",
tag,
provider.name,
@@ -168,7 +168,6 @@ impl RequestContext {
.unwrap_or("unknown")
.to_string();
log::info!("[{}] 从 URI 提取模型: {}", self.tag, self.request_model);
self
}
@@ -190,7 +189,7 @@ impl RequestContext {
)
} else {
// 故障转移关闭:不启用超时配置
log::info!(
log::debug!(
"[{}] Failover disabled, timeout configs are bypassed",
self.tag
);
+7 -54
View File
@@ -98,16 +98,6 @@ pub async fn handle_messages(
let adapter = get_adapter(&AppType::Claude);
let needs_transform = adapter.needs_transform(&ctx.provider);
log::info!(
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
ctx.provider.name,
needs_transform,
is_stream
);
let status = response.status();
log::info!("[Claude] 上游响应状态: {status}");
// Claude 特有:格式转换处理
if needs_transform {
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
@@ -131,8 +121,6 @@ async fn handle_claude_transform(
if is_stream {
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
log::info!("[Claude] 开始流式响应转换 (OpenAI SSE → Anthropic SSE)");
let stream = response.bytes_stream();
let sse_stream = create_anthropic_sse_stream(stream);
@@ -196,13 +184,10 @@ async fn handle_claude_transform(
);
let body = axum::body::Body::from_stream(logged_stream);
log::info!("[Claude] ====== 请求结束 (流式转换) ======");
return Ok((headers, body).into_response());
}
// 非流式响应转换 (OpenAI → Anthropic)
log::info!("[Claude] 开始转换响应 (OpenAI → Anthropic)");
let response_headers = response.headers().clone();
let body_bytes = response.bytes().await.map_err(|e| {
@@ -211,31 +196,17 @@ async fn handle_claude_transform(
})?;
let body_str = String::from_utf8_lossy(&body_bytes);
log::info!("[Claude] OpenAI 响应长度: {} bytes", body_bytes.len());
log::debug!("[Claude] OpenAI 原始响应: {body_str}");
let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}");
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
})?;
log::info!("[Claude] 解析 OpenAI 响应成功");
log::info!(
"[Claude] <<< OpenAI 响应 JSON:\n{}",
serde_json::to_string_pretty(&openai_response).unwrap_or_default()
);
let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| {
log::error!("[Claude] 转换响应失败: {e}");
e
})?;
log::info!("[Claude] 转换响应成功");
log::info!(
"[Claude] <<< Anthropic 响应 JSON:\n{}",
serde_json::to_string_pretty(&anthropic_response).unwrap_or_default()
);
// 记录使用量
if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {
let model = anthropic_response
@@ -265,8 +236,6 @@ async fn handle_claude_transform(
});
}
log::info!("[Claude] ====== 请求结束 ======");
// 构建响应
let mut builder = axum::response::Response::builder().status(status);
@@ -285,11 +254,6 @@ async fn handle_claude_transform(
ProxyError::TransformError(format!("Failed to serialize response: {e}"))
})?;
log::info!(
"[Claude] 返回转换后的响应, 长度: {} bytes",
response_body.len()
);
let body = axum::body::Body::from(response_body);
builder.body(body).map_err(|e| {
log::error!("[Claude] 构建响应失败: {e}");
@@ -307,8 +271,6 @@ pub async fn handle_chat_completions(
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
@@ -317,12 +279,6 @@ pub async fn handle_chat_completions(
.and_then(|v| v.as_bool())
.unwrap_or(false);
log::info!(
"[Codex] 请求模型: {}, 流式: {}",
ctx.request_model,
is_stream
);
let forwarder = ctx.create_forwarder(&state);
let result = match forwarder
.forward_with_retry(
@@ -347,8 +303,6 @@ pub async fn handle_chat_completions(
ctx.provider = result.provider;
let response = result.response;
log::info!("[Codex] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
}
@@ -390,8 +344,6 @@ pub async fn handle_responses(
ctx.provider = result.provider;
let response = result.response;
log::info!("[Codex] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
}
@@ -417,8 +369,6 @@ pub async fn handle_gemini(
.map(|pq| pq.as_str())
.unwrap_or(uri.path());
log::info!("[Gemini] 请求端点: {endpoint}");
let is_stream = body
.get("stream")
.and_then(|v| v.as_bool())
@@ -448,8 +398,6 @@ pub async fn handle_gemini(
ctx.provider = result.provider;
let response = result.response;
log::info!("[Gemini] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
}
@@ -508,7 +456,12 @@ async fn log_usage(
Ok(Some(p)) => {
if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier {
Decimal::from_str(&cm).unwrap_or(Decimal::from(1))
Decimal::from_str(&cm).unwrap_or_else(|e| {
log::warn!(
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
);
Decimal::from(1)
})
} else {
Decimal::from(1)
}
@@ -535,6 +488,6 @@ async fn log_usage(
None, // provider_type
is_streaming,
) {
log::warn!("记录使用量失败: {e}");
log::warn!("[USG-001] 记录使用量失败: {e}");
}
}
+59
View File
@@ -0,0 +1,59 @@
//! 代理模块日志错误码定义
//!
//! 格式: [模块-编号] 消息
//! - CB: Circuit Breaker (熔断器)
//! - SRV: Server (服务器)
//! - FWD: Forwarder (转发器)
//! - FO: Failover (故障转移)
//! - RSP: Response (响应处理)
//! - USG: Usage (使用量)
#![allow(dead_code)]
/// 熔断器日志码
pub mod cb {
pub const OPEN_TO_HALF_OPEN: &str = "CB-001";
pub const HALF_OPEN_TO_CLOSED: &str = "CB-002";
pub const HALF_OPEN_PROBE_FAILED: &str = "CB-003";
pub const TRIGGERED_FAILURES: &str = "CB-004";
pub const TRIGGERED_ERROR_RATE: &str = "CB-005";
pub const MANUAL_RESET: &str = "CB-006";
}
/// 服务器日志码
pub mod srv {
pub const STARTED: &str = "SRV-001";
pub const STOPPED: &str = "SRV-002";
pub const STOP_TIMEOUT: &str = "SRV-003";
pub const TASK_ERROR: &str = "SRV-004";
}
/// 转发器日志码
pub mod fwd {
pub const PROVIDER_FAILED_RETRY: &str = "FWD-001";
pub const ALL_PROVIDERS_FAILED: &str = "FWD-002";
}
/// 故障转移日志码
pub mod fo {
pub const SWITCH_SUCCESS: &str = "FO-001";
pub const CONFIG_READ_ERROR: &str = "FO-002";
pub const LIVE_BACKUP_ERROR: &str = "FO-003";
pub const ALL_CIRCUIT_OPEN: &str = "FO-004";
pub const NO_PROVIDERS: &str = "FO-005";
}
/// 响应处理日志码
pub mod rsp {
pub const BUILD_STREAM_ERROR: &str = "RSP-001";
pub const READ_BODY_ERROR: &str = "RSP-002";
pub const BUILD_RESPONSE_ERROR: &str = "RSP-003";
pub const STREAM_TIMEOUT: &str = "RSP-004";
pub const STREAM_ERROR: &str = "RSP-005";
}
/// 使用量日志码
pub mod usg {
pub const LOG_FAILED: &str = "USG-001";
pub const PRICING_NOT_FOUND: &str = "USG-002";
}
+1
View File
@@ -12,6 +12,7 @@ pub mod handler_config;
pub mod handler_context;
mod handlers;
mod health;
pub mod log_codes;
pub mod model_mapper;
pub mod provider_router;
pub mod providers;
+1 -1
View File
@@ -127,7 +127,7 @@ pub fn apply_model_mapping(
let mapped = mapping.map_model(original, has_thinking);
if mapped != *original {
log::info!("[ModelMapper] 模型映射: {original} → {mapped}");
log::debug!("[ModelMapper] 模型映射: {original} → {mapped}");
body["model"] = serde_json::json!(mapped);
return (body, Some(original.clone()), Some(mapped));
}
+14 -103
View File
@@ -39,15 +39,9 @@ impl ProviderRouter {
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
Ok(config) => {
let enabled = config.auto_failover_enabled;
log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}");
enabled
}
Ok(config) => config.auto_failover_enabled,
Err(e) => {
log::error!(
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
);
log::error!("[{app_type}] 读取 proxy_config 失败: {e},默认禁用故障转移");
false
}
};
@@ -56,85 +50,37 @@ impl ProviderRouter {
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
let failover_providers = self.db.get_failover_providers(app_type)?;
total_providers = failover_providers.len();
log::debug!("[{app_type}] Found {total_providers} failover queue provider(s)");
log::info!(
"[{app_type}] Failover enabled, using queue order ({total_providers} items)"
);
for provider in failover_providers {
// 检查熔断器状态
let circuit_key = format!("{}:{}", app_type, provider.id);
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
let state = breaker.get_state().await;
if breaker.is_available().await {
log::debug!(
"[{}] Queue provider available: {} ({}) (state: {:?})",
app_type,
provider.name,
provider.id,
state
);
log::info!(
"[{}] Queue provider available: {} ({}) at sort_index {:?}",
app_type,
provider.name,
provider.id,
provider.sort_index
);
result.push(provider);
} else {
circuit_open_count += 1;
log::debug!(
"[{}] Queue provider {} circuit breaker open (state: {:?}), skipping",
app_type,
provider.name,
state
);
}
}
} else {
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
// 原因:单 Provider 场景下,熔断器打开会导致所有请求失败,用户体验差
log::info!("[{app_type}] Failover disabled, using current provider only (circuit breaker bypassed)");
if let Some(current_id) = self.db.get_current_provider(app_type)? {
if let Some(current) = self.db.get_provider_by_id(&current_id, app_type)? {
log::info!(
"[{}] Current provider: {} ({})",
app_type,
current.name,
current.id
);
total_providers = 1;
result.push(current);
} else {
log::debug!(
"[{app_type}] Current provider id {current_id} not found in database"
);
}
} else {
log::debug!("[{app_type}] No current provider configured");
}
}
if result.is_empty() {
// 区分两种情况:全部熔断 vs 未配置供应商
if total_providers > 0 && circuit_open_count == total_providers {
log::warn!("[{app_type}] 所有 {total_providers} 个供应商均已熔断,无可用渠道");
log::warn!("[{app_type}] [FO-004] 所有供应商均已熔断");
return Err(AppError::AllProvidersCircuitOpen);
} else {
log::warn!("[{app_type}] 未配置供应商或故障转移队列为空");
log::warn!("[{app_type}] [FO-005] 未配置供应商");
return Err(AppError::NoProvidersConfigured);
}
}
log::info!(
"[{}] Provider chain: {} provider(s) available",
app_type,
result.len()
);
Ok(result)
}
@@ -161,15 +107,10 @@ impl ProviderRouter {
success: bool,
error_msg: Option<String>,
) -> Result<(), AppError> {
// 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用)
// 1. 按应用独立获取熔断器配置
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
Ok(app_config) => app_config.circuit_failure_threshold,
Err(e) => {
log::warn!(
"Failed to load circuit config for {app_type}, using default threshold: {e}"
);
5 // 默认值
}
Err(_) => 5, // 默认值
};
// 2. 更新熔断器状态
@@ -178,14 +119,8 @@ impl ProviderRouter {
if success {
breaker.record_success(used_half_open_permit).await;
log::debug!("Provider {provider_id} request succeeded");
} else {
breaker.record_failure(used_half_open_permit).await;
log::warn!(
"Provider {} request failed: {}",
provider_id,
error_msg.as_deref().unwrap_or("Unknown error")
);
}
// 3. 更新数据库健康状态(使用配置的阈值)
@@ -206,7 +141,6 @@ impl ProviderRouter {
pub async fn reset_circuit_breaker(&self, circuit_key: &str) {
let breakers = self.circuit_breakers.read().await;
if let Some(breaker) = breakers.get(circuit_key) {
log::info!("Manually resetting circuit breaker for {circuit_key}");
breaker.reset().await;
}
}
@@ -218,18 +152,11 @@ impl ProviderRouter {
}
/// 更新所有熔断器的配置(热更新)
///
/// 当用户在 UI 中修改熔断器配置后调用此方法,
/// 所有现有的熔断器会立即使用新配置
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
let breakers = self.circuit_breakers.read().await;
let count = breakers.len();
for breaker in breakers.values() {
breaker.update_config(config.clone()).await;
}
log::info!("已更新 {count} 个熔断器的配置");
}
/// 获取熔断器状态
@@ -272,32 +199,16 @@ impl ProviderRouter {
// 按应用独立读取熔断器配置
let config = match self.db.get_proxy_config_for_app(app_type).await {
Ok(app_config) => {
log::debug!(
"Loading circuit breaker config for {key} (app={app_type}): \
failure_threshold={}, success_threshold={}, timeout={}s",
app_config.circuit_failure_threshold,
app_config.circuit_success_threshold,
app_config.circuit_timeout_seconds
);
crate::proxy::circuit_breaker::CircuitBreakerConfig {
failure_threshold: app_config.circuit_failure_threshold,
success_threshold: app_config.circuit_success_threshold,
timeout_seconds: app_config.circuit_timeout_seconds as u64,
error_rate_threshold: app_config.circuit_error_rate_threshold,
min_requests: app_config.circuit_min_requests,
}
}
Err(e) => {
log::warn!(
"Failed to load circuit breaker config for {key} (app={app_type}): {e}, using default"
);
crate::proxy::circuit_breaker::CircuitBreakerConfig::default()
}
Ok(app_config) => crate::proxy::circuit_breaker::CircuitBreakerConfig {
failure_threshold: app_config.circuit_failure_threshold,
success_threshold: app_config.circuit_success_threshold,
timeout_seconds: app_config.circuit_timeout_seconds as u64,
error_rate_threshold: app_config.circuit_error_rate_threshold,
min_requests: app_config.circuit_min_requests,
},
Err(_) => crate::proxy::circuit_breaker::CircuitBreakerConfig::default(),
};
log::debug!("Creating new circuit breaker for {key} with config: {config:?}");
let breaker = Arc::new(CircuitBreaker::new(config));
breakers.insert(key.to_string(), breaker.clone());
+13 -2
View File
@@ -62,7 +62,8 @@ impl ClaudeAdapter {
let normalized = value.trim().to_lowercase();
normalized == "true" || normalized == "1"
}
_ => true,
// OpenRouter now supports Claude Code compatible API, default to passthrough
_ => false,
}
}
@@ -465,12 +466,22 @@ mod tests {
}));
assert!(!adapter.needs_transform(&anthropic_provider));
// OpenRouter provider without explicit setting now defaults to passthrough (no transform)
let openrouter_provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
}
}));
assert!(adapter.needs_transform(&openrouter_provider));
assert!(!adapter.needs_transform(&openrouter_provider));
// OpenRouter provider with explicit compat mode enabled should transform
let openrouter_enabled = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
},
"openrouter_compat_mode": true
}));
assert!(adapter.needs_transform(&openrouter_enabled));
let openrouter_disabled = create_provider(json!({
"env": {
+4 -13
View File
@@ -75,8 +75,6 @@ pub fn create_anthropic_sse_stream(
let mut current_block_type: Option<String> = None;
let mut tool_call_id = None;
log::info!("[Claude/OpenRouter] ====== 开始流式响应转换 ======");
tokio::pin!(stream);
while let Some(chunk) = stream.next().await {
@@ -96,25 +94,18 @@ pub fn create_anthropic_sse_stream(
for l in line.lines() {
if let Some(data) = l.strip_prefix("data: ") {
if data.trim() == "[DONE]" {
log::info!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
log::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
let event = json!({"type": "message_stop"});
let sse_data = format!("event: message_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
log::info!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
yield Ok(Bytes::from(sse_data));
continue;
}
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
// 记录原始 OpenAI 事件(格式化显示)
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(data) {
log::info!(
"[Claude/OpenRouter] <<< OpenAI SSE 事件:\n{}",
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
);
} else {
log::info!("[Claude/OpenRouter] <<< OpenAI SSE 数据: {data}");
}
// 仅在 DEBUG 级别简短记录 SSE 事件
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
if message_id.is_none() {
message_id = Some(chunk.id.clone());
+13 -20
View File
@@ -46,8 +46,6 @@ pub async fn handle_streaming(
state: &ProxyState,
parser_config: &UsageParserConfig,
) -> Response {
log::info!("[{}] 流式透传响应 (SSE)", ctx.tag);
let status = response.status();
let mut builder = axum::response::Response::builder().status(status);
@@ -99,12 +97,6 @@ pub async fn handle_non_streaming(
// 解析并记录使用量
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
log::info!(
"[{}] <<< 响应 JSON:\n{}",
ctx.tag,
serde_json::to_string_pretty(&json_value).unwrap_or_default()
);
// 解析使用量
if let Some(usage) = (parser_config.response_parser)(&json_value) {
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
@@ -137,7 +129,7 @@ pub async fn handle_non_streaming(
);
}
} else {
log::info!(
log::debug!(
"[{}] <<< 响应 (非 JSON): {} bytes",
ctx.tag,
body_bytes.len()
@@ -152,8 +144,6 @@ pub async fn handle_non_streaming(
);
}
log::info!("[{}] ====== 请求结束 ======", ctx.tag);
// 构建响应
let mut builder = axum::response::Response::builder().status(status);
for (key, value) in response_headers.iter() {
@@ -382,7 +372,12 @@ async fn log_usage_internal(
Ok(Some(p)) => {
if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier {
Decimal::from_str(&cm).unwrap_or(Decimal::from(1))
Decimal::from_str(&cm).unwrap_or_else(|e| {
log::warn!(
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
);
Decimal::from(1)
})
} else {
Decimal::from(1)
}
@@ -418,7 +413,7 @@ async fn log_usage_internal(
None, // provider_type
is_streaming,
) {
log::warn!("记录使用量失败: {e}");
log::warn!("[USG-001] 记录使用量失败: {e}");
}
}
@@ -493,16 +488,16 @@ pub fn create_logged_passthrough_stream(
if let Some(c) = &collector {
c.push(json_value.clone()).await;
}
log::info!(
"[{}] <<< SSE 事件:\n{}",
log::debug!(
"[{}] <<< SSE 事件: {}",
tag,
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
data.chars().take(100).collect::<String>()
);
} else {
log::info!("[{tag}] <<< SSE 数据: {data}");
log::debug!("[{tag}] <<< SSE 数据: {}", data.chars().take(100).collect::<String>());
}
} else {
log::info!("[{tag}] <<< SSE: [DONE]");
log::debug!("[{tag}] <<< SSE: [DONE]");
}
}
}
@@ -523,8 +518,6 @@ pub fn create_logged_passthrough_stream(
}
}
log::info!("[{}] ====== 流结束 ======", tag);
if let Some(c) = collector.take() {
c.finish().await;
}
+20 -8
View File
@@ -3,8 +3,8 @@
//! 基于Axum的HTTP服务器,处理代理请求
use super::{
failover_switch::FailoverSwitchManager, handlers, provider_router::ProviderRouter, types::*,
ProxyError,
failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
provider_router::ProviderRouter, types::*, ProxyError,
};
use crate::database::Database;
use axum::{
@@ -95,7 +95,7 @@ impl ProxyServer {
.await
.map_err(|e| ProxyError::BindFailed(e.to_string()))?;
log::info!("代理服务器启动于 {addr}");
log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED);
// 保存关闭句柄
*self.shutdown_tx.write().await = Some(shutdown_tx);
@@ -146,13 +146,25 @@ impl ProxyServer {
// 2. 等待服务器任务结束(带 5 秒超时保护)
if let Some(handle) = self.server_handle.write().await.take() {
match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await {
Ok(Ok(())) => log::info!("代理服务器已完全停止"),
Ok(Err(e)) => log::warn!("代理服务器任务异常终止: {e}"),
Err(_) => log::warn!("代理服务器停止超时(5秒),强制继续"),
Ok(Ok(())) => {
log::info!("[{}] 代理服务器已完全停止", log_srv::STOPPED);
Ok(())
}
Ok(Err(e)) => {
log::warn!("[{}] 代理服务器任务异常终止: {e}", log_srv::TASK_ERROR);
Err(ProxyError::StopFailed(e.to_string()))
}
Err(_) => {
log::warn!(
"[{}] 代理服务器停止超时(5秒),强制继续",
log_srv::STOP_TIMEOUT
);
Err(ProxyError::StopTimeout)
}
}
} else {
Ok(())
}
Ok(())
}
pub async fn get_status(&self) -> ProxyStatus {
+1 -1
View File
@@ -214,7 +214,7 @@ impl<'a> UsageLogger<'a> {
let pricing = self.get_model_pricing(&model)?;
if pricing.is_none() {
log::warn!("模型 {model} 的定价信息未找到,成本将记录为 0");
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0");
}
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
+1 -1
View File
@@ -85,7 +85,7 @@ impl PromptService {
if !content_exists {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.unwrap_or_default()
.as_secs() as i64;
let backup_id = format!("backup-{timestamp}");
let backup_prompt = Prompt {
+9
View File
@@ -148,6 +148,15 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// MCP sync
McpService::sync_all_enabled(state)?;
// Skill sync
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
log::warn!("同步 Skill 到 {:?} 失败: {}", app_type, e);
// Continue syncing other apps, don't abort
}
}
Ok(())
}
+1 -1
View File
@@ -160,7 +160,7 @@ impl SkillService {
.user_agent("cc-switch")
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("Failed to create HTTP client"),
.unwrap_or_else(|_| Client::new()),
}
}
+16 -9
View File
@@ -802,25 +802,25 @@ pub(crate) fn find_model_pricing_row(
conn: &Connection,
model_id: &str,
) -> Result<Option<(String, String, String, String)>, AppError> {
// 1) 去除供应商前缀(/ 之前)与冒号后缀(: 之后),例如 moonshotai/kimi-k2-0905:exa → kimi-k2-0905
let without_prefix = model_id
// 清洗模型名称:去前缀(/)、去后缀(:)、@ 替换为 -
// 例如 moonshotai/gpt-5.2-codex@low:v2 → gpt-5.2-codex-low
let cleaned = model_id
.rsplit_once('/')
.map(|(_, rest)| rest)
.unwrap_or(model_id);
let cleaned = without_prefix
.map_or(model_id, |(_, r)| r)
.split(':')
.next()
.map(str::trim)
.unwrap_or(without_prefix);
.unwrap_or(model_id)
.trim()
.replace('@', "-");
// 2) 精确匹配清洗后的名称
// 精确匹配清洗后的名称
let exact = conn
.query_row(
"SELECT input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing
WHERE model_id = ?1",
[cleaned],
[&cleaned],
|row| {
Ok((
row.get::<_, String>(0)?,
@@ -952,6 +952,13 @@ mod tests {
"带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905"
);
// 清洗:@ 替换为 -seed_model_pricing 已预置 gpt-5.2-codex-low
let result = find_model_pricing_row(&conn, "gpt-5.2-codex@low")?;
assert!(
result.is_some(),
"带 @ 分隔符的模型 gpt-5.2-codex@low 应能匹配到 gpt-5.2-codex-low"
);
// 测试不存在的模型
let result = find_model_pricing_row(&conn, "unknown-model-123")?;
assert!(result.is_none(), "不应该匹配不存在的模型");
+23 -10
View File
@@ -92,12 +92,9 @@ impl Default for AppSettings {
}
impl AppSettings {
fn settings_path() -> PathBuf {
fn settings_path() -> Option<PathBuf> {
// settings.json 保留用于旧版本迁移和无数据库场景
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".cc-switch")
.join("settings.json")
dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json"))
}
fn normalize_paths(&mut self) {
@@ -131,7 +128,9 @@ impl AppSettings {
}
fn load_from_file() -> Self {
let path = Self::settings_path();
let Some(path) = Self::settings_path() else {
return Self::default();
};
if let Ok(content) = fs::read_to_string(&path) {
match serde_json::from_str::<AppSettings>(&content) {
Ok(mut settings) => {
@@ -156,7 +155,9 @@ impl AppSettings {
fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
let mut normalized = settings.clone();
normalized.normalize_paths();
let path = AppSettings::settings_path();
let Some(path) = AppSettings::settings_path() else {
return Err(AppError::Config("无法获取用户主目录".to_string()));
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
@@ -193,14 +194,23 @@ fn resolve_override_path(raw: &str) -> PathBuf {
}
pub fn get_settings() -> AppSettings {
settings_store().read().expect("读取设置锁失败").clone()
settings_store()
.read()
.unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
})
.clone()
}
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
new_settings.normalize_paths();
save_settings_file(&new_settings)?;
let mut guard = settings_store().write().expect("写入设置锁失败");
let mut guard = settings_store().write().unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
});
*guard = new_settings;
Ok(())
}
@@ -209,7 +219,10 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
/// 用于导入配置等场景,确保内存缓存与文件同步
pub fn reload_settings() -> Result<(), AppError> {
let fresh_settings = AppSettings::load_from_file();
let mut guard = settings_store().write().expect("写入设置锁失败");
let mut guard = settings_store().write().unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
});
*guard = fresh_settings;
Ok(())
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.9.0",
"version": "3.9.1",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+1
View File
@@ -4,6 +4,7 @@
"windows": [
{
"label": "main",
"title": "CC Switch",
"titleBarStyle": "Visible",
"minWidth": 900,
"minHeight": 600
+10 -5
View File
@@ -31,6 +31,7 @@ import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useLastValidValue } from "@/hooks/useLastValidValue";
import { extractErrorMessage } from "@/utils/errorUtils";
import { cn } from "@/lib/utils";
import { isWindows, isLinux } from "@/lib/platform";
import { AppSwitcher } from "@/components/AppSwitcher";
import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
@@ -60,7 +61,8 @@ type View =
| "agents"
| "universal";
const DRAG_BAR_HEIGHT = 28; // px
// macOS Overlay mode needs space for traffic light buttons, Windows/Linux use native titlebar
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
const HEADER_HEIGHT = 64; // px
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
@@ -622,10 +624,12 @@ function App() {
<Settings className="w-4 h-4" />
</Button>
</div>
<UpdateBadge onClick={() => {
setSettingsDefaultTab("about");
setCurrentView("settings");
}} />
<UpdateBadge
onClick={() => {
setSettingsDefaultTab("about");
setCurrentView("settings");
}}
/>
</>
)}
</div>
@@ -804,6 +808,7 @@ function App() {
{effectiveUsageProvider && (
<UsageScriptModal
key={effectiveUsageProvider.id}
provider={effectiveUsageProvider}
appId={activeApp}
isOpen={Boolean(usageProvider)}
+2 -1
View File
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { isWindows, isLinux } from "@/lib/platform";
interface FullScreenPanelProps {
isOpen: boolean;
@@ -12,7 +13,7 @@ interface FullScreenPanelProps {
footer?: React.ReactNode;
}
const DRAG_BAR_HEIGHT = 28; // px - match App.tsx
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px - match App.tsx
const HEADER_HEIGHT = 64; // px - match App.tsx
/**
@@ -236,7 +236,7 @@ export function ProviderForm({
} catch {
// ignore
}
return true;
return false; // OpenRouter now supports Claude Code compatible API, no need for transform
}, [isOpenRouterProvider, settingsConfigValue]);
const handleOpenRouterCompatChange = useCallback(
@@ -864,7 +864,7 @@ export function ProviderForm({
defaultOpusModel={defaultOpusModel}
onModelChange={handleModelChange}
speedTestEndpoints={speedTestEndpoints}
showOpenRouterCompatToggle={isOpenRouterProvider}
showOpenRouterCompatToggle={false}
openRouterCompatEnabled={openRouterCompatEnabled}
onOpenRouterCompatChange={handleOpenRouterCompatChange}
/>
@@ -43,11 +43,7 @@ export function useApiKeyState({
return;
}
// 仅当配置确实包含 API Key 字段时才同步(避免无意清空用户正在输入的 key
if (!hasApiKeyField(initialConfig, appType)) {
return;
}
// 从配置中提取 API Key(如果不存在则返回空字符串
const extracted = getApiKeyFromConfig(initialConfig, appType);
if (extracted !== apiKey) {
setApiKey(extracted);
@@ -41,8 +41,9 @@ export function useBaseUrlState({
try {
const config = JSON.parse(settingsConfig || "{}");
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
if (typeof envUrl === "string" && envUrl && envUrl.trim() !== baseUrl) {
setBaseUrl(envUrl.trim());
const nextUrl = typeof envUrl === "string" ? envUrl.trim() : "";
if (nextUrl !== baseUrl) {
setBaseUrl(nextUrl);
}
} catch {
// ignore
+180 -85
View File
@@ -21,52 +21,157 @@ export function AutoFailoverConfigPanel({
const { data: config, isLoading, error } = useAppProxyConfig(appType);
const updateConfig = useUpdateAppProxyConfig();
// 使用字符串状态以支持完全清空数字输入框
const [formData, setFormData] = useState({
autoFailoverEnabled: false,
maxRetries: 3,
streamingFirstByteTimeout: 30,
streamingIdleTimeout: 60,
nonStreamingTimeout: 300,
circuitFailureThreshold: 5,
circuitSuccessThreshold: 2,
circuitTimeoutSeconds: 60,
circuitErrorRateThreshold: 0.5,
circuitMinRequests: 10,
maxRetries: "3",
streamingFirstByteTimeout: "30",
streamingIdleTimeout: "60",
nonStreamingTimeout: "300",
circuitFailureThreshold: "5",
circuitSuccessThreshold: "2",
circuitTimeoutSeconds: "60",
circuitErrorRateThreshold: "50", // 存储百分比值
circuitMinRequests: "10",
});
useEffect(() => {
if (config) {
setFormData({
autoFailoverEnabled: config.autoFailoverEnabled,
maxRetries: config.maxRetries,
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
streamingIdleTimeout: config.streamingIdleTimeout,
nonStreamingTimeout: config.nonStreamingTimeout,
circuitFailureThreshold: config.circuitFailureThreshold,
circuitSuccessThreshold: config.circuitSuccessThreshold,
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
circuitMinRequests: config.circuitMinRequests,
maxRetries: String(config.maxRetries),
streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),
streamingIdleTimeout: String(config.streamingIdleTimeout),
nonStreamingTimeout: String(config.nonStreamingTimeout),
circuitFailureThreshold: String(config.circuitFailureThreshold),
circuitSuccessThreshold: String(config.circuitSuccessThreshold),
circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),
circuitErrorRateThreshold: String(
Math.round(config.circuitErrorRateThreshold * 100),
),
circuitMinRequests: String(config.circuitMinRequests),
});
}
}, [config]);
const handleSave = async () => {
if (!config) return;
// 解析数字,返回 NaN 表示无效输入
const parseNum = (val: string) => {
const trimmed = val.trim();
// 必须是纯数字
if (!/^-?\d+$/.test(trimmed)) return NaN;
return parseInt(trimmed);
};
// 定义各字段的有效范围
const ranges = {
maxRetries: { min: 0, max: 10 },
streamingFirstByteTimeout: { min: 0, max: 180 },
streamingIdleTimeout: { min: 0, max: 600 },
nonStreamingTimeout: { min: 0, max: 1800 },
circuitFailureThreshold: { min: 1, max: 20 },
circuitSuccessThreshold: { min: 1, max: 10 },
circuitTimeoutSeconds: { min: 0, max: 300 },
circuitErrorRateThreshold: { min: 0, max: 100 },
circuitMinRequests: { min: 5, max: 100 },
};
// 解析原始值
const raw = {
maxRetries: parseNum(formData.maxRetries),
streamingFirstByteTimeout: parseNum(formData.streamingFirstByteTimeout),
streamingIdleTimeout: parseNum(formData.streamingIdleTimeout),
nonStreamingTimeout: parseNum(formData.nonStreamingTimeout),
circuitFailureThreshold: parseNum(formData.circuitFailureThreshold),
circuitSuccessThreshold: parseNum(formData.circuitSuccessThreshold),
circuitTimeoutSeconds: parseNum(formData.circuitTimeoutSeconds),
circuitErrorRateThreshold: parseNum(formData.circuitErrorRateThreshold),
circuitMinRequests: parseNum(formData.circuitMinRequests),
};
// 校验是否超出范围(NaN 也视为无效)
const errors: string[] = [];
const checkRange = (
value: number,
range: { min: number; max: number },
label: string,
) => {
if (isNaN(value) || value < range.min || value > range.max) {
errors.push(`${label}: ${range.min}-${range.max}`);
}
};
checkRange(
raw.maxRetries,
ranges.maxRetries,
t("proxy.autoFailover.maxRetries", "最大重试次数"),
);
checkRange(
raw.streamingFirstByteTimeout,
ranges.streamingFirstByteTimeout,
t("proxy.autoFailover.streamingFirstByte", "流式首字节超时"),
);
checkRange(
raw.streamingIdleTimeout,
ranges.streamingIdleTimeout,
t("proxy.autoFailover.streamingIdle", "流式静默超时"),
);
checkRange(
raw.nonStreamingTimeout,
ranges.nonStreamingTimeout,
t("proxy.autoFailover.nonStreaming", "非流式超时"),
);
checkRange(
raw.circuitFailureThreshold,
ranges.circuitFailureThreshold,
t("proxy.autoFailover.failureThreshold", "失败阈值"),
);
checkRange(
raw.circuitSuccessThreshold,
ranges.circuitSuccessThreshold,
t("proxy.autoFailover.successThreshold", "恢复成功阈值"),
);
checkRange(
raw.circuitTimeoutSeconds,
ranges.circuitTimeoutSeconds,
t("proxy.autoFailover.timeout", "恢复等待时间"),
);
checkRange(
raw.circuitErrorRateThreshold,
ranges.circuitErrorRateThreshold,
t("proxy.autoFailover.errorRate", "错误率阈值"),
);
checkRange(
raw.circuitMinRequests,
ranges.circuitMinRequests,
t("proxy.autoFailover.minRequests", "最小请求数"),
);
if (errors.length > 0) {
toast.error(
t("proxy.autoFailover.validationFailed", {
fields: errors.join("; "),
defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
}),
);
return;
}
try {
await updateConfig.mutateAsync({
appType,
enabled: config.enabled,
autoFailoverEnabled: formData.autoFailoverEnabled,
maxRetries: formData.maxRetries,
streamingFirstByteTimeout: formData.streamingFirstByteTimeout,
streamingIdleTimeout: formData.streamingIdleTimeout,
nonStreamingTimeout: formData.nonStreamingTimeout,
circuitFailureThreshold: formData.circuitFailureThreshold,
circuitSuccessThreshold: formData.circuitSuccessThreshold,
circuitTimeoutSeconds: formData.circuitTimeoutSeconds,
circuitErrorRateThreshold: formData.circuitErrorRateThreshold,
circuitMinRequests: formData.circuitMinRequests,
maxRetries: raw.maxRetries,
streamingFirstByteTimeout: raw.streamingFirstByteTimeout,
streamingIdleTimeout: raw.streamingIdleTimeout,
nonStreamingTimeout: raw.nonStreamingTimeout,
circuitFailureThreshold: raw.circuitFailureThreshold,
circuitSuccessThreshold: raw.circuitSuccessThreshold,
circuitTimeoutSeconds: raw.circuitTimeoutSeconds,
circuitErrorRateThreshold: raw.circuitErrorRateThreshold / 100,
circuitMinRequests: raw.circuitMinRequests,
});
toast.success(
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
@@ -83,15 +188,17 @@ export function AutoFailoverConfigPanel({
if (config) {
setFormData({
autoFailoverEnabled: config.autoFailoverEnabled,
maxRetries: config.maxRetries,
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
streamingIdleTimeout: config.streamingIdleTimeout,
nonStreamingTimeout: config.nonStreamingTimeout,
circuitFailureThreshold: config.circuitFailureThreshold,
circuitSuccessThreshold: config.circuitSuccessThreshold,
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
circuitMinRequests: config.circuitMinRequests,
maxRetries: String(config.maxRetries),
streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),
streamingIdleTimeout: String(config.streamingIdleTimeout),
nonStreamingTimeout: String(config.nonStreamingTimeout),
circuitFailureThreshold: String(config.circuitFailureThreshold),
circuitSuccessThreshold: String(config.circuitSuccessThreshold),
circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),
circuitErrorRateThreshold: String(
Math.round(config.circuitErrorRateThreshold * 100),
),
circuitMinRequests: String(config.circuitMinRequests),
});
}
};
@@ -142,13 +249,9 @@ export function AutoFailoverConfigPanel({
min="0"
max="10"
value={formData.maxRetries}
onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({
...formData,
maxRetries: isNaN(val) ? 0 : val,
});
}}
onChange={(e) =>
setFormData({ ...formData, maxRetries: e.target.value })
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -169,13 +272,12 @@ export function AutoFailoverConfigPanel({
min="1"
max="20"
value={formData.circuitFailureThreshold}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
circuitFailureThreshold: isNaN(val) ? 1 : Math.max(1, val),
});
}}
circuitFailureThreshold: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -208,13 +310,12 @@ export function AutoFailoverConfigPanel({
min="0"
max="180"
value={formData.streamingFirstByteTimeout}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
streamingFirstByteTimeout: isNaN(val) ? 0 : val,
});
}}
streamingFirstByteTimeout: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -235,13 +336,12 @@ export function AutoFailoverConfigPanel({
min="0"
max="600"
value={formData.streamingIdleTimeout}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
streamingIdleTimeout: isNaN(val) ? 0 : val,
});
}}
streamingIdleTimeout: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -262,13 +362,12 @@ export function AutoFailoverConfigPanel({
min="0"
max="1800"
value={formData.nonStreamingTimeout}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
nonStreamingTimeout: isNaN(val) ? 0 : val,
});
}}
nonStreamingTimeout: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -298,13 +397,12 @@ export function AutoFailoverConfigPanel({
min="1"
max="10"
value={formData.circuitSuccessThreshold}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
circuitSuccessThreshold: isNaN(val) ? 1 : Math.max(1, val),
});
}}
circuitSuccessThreshold: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -322,16 +420,15 @@ export function AutoFailoverConfigPanel({
<Input
id={`timeoutSeconds-${appType}`}
type="number"
min="10"
min="0"
max="300"
value={formData.circuitTimeoutSeconds}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
circuitTimeoutSeconds: isNaN(val) ? 10 : Math.max(10, val),
});
}}
circuitTimeoutSeconds: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -352,14 +449,13 @@ export function AutoFailoverConfigPanel({
min="0"
max="100"
step="5"
value={Math.round(formData.circuitErrorRateThreshold * 100)}
onChange={(e) => {
const val = parseInt(e.target.value);
value={formData.circuitErrorRateThreshold}
onChange={(e) =>
setFormData({
...formData,
circuitErrorRateThreshold: isNaN(val) ? 0.5 : val / 100,
});
}}
circuitErrorRateThreshold: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -380,13 +476,12 @@ export function AutoFailoverConfigPanel({
min="5"
max="100"
value={formData.circuitMinRequests}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
circuitMinRequests: isNaN(val) ? 5 : Math.max(5, val),
});
}}
circuitMinRequests: e.target.value,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -7,42 +7,141 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
/**
* 熔断器配置面板
* 允许用户调整熔断器参数
*/
export function CircuitBreakerConfigPanel() {
const { t } = useTranslation();
const { data: config, isLoading } = useCircuitBreakerConfig();
const updateConfig = useUpdateCircuitBreakerConfig();
// 使用字符串状态以支持完全清空输入框
const [formData, setFormData] = useState({
failureThreshold: 5,
successThreshold: 2,
timeoutSeconds: 60,
errorRateThreshold: 0.5,
minRequests: 10,
failureThreshold: "5",
successThreshold: "2",
timeoutSeconds: "60",
errorRateThreshold: "50", // 存储百分比值
minRequests: "10",
});
// 当配置加载完成时更新表单数据
useEffect(() => {
if (config) {
setFormData(config);
setFormData({
failureThreshold: String(config.failureThreshold),
successThreshold: String(config.successThreshold),
timeoutSeconds: String(config.timeoutSeconds),
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
minRequests: String(config.minRequests),
});
}
}, [config]);
const handleSave = async () => {
// 解析数字,返回 NaN 表示无效输入
const parseNum = (val: string) => {
const trimmed = val.trim();
// 必须是纯数字
if (!/^-?\d+$/.test(trimmed)) return NaN;
return parseInt(trimmed);
};
// 定义各字段的有效范围
const ranges = {
failureThreshold: { min: 1, max: 20 },
successThreshold: { min: 1, max: 10 },
timeoutSeconds: { min: 0, max: 300 },
errorRateThreshold: { min: 0, max: 100 },
minRequests: { min: 5, max: 100 },
};
// 解析原始值
const raw = {
failureThreshold: parseNum(formData.failureThreshold),
successThreshold: parseNum(formData.successThreshold),
timeoutSeconds: parseNum(formData.timeoutSeconds),
errorRateThreshold: parseNum(formData.errorRateThreshold),
minRequests: parseNum(formData.minRequests),
};
// 校验是否超出范围(NaN 也视为无效)
const errors: string[] = [];
const checkRange = (
value: number,
range: { min: number; max: number },
label: string,
) => {
if (isNaN(value) || value < range.min || value > range.max) {
errors.push(`${label}: ${range.min}-${range.max}`);
}
};
checkRange(
raw.failureThreshold,
ranges.failureThreshold,
t("circuitBreaker.failureThreshold", "失败阈值"),
);
checkRange(
raw.successThreshold,
ranges.successThreshold,
t("circuitBreaker.successThreshold", "成功阈值"),
);
checkRange(
raw.timeoutSeconds,
ranges.timeoutSeconds,
t("circuitBreaker.timeoutSeconds", "超时时间"),
);
checkRange(
raw.errorRateThreshold,
ranges.errorRateThreshold,
t("circuitBreaker.errorRateThreshold", "错误率阈值"),
);
checkRange(
raw.minRequests,
ranges.minRequests,
t("circuitBreaker.minRequests", "最小请求数"),
);
if (errors.length > 0) {
toast.error(
t("circuitBreaker.validationFailed", {
fields: errors.join("; "),
defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
}),
);
return;
}
try {
await updateConfig.mutateAsync(formData);
toast.success("熔断器配置已保存", { closeButton: true });
await updateConfig.mutateAsync({
failureThreshold: raw.failureThreshold,
successThreshold: raw.successThreshold,
timeoutSeconds: raw.timeoutSeconds,
errorRateThreshold: raw.errorRateThreshold / 100,
minRequests: raw.minRequests,
});
toast.success(t("circuitBreaker.configSaved", "熔断器配置已保存"), {
closeButton: true,
});
} catch (error) {
toast.error("保存失败: " + String(error));
toast.error(
t("circuitBreaker.saveFailed", "保存失败") + ": " + String(error),
);
}
};
const handleReset = () => {
if (config) {
setFormData(config);
setFormData({
failureThreshold: String(config.failureThreshold),
successThreshold: String(config.successThreshold),
timeoutSeconds: String(config.timeoutSeconds),
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
minRequests: String(config.minRequests),
});
}
};
@@ -72,10 +171,7 @@ export function CircuitBreakerConfigPanel() {
max="20"
value={formData.failureThreshold}
onChange={(e) =>
setFormData({
...formData,
failureThreshold: parseInt(e.target.value) || 5,
})
setFormData({ ...formData, failureThreshold: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
@@ -89,14 +185,11 @@ export function CircuitBreakerConfigPanel() {
<Input
id="timeoutSeconds"
type="number"
min="10"
min="0"
max="300"
value={formData.timeoutSeconds}
onChange={(e) =>
setFormData({
...formData,
timeoutSeconds: parseInt(e.target.value) || 60,
})
setFormData({ ...formData, timeoutSeconds: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
@@ -114,10 +207,7 @@ export function CircuitBreakerConfigPanel() {
max="10"
value={formData.successThreshold}
onChange={(e) =>
setFormData({
...formData,
successThreshold: parseInt(e.target.value) || 2,
})
setFormData({ ...formData, successThreshold: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
@@ -134,12 +224,9 @@ export function CircuitBreakerConfigPanel() {
min="0"
max="100"
step="5"
value={Math.round(formData.errorRateThreshold * 100)}
value={formData.errorRateThreshold}
onChange={(e) =>
setFormData({
...formData,
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
})
setFormData({ ...formData, errorRateThreshold: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
@@ -157,10 +244,7 @@ export function CircuitBreakerConfigPanel() {
max="100"
value={formData.minRequests}
onChange={(e) =>
setFormData({
...formData,
minRequests: parseInt(e.target.value) || 10,
})
setFormData({ ...formData, minRequests: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
+72 -16
View File
@@ -38,15 +38,15 @@ export function ProxyPanel() {
const { data: globalConfig } = useGlobalProxyConfig();
const updateGlobalConfig = useUpdateGlobalProxyConfig();
// 监听地址/端口的本地状态
// 监听地址/端口的本地状态(端口用字符串以支持完全清空)
const [listenAddress, setListenAddress] = useState("127.0.0.1");
const [listenPort, setListenPort] = useState(15721);
const [listenPort, setListenPort] = useState("15721");
// 同步全局配置到本地状态
useEffect(() => {
if (globalConfig) {
setListenAddress(globalConfig.listenAddress);
setListenPort(globalConfig.listenPort);
setListenPort(String(globalConfig.listenPort));
}
}, [globalConfig]);
@@ -102,12 +102,57 @@ export function ProxyPanel() {
const handleSaveBasicConfig = async () => {
if (!globalConfig) return;
// 校验地址格式(简单的 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) => {
const num = parseInt(n);
return num >= 0 && num <= 255;
}));
if (!isValidAddress) {
toast.error(
t("proxy.settings.invalidAddress", {
defaultValue:
"地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost",
}),
);
return;
}
// 严格校验端口:必须是纯数字
const portTrimmed = listenPort.trim();
if (!/^\d+$/.test(portTrimmed)) {
toast.error(
t("proxy.settings.invalidPort", {
defaultValue: "端口无效,请输入 1024-65535 之间的数字",
}),
);
return;
}
const port = parseInt(portTrimmed);
if (isNaN(port) || port < 1024 || port > 65535) {
toast.error(
t("proxy.settings.invalidPort", {
defaultValue: "端口无效,请输入 1024-65535 之间的数字",
}),
);
return;
}
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
listenAddress,
listenPort,
listenAddress: normalizedAddress,
listenPort: port,
});
// 同步更新本地状态为规范化后的值
setListenAddress(normalizedAddress);
toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
{ closeButton: true },
@@ -133,6 +178,13 @@ export function ProxyPanel() {
}
};
// 格式化地址用于 URL(IPv6 需要方括号)
const formatAddressForUrl = (address: string, port: number): string => {
const isIPv6 = address.includes(":");
const host = isIPv6 ? `[${address}]` : address;
return `http://${host}:${port}`;
};
return (
<>
<section className="space-y-6">
@@ -147,14 +199,14 @@ export function ProxyPanel() {
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
http://{status.address}:{status.port}
{formatAddressForUrl(status.address, status.port)}
</code>
<Button
size="sm"
variant="outline"
onClick={() => {
navigator.clipboard.writeText(
`http://${status.address}:${status.port}`,
formatAddressForUrl(status.address, status.port),
);
toast.success(
t("proxy.panel.addressCopied", {
@@ -389,9 +441,12 @@ export function ProxyPanel() {
id="listen-address"
value={listenAddress}
onChange={(e) => setListenAddress(e.target.value)}
placeholder={t("proxy.settings.fields.listenAddress.placeholder", {
defaultValue: "127.0.0.1",
})}
placeholder={t(
"proxy.settings.fields.listenAddress.placeholder",
{
defaultValue: "127.0.0.1",
},
)}
/>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenAddress.description", {
@@ -411,12 +466,13 @@ export function ProxyPanel() {
id="listen-port"
type="number"
value={listenPort}
onChange={(e) =>
setListenPort(parseInt(e.target.value) || 15721)
}
placeholder={t("proxy.settings.fields.listenPort.placeholder", {
defaultValue: "15721",
})}
onChange={(e) => setListenPort(e.target.value)}
placeholder={t(
"proxy.settings.fields.listenPort.placeholder",
{
defaultValue: "15721",
},
)}
/>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenPort.description", {
+30 -18
View File
@@ -17,10 +17,11 @@ export function ModelTestConfigPanel() {
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [config, setConfig] = useState<StreamCheckConfig>({
timeoutSecs: 45,
maxRetries: 2,
degradedThresholdMs: 6000,
// 使用字符串状态以支持完全清空数字输入框
const [config, setConfig] = useState({
timeoutSecs: "45",
maxRetries: "2",
degradedThresholdMs: "6000",
claudeModel: "claude-haiku-4-5-20251001",
codexModel: "gpt-5.1-codex@low",
geminiModel: "gemini-3-pro-preview",
@@ -35,7 +36,14 @@ export function ModelTestConfigPanel() {
setIsLoading(true);
setError(null);
const data = await getStreamCheckConfig();
setConfig(data);
setConfig({
timeoutSecs: String(data.timeoutSecs),
maxRetries: String(data.maxRetries),
degradedThresholdMs: String(data.degradedThresholdMs),
claudeModel: data.claudeModel,
codexModel: data.codexModel,
geminiModel: data.geminiModel,
});
} catch (e) {
setError(String(e));
} finally {
@@ -44,9 +52,22 @@ export function ModelTestConfigPanel() {
}
async function handleSave() {
// 解析数字,空值使用默认值,0 是有效值
const parseNum = (val: string, defaultVal: number) => {
const n = parseInt(val);
return isNaN(n) ? defaultVal : n;
};
try {
setIsSaving(true);
await saveStreamCheckConfig(config);
const parsed: StreamCheckConfig = {
timeoutSecs: parseNum(config.timeoutSecs, 45),
maxRetries: parseNum(config.maxRetries, 2),
degradedThresholdMs: parseNum(config.degradedThresholdMs, 6000),
claudeModel: config.claudeModel,
codexModel: config.codexModel,
geminiModel: config.geminiModel,
};
await saveStreamCheckConfig(parsed);
toast.success(t("streamCheck.configSaved"), {
closeButton: true,
});
@@ -132,10 +153,7 @@ export function ModelTestConfigPanel() {
max={120}
value={config.timeoutSecs}
onChange={(e) =>
setConfig({
...config,
timeoutSecs: parseInt(e.target.value) || 45,
})
setConfig({ ...config, timeoutSecs: e.target.value })
}
/>
</div>
@@ -149,10 +167,7 @@ export function ModelTestConfigPanel() {
max={5}
value={config.maxRetries}
onChange={(e) =>
setConfig({
...config,
maxRetries: parseInt(e.target.value) || 2,
})
setConfig({ ...config, maxRetries: e.target.value })
}
/>
</div>
@@ -169,10 +184,7 @@ export function ModelTestConfigPanel() {
step={1000}
value={config.degradedThresholdMs}
onChange={(e) =>
setConfig({
...config,
degradedThresholdMs: parseInt(e.target.value) || 6000,
})
setConfig({ ...config, degradedThresholdMs: e.target.value })
}
/>
</div>
+18
View File
@@ -394,6 +394,24 @@ export const providerPresets: ProviderPreset[] = [
icon: "cubence",
iconColor: "#000000",
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.aigocode.com/api",
ANTHROPIC_AUTH_TOKEN: "",
},
},
// 请求地址候选(用于地址管理/测速)
endpointCandidates: ["https://api.aigocode.com"],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "aigocode", // 促销信息 i18n key
icon: "aigocode",
iconColor: "#5B7FFF",
},
{
name: "OpenRouter",
websiteUrl: "https://openrouter.ai",
+19 -2
View File
@@ -171,10 +171,27 @@ requires_openai_auth = true`,
],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "cubence", // 促销信息 i18n key
partnerPromotionKey: "cubence", // 促销信息 i18n key
icon: "cubence",
iconColor: "#000000",
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
category: "third_party",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"aigocode",
"https://api.aigocode.com/openai",
"gpt-5.2",
),
endpointCandidates: ["https://api.aigocode.com"],
isPartner: true, // 合作伙伴
partnerPromotionKey: "aigocode", // 促销信息 i18n key
icon: "aigocode",
iconColor: "#5B7FFF",
},
{
name: "OpenRouter",
websiteUrl: "https://openrouter.ai",
@@ -188,5 +205,5 @@ requires_openai_auth = true`,
category: "aggregator",
icon: "openrouter",
iconColor: "#6566F1",
},
},
];
+21 -1
View File
@@ -96,6 +96,26 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
icon: "cubence",
iconColor: "#000000",
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.com/gemini",
GEMINI_MODEL: "gemini-3-pro",
},
},
baseURL: "https://api.aigocode.com/gemini",
model: "gemini-3-pro",
description: "AIGoCode",
category: "third_party",
isPartner: true,
partnerPromotionKey: "aigocode",
endpointCandidates: ["https://api.aigocode.com/gemini"],
icon: "aigocode",
iconColor: "#5B7FFF",
},
{
name: "OpenRouter",
websiteUrl: "https://openrouter.ai",
@@ -105,7 +125,7 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
GOOGLE_GEMINI_BASE_URL: "https://openrouter.ai/api",
GEMINI_MODEL: "gemini-3-pro-preview",
},
},
},
baseURL: "https://openrouter.ai/api",
model: "gemini-3-pro",
description: "OpenRouter",
+19 -2
View File
@@ -324,7 +324,8 @@
"minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
"dmxapi": "Claude Code exclusive model 66% OFF now!",
"cubence": "Cubence is an official partner of CC Switch. Register using this link and enter \"CCSWITCH\" promo code during recharge to get 10% off every top-up"
"cubence": "Cubence is an official partner of CC Switch. Register using this link and enter \"CCSWITCH\" promo code during recharge to get 10% off every top-up",
"aigocode": "AIGoCode is an official partner of CC Switch. Register using this link and get 10% bonus credit on your first top-up!"
},
"parameterConfig": "Parameter Config - {{name}} *",
"mainModel": "Main Model (optional)",
@@ -1106,7 +1107,12 @@
"toast": {
"saved": "Proxy configuration saved",
"saveFailed": "Save failed: {{error}}"
}
},
"invalidPort": "Invalid port, please enter a number between 1024-65535",
"invalidAddress": "Invalid address, please enter a valid IP address (e.g. 127.0.0.1) or localhost",
"configSaved": "Proxy configuration saved",
"configSaveFailed": "Failed to save configuration",
"restartRequired": "Restart proxy service for address or port changes to take effect"
},
"switchFailed": "Switch failed: {{error}}",
"failover": {
@@ -1133,6 +1139,7 @@
"info": "When the failover queue has multiple providers, the system will try them in priority order when requests fail. When a provider reaches the consecutive failure threshold, the circuit breaker will open and skip it temporarily.",
"configSaved": "Auto failover config saved",
"configSaveFailed": "Failed to save",
"validationFailed": "The following fields are out of valid range: {{fields}}",
"retrySettings": "Retry & Timeout Settings",
"failureThreshold": "Failure Threshold",
"failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)",
@@ -1184,6 +1191,16 @@
"streamingIdle": "Streaming Idle Timeout",
"nonStreaming": "Non-Streaming Timeout"
},
"circuitBreaker": {
"failureThreshold": "Failure Threshold",
"successThreshold": "Success Threshold",
"timeoutSeconds": "Timeout (seconds)",
"errorRateThreshold": "Error Rate Threshold",
"minRequests": "Min Requests",
"validationFailed": "The following fields are out of valid range: {{fields}}",
"configSaved": "Circuit breaker config saved",
"saveFailed": "Failed to save"
},
"universalProvider": {
"title": "Universal Provider",
"description": "Universal providers manage Claude, Codex, and Gemini configurations simultaneously. Changes are automatically synced to all enabled apps.",
+2 -1
View File
@@ -324,7 +324,8 @@
"minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から",
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $280% OFF",
"dmxapi": "Claude Code 専用モデル 66% OFF 実施中!",
"cubence": "Cubence は CC Switch の公式パートナーです。登録後チャージ時に \"CCSWITCH\" を入力すると、毎回 10% オフ"
"cubence": "Cubence は CC Switch の公式パートナーです。登録後チャージ時に \"CCSWITCH\" を入力すると、毎回 10% オフ",
"aigocode": "AIGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます!"
},
"parameterConfig": "パラメーター設定 - {{name}} *",
"mainModel": "メインモデル(任意)",
+19 -2
View File
@@ -324,7 +324,8 @@
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起",
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)",
"dmxapi": "Claude Code 专属模型 3.4 折优惠进行中!",
"cubence": "Cubence 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"CCSWITCH\" 优惠码,每次充值均可享受9折优惠"
"cubence": "Cubence 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"CCSWITCH\" 优惠码,每次充值均可享受9折优惠",
"aigocode": "AIGoCode 是 CC Switch 的官方合作伙伴,使用此链接注册首次充值时可以获得10%额度奖励!"
},
"parameterConfig": "参数配置 - {{name}} *",
"mainModel": "主模型 (可选)",
@@ -1106,7 +1107,12 @@
"toast": {
"saved": "代理配置已保存",
"saveFailed": "保存失败: {{error}}"
}
},
"invalidPort": "端口无效,请输入 1024-65535 之间的数字",
"invalidAddress": "地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost",
"configSaved": "代理配置已保存",
"configSaveFailed": "保存配置失败",
"restartRequired": "修改地址或端口后需要重启代理服务才能生效"
},
"switchFailed": "切换失败: {{error}}",
"failover": {
@@ -1133,6 +1139,7 @@
"info": "当故障转移队列中配置了多个供应商时,系统会在请求失败时按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会打开并在一段时间内跳过该供应商。",
"configSaved": "自动故障转移配置已保存",
"configSaveFailed": "保存失败",
"validationFailed": "以下字段超出有效范围: {{fields}}",
"retrySettings": "重试与超时设置",
"failureThreshold": "失败阈值",
"failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)",
@@ -1184,6 +1191,16 @@
"streamingIdle": "流式静默超时",
"nonStreaming": "非流式超时"
},
"circuitBreaker": {
"failureThreshold": "失败阈值",
"successThreshold": "成功阈值",
"timeoutSeconds": "超时时间",
"errorRateThreshold": "错误率阈值",
"minRequests": "最小请求数",
"validationFailed": "以下字段超出有效范围: {{fields}}",
"configSaved": "熔断器配置已保存",
"saveFailed": "保存失败"
},
"universalProvider": {
"title": "统一供应商",
"description": "统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。",
+51
View File
@@ -0,0 +1,51 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="648.000000pt" height="564.000000pt" viewBox="0 0 648.000000 564.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,564.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M5392 5379 c-26 -10 -36 -28 -56 -108 -24 -94 -47 -140 -101 -199
-56 -62 -117 -96 -219 -121 -101 -25 -116 -35 -116 -75 0 -45 21 -60 123 -89
190 -53 271 -147 336 -384 13 -46 38 -61 85 -49 25 6 36 28 56 111 7 28 23 72
36 99 13 28 21 54 18 58 -3 5 -2 7 3 6 4 -2 29 18 55 44 41 41 145 110 173
114 56 8 126 27 131 36 4 6 7 30 8 54 1 49 -5 54 -104 74 -164 33 -280 148
-320 320 -23 101 -52 129 -108 109z"/>
<path d="M1770 4814 c-138 -25 -301 -93 -425 -177 -80 -55 -227 -197 -280
-272 -98 -138 -161 -279 -201 -447 -16 -66 -18 -164 -21 -1098 -4 -1130 -4
-1144 57 -1331 80 -242 222 -434 444 -598 l56 -42 0 -337 c0 -309 2 -340 19
-379 31 -66 87 -93 158 -74 22 6 202 117 404 249 200 131 398 259 439 285 144
89 48 81 1095 88 912 5 932 6 1012 27 243 64 441 178 599 344 166 176 274 408
304 648 13 110 13 2016 0 2120 -25 190 -83 347 -183 498 -160 240 -384 400
-677 484 l-75 22 -1325 2 c-1086 2 -1339 0 -1400 -12z m1068 -1455 l-3 -751
-71 -18 c-71 -18 -154 -55 -205 -91 -14 -10 -29 -16 -33 -12 -3 3 -6 91 -6
195 l0 188 -306 0 -305 0 -21 -47 c-11 -27 -33 -75 -48 -108 -16 -33 -44 -96
-62 -140 l-34 -80 -172 -3 c-101 -1 -172 1 -172 7 0 5 14 40 31 78 31 68 101
227 254 578 335 769 358 814 434 874 94 74 122 79 449 80 l272 1 -2 -751z
m794 717 c41 -13 103 -42 139 -62 67 -40 202 -168 232 -221 l17 -31 -77 -65
c-43 -35 -101 -80 -129 -101 l-52 -36 -39 57 c-79 116 -204 177 -313 154 -66
-14 -105 -42 -130 -91 -19 -37 -20 -60 -20 -400 0 -339 1 -363 20 -399 26 -51
61 -78 128 -97 143 -42 327 52 366 186 l13 45 -163 3 -163 2 -3 157 c-2 111 0
157 9 160 6 2 263 3 570 1 487 -3 562 -5 593 -19 98 -45 125 -159 58 -244 -39
-50 -66 -55 -322 -55 l-236 0 -6 -27 c-18 -83 -29 -115 -62 -183 -69 -143
-230 -269 -402 -316 -81 -22 -271 -25 -350 -5 -173 43 -289 145 -341 298 -19
57 -20 81 -17 534 l3 474 33 67 c55 112 168 199 307 235 79 20 246 10 337 -21z
m828 -1515 c67 -71 67 -73 -62 -396 -45 -110 -102 -254 -128 -320 -254 -639
-272 -681 -298 -702 -74 -62 -192 -15 -192 77 0 12 39 116 86 233 48 117 97
239 110 272 119 311 336 833 354 852 36 37 85 32 130 -16z m-1574 -205 c16
-13 19 -29 22 -128 l3 -113 -195 -155 c-108 -85 -196 -158 -196 -161 0 -4 17
-19 38 -35 128 -96 327 -272 339 -301 16 -39 17 -143 2 -177 -15 -33 -34 -39
-67 -22 -48 26 -566 446 -584 474 -23 35 -23 89 1 128 16 27 150 143 376 326
39 31 79 65 90 75 44 41 128 103 139 103 7 0 21 -6 32 -14z m713 -25 c52 -37
53 -33 -92 -551 -36 -129 -80 -289 -98 -355 -39 -143 -62 -175 -129 -175 -47
0 -92 20 -114 52 -24 34 -19 90 18 217 19 64 78 271 131 461 53 190 98 353
101 364 6 16 14 18 79 14 54 -4 81 -11 104 -27z m1116 -351 c55 -45 117 -98
138 -120 61 -64 48 -115 -50 -192 -32 -25 -118 -94 -191 -152 -136 -108 -163
-120 -217 -100 -26 10 -47 62 -39 97 10 46 22 62 94 119 36 29 93 77 128 106
l62 55 -65 59 c-71 65 -77 84 -49 141 24 47 44 67 68 67 12 0 66 -36 121 -80z"/>
<path d="M2328 3755 c-37 -20 -54 -53 -153 -280 -48 -110 -96 -219 -106 -242
l-18 -43 234 0 235 0 0 290 0 290 -82 0 c-53 -1 -93 -6 -110 -15z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+1
View File
@@ -2,6 +2,7 @@
// Do not edit manually
export const icons: Record<string, string> = {
aigocode: `<svg height="1em" width="1em" style="flex:none;line-height:1" viewBox="0 0 648 564" xmlns="http://www.w3.org/2000/svg"><title>AiGoCode</title><g transform="translate(0,564) scale(0.1,-0.1)"><path fill="#7C6AEF" d="M5392 5379 c-26 -10 -36 -28 -56 -108 -24 -94 -47 -140 -101 -199 -56 -62 -117 -96 -219 -121 -101 -25 -116 -35 -116 -75 0 -45 21 -60 123 -89 190 -53 271 -147 336 -384 13 -46 38 -61 85 -49 25 6 36 28 56 111 7 28 23 72 36 99 13 28 21 54 18 58 -3 5 -2 7 3 6 4 -2 29 18 55 44 41 41 145 110 173 114 56 8 126 27 131 36 4 6 7 30 8 54 1 49 -5 54 -104 74 -164 33 -280 148 -320 320 -23 101 -52 129 -108 109z"/><path fill="#5B7FFF" d="M1770 4814 c-138 -25 -301 -93 -425 -177 -80 -55 -227 -197 -280 -272 -98 -138 -161 -279 -201 -447 -16 -66 -18 -164 -21 -1098 -4 -1130 -4 -1144 57 -1331 80 -242 222 -434 444 -598 l56 -42 0 -337 c0 -309 2 -340 19 -379 31 -66 87 -93 158 -74 22 6 202 117 404 249 200 131 398 259 439 285 144 89 48 81 1095 88 912 5 932 6 1012 27 243 64 441 178 599 344 166 176 274 408 304 648 13 110 13 2016 0 2120 -25 190 -83 347 -183 498 -160 240 -384 400 -677 484 l-75 22 -1325 2 c-1086 2 -1339 0 -1400 -12z m1068 -1455 l-3 -751 -71 -18 c-71 -18 -154 -55 -205 -91 -14 -10 -29 -16 -33 -12 -3 3 -6 91 -6 195 l0 188 -306 0 -305 0 -21 -47 c-11 -27 -33 -75 -48 -108 -16 -33 -44 -96 -62 -140 l-34 -80 -172 -3 c-101 -1 -172 1 -172 7 0 5 14 40 31 78 31 68 101 227 254 578 335 769 358 814 434 874 94 74 122 79 449 80 l272 1 -2 -751z m794 717 c41 -13 103 -42 139 -62 67 -40 202 -168 232 -221 l17 -31 -77 -65 c-43 -35 -101 -80 -129 -101 l-52 -36 -39 57 c-79 116 -204 177 -313 154 -66 -14 -105 -42 -130 -91 -19 -37 -20 -60 -20 -400 0 -339 1 -363 20 -399 26 -51 61 -78 128 -97 143 -42 327 52 366 186 l13 45 -163 3 -163 2 -3 157 c-2 111 0 157 9 160 6 2 263 3 570 1 487 -3 562 -5 593 -19 98 -45 125 -159 58 -244 -39 -50 -66 -55 -322 -55 l-236 0 -6 -27 c-18 -83 -29 -115 -62 -183 -69 -143 -230 -269 -402 -316 -81 -22 -271 -25 -350 -5 -173 43 -289 145 -341 298 -19 57 -20 81 -17 534 l3 474 33 67 c55 112 168 199 307 235 79 20 246 10 337 -21z m828 -1515 c67 -71 67 -73 -62 -396 -45 -110 -102 -254 -128 -320 -254 -639 -272 -681 -298 -702 -74 -62 -192 -15 -192 77 0 12 39 116 86 233 48 117 97 239 110 272 119 311 336 833 354 852 36 37 85 32 130 -16z m-1574 -205 c16 -13 19 -29 22 -128 l3 -113 -195 -155 c-108 -85 -196 -158 -196 -161 0 -4 17 -19 38 -35 128 -96 327 -272 339 -301 16 -39 17 -143 2 -177 -15 -33 -34 -39 -67 -22 -48 26 -566 446 -584 474 -23 35 -23 89 1 128 16 27 150 143 376 326 39 31 79 65 90 75 44 41 128 103 139 103 7 0 21 -6 32 -14z m713 -25 c52 -37 53 -33 -92 -551 -36 -129 -80 -289 -98 -355 -39 -143 -62 -175 -129 -175 -47 0 -92 20 -114 52 -24 34 -19 90 18 217 19 64 78 271 131 461 53 190 98 353 101 364 6 16 14 18 79 14 54 -4 81 -11 104 -27z m1116 -351 c55 -45 117 -98 138 -120 61 -64 48 -115 -50 -192 -32 -25 -118 -94 -191 -152 -136 -108 -163 -120 -217 -100 -26 10 -47 62 -39 97 10 46 22 62 94 119 36 29 93 77 128 106 l62 55 -65 59 c-71 65 -77 84 -49 141 24 47 44 67 68 67 12 0 66 -36 121 -80z"/><path fill="#5B7FFF" d="M2328 3755 c-37 -20 -54 -53 -153 -280 -48 -110 -96 -219 -106 -242 l-18 -43 234 0 235 0 0 290 0 290 -82 0 c-53 -1 -93 -6 -110 -15z"/></g></svg>`,
alibaba: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Alibaba</title><path d="M24 14.014c-2.8 1.512-5.62 2.896-8.759 3.524-.7.139-1.476.139-2.187.043-.678-.085-1.017-.682-.776-1.31.23-.585.536-1.181.93-1.671.852-1.065 1.814-2.034 2.678-3.088a15.75 15.75 0 001.422-2.054c.306-.511.164-1.129-.372-1.384-.897-.437-1.859-.745-2.81-1.075-.11-.043-.274.074-.492.149.273.244.47.425.743.67-2.821.48-5.49 1.16-8.08 2.098-.012.053-.033.095-.023.117.383.585.208 1.032-.35 1.394a2.365 2.365 0 00-.568.522c1.706.5 3.226.213 4.68-.735-.087-.127-.175-.244-.262-.372.546.096.874.394.918.862.011.107-.054.213-.087.32-.077-.086-.175-.17-.24-.267-.045-.064-.056-.138-.088-.245-1.728 1.15-3.587 1.438-5.632.842 0 .404-.022.745.011 1.075.022.287-.098.415-.36.564-.591.362-1.204.735-1.696 1.214-.59.585-.371 1.299.427 1.597.907.34 1.859.35 2.81.234 1.126-.139 2.23-.32 3.456-.49-1.433.67-2.844 1.14-4.33 1.33-1.04.14-2.078.214-3.106-.084-1.476-.415-2.133-1.501-1.75-2.96.361-1.363 1.236-2.449 2.176-3.45 3.139-3.332 7.108-5.024 11.7-5.365 1.072-.074 2.155.064 3.16.511 1.411.639 2.002 1.99 1.313 3.354-.448.905-1.072 1.735-1.695 2.555-.612.809-1.301 1.554-1.946 2.331-.186.234-.361.48-.503.745-.274.5-.088.83.492.778 1.213-.118 2.45-.213 3.62-.511 1.716-.437 3.389-1.054 5.084-1.597.175-.043.339-.107.492-.17z" fill="#FF6003" fill-rule="evenodd"></path></svg>`,
anthropic: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>`,
aws: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AWS</title><path d="M6.763 11.212c0 .296.032.535.088.71.064.176.144.368.256.576.04.063.056.127.056.183 0 .08-.048.16-.152.24l-.503.335a.383.383 0 01-.208.072c-.08 0-.16-.04-.239-.112a2.47 2.47 0 01-.287-.375 6.18 6.18 0 01-.248-.471c-.622.734-1.405 1.101-2.347 1.101-.67 0-1.205-.191-1.596-.574-.39-.384-.59-.894-.59-1.533 0-.678.24-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583c0-.607-.127-1.03-.375-1.277-.255-.248-.686-.367-1.3-.367-.28 0-.568.031-.863.103-.295.072-.583.16-.862.272a2.4 2.4 0 01-.28.104.488.488 0 01-.127.023c-.112 0-.168-.08-.168-.247v-.391c0-.128.016-.224.056-.28a.597.597 0 01.224-.167 4.577 4.577 0 011.005-.36 4.84 4.84 0 011.246-.151c.95 0 1.644.216 2.091.647.44.43.662 1.085.662 1.963v2.586h.016zm-3.24 1.214c.263 0 .534-.048.822-.144a1.78 1.78 0 00.758-.51 1.27 1.27 0 00.272-.512c.047-.191.08-.423.08-.694v-.335a6.66 6.66 0 00-.735-.136 6.02 6.02 0 00-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296zm6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 6.726a1.398 1.398 0 01-.072-.32c0-.128.064-.2.191-.2h.783c.151 0 .255.025.31.08.065.048.113.16.16.312l1.342 5.284 1.245-5.284c.04-.16.088-.264.151-.312a.549.549 0 01.32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348c.048-.16.104-.264.16-.312a.52.52 0 01.311-.08h.743c.127 0 .2.065.2.2 0 .04-.009.08-.017.128a1.137 1.137 0 01-.056.2l-1.923 6.17c-.048.16-.104.263-.168.311a.51.51 0 01-.303.08h-.687c-.15 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32L12.32 7.747l-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08l-.686.001zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.563.563 0 01-.048-.224v-.407c0-.167.064-.247.183-.247.048 0 .096.008.144.024.048.016.12.048.2.08.271.12.566.215.878.279.32.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 00.415-.758.777.777 0 00-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.902 1.902 0 01-.4-1.158c0-.335.073-.63.216-.886.144-.255.335-.479.575-.654.24-.184.51-.32.83-.415.32-.096.655-.136 1.006-.136.175 0 .36.008.535.032.183.024.35.056.518.088.16.04.312.08.455.127.144.048.256.096.336.144a.69.69 0 01.24.2.43.43 0 01.071.263v.375c0 .168-.064.256-.184.256a.83.83 0 01-.303-.096 3.652 3.652 0 00-1.532-.311c-.455 0-.815.071-1.062.223-.248.152-.375.383-.375.71 0 .224.08.416.24.567.16.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767.247.327.367.702.367 1.117 0 .343-.072.655-.207.926a2.157 2.157 0 01-.583.703c-.248.2-.543.343-.886.447-.36.111-.734.167-1.142.167z"></path><path d="M.378 15.475c3.384 1.963 7.56 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.44-.2.814.287.383.607-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.272-.351zm23.531-.2c.287.36-.08 2.826-1.485 4.007-.215.184-.423.088-.327-.151l.175-.439c.343-.88.802-2.198.52-2.555-.336-.43-2.22-.207-3.074-.103-.255.032-.295-.192-.063-.36 1.5-1.053 3.967-.75 4.254-.399z" fill="#F90"></path></svg>`,
+7
View File
@@ -2,6 +2,13 @@
import { IconMetadata } from "@/types/icon";
export const iconMetadata: Record<string, IconMetadata> = {
aigocode: {
name: "aigocode",
displayName: "AIGoCode",
category: "ai-provider",
keywords: ["aigocode", "aigo", "code", "third-party"],
defaultColor: "#5B7FFF",
},
alibaba: {
name: "alibaba",
displayName: "Alibaba",