Compare commits

..

16 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
42 changed files with 794 additions and 504 deletions
+1 -1
View File
@@ -701,7 +701,7 @@ dependencies = [
[[package]] [[package]]
name = "cc-switch" name = "cc-switch"
version = "3.9.0" version = "3.9.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
+9 -1
View File
@@ -9,13 +9,21 @@ use serde_json::Value;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Codex 配置目录路径 /// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf { pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() { if let Some(custom) = crate::settings::get_codex_override_dir() {
return custom; return custom;
} }
dirs::home_dir().expect("无法获取用户主目录").join(".codex") get_home_dir().join(".codex")
} }
/// 获取 Codex auth.json 路径 /// 获取 Codex auth.json 路径
+8 -3
View File
@@ -1,6 +1,8 @@
#![allow(non_snake_case)] #![allow(non_snake_case)]
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload}; use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
use once_cell::sync::Lazy;
use regex::Regex;
use tauri::AppHandle; use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt; 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 { fn extract_version(raw: &str) -> String {
// 匹配 semver 格式: x.y.z 或 x.y.z-xxx VERSION_RE
let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").unwrap(); .find(raw)
re.find(raw)
.map(|m| m.as_str().to_string()) .map(|m| m.as_str().to_string())
.unwrap_or_else(|| raw.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; use crate::error::AppError;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Claude Code 配置目录路径 /// 获取 Claude Code 配置目录路径
pub fn get_claude_config_dir() -> PathBuf { pub fn get_claude_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_claude_override_dir() { if let Some(custom) = crate::settings::get_claude_override_dir() {
return custom; return custom;
} }
dirs::home_dir() get_home_dir().join(".claude")
.expect("无法获取用户主目录")
.join(".claude")
} }
/// 默认 Claude MCP 配置文件路径 (~/.claude.json) /// 默认 Claude MCP 配置文件路径 (~/.claude.json)
pub fn get_default_claude_mcp_path() -> PathBuf { pub fn get_default_claude_mcp_path() -> PathBuf {
dirs::home_dir() get_home_dir().join(".claude.json")
.expect("无法获取用户主目录")
.join(".claude.json")
} }
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> { fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
+5 -2
View File
@@ -73,11 +73,14 @@ impl Database {
params![ params![
server.id, server.id,
server.name, 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.description,
server.homepage, server.homepage,
server.docs, 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.claude,
server.apps.codex, server.apps.codex,
server.apps.gemini, server.apps.gemini,
+13 -5
View File
@@ -220,7 +220,9 @@ impl Database {
WHERE id = ?13 AND app_type = ?14", WHERE id = ?13 AND app_type = ?14",
params![ params![
provider.name, provider.name,
serde_json::to_string(&provider.settings_config).unwrap(), serde_json::to_string(&provider.settings_config).map_err(|e| {
AppError::Database(format!("Failed to serialize settings_config: {e}"))
})?,
provider.website_url, provider.website_url,
provider.category, provider.category,
provider.created_at, provider.created_at,
@@ -228,7 +230,9 @@ impl Database {
provider.notes, provider.notes,
provider.icon, provider.icon,
provider.icon_color, 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, is_current,
in_failover_queue, in_failover_queue,
provider.id, provider.id,
@@ -247,7 +251,8 @@ impl Database {
provider.id, provider.id,
app_type, app_type,
provider.name, 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.website_url,
provider.category, provider.category,
provider.created_at, provider.created_at,
@@ -255,7 +260,8 @@ impl Database {
provider.notes, provider.notes,
provider.icon, provider.icon,
provider.icon_color, 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, is_current,
in_failover_queue, in_failover_queue,
], ],
@@ -324,7 +330,9 @@ impl Database {
conn.execute( conn.execute(
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3", "UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
params![ 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, provider_id,
app_type app_type
], ],
+24 -27
View File
@@ -409,27 +409,26 @@ fn merge_claude_config(
})?; })?;
// Auto-fill API key if not provided in URL // 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()) { if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
request.api_key = Some(token.to_string()); request.api_key = Some(token.to_string());
} }
} }
// Auto-fill endpoint if not provided in URL // 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()) { if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
request.endpoint = Some(base_url.to_string()); request.endpoint = Some(base_url.to_string());
} }
} }
// Auto-fill homepage from endpoint if not provided // Auto-fill homepage from endpoint if not provided
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty()) if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
&& request.endpoint.is_some() if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
&& !request.endpoint.as_ref().unwrap().is_empty() request.homepage = infer_homepage_from_endpoint(endpoint);
{ if request.homepage.is_none() {
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap()); request.homepage = Some("https://anthropic.com".to_string());
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, config: &serde_json::Value,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
// Auto-fill API key from auth.OPENAI_API_KEY // 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 if let Some(api_key) = config
.get("auth") .get("auth")
.and_then(|v| v.get("OPENAI_API_KEY")) .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 // Parse TOML config string to extract base_url and model
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) { if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
// Extract base_url from model_providers section // 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) { if let Some(base_url) = extract_codex_base_url(&toml_value) {
request.endpoint = Some(base_url); request.endpoint = Some(base_url);
} }
@@ -499,13 +498,12 @@ fn merge_codex_config(
} }
// Auto-fill homepage from endpoint // Auto-fill homepage from endpoint
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty()) if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
&& request.endpoint.is_some() if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
&& !request.endpoint.as_ref().unwrap().is_empty() request.homepage = infer_homepage_from_endpoint(endpoint);
{ if request.homepage.is_none() {
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap()); request.homepage = Some("https://openai.com".to_string());
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, config: &serde_json::Value,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
// Gemini uses flat env structure // 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()) { if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
request.api_key = Some(api_key.to_string()); 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()) { if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
request.endpoint = Some(base_url.to_string()); request.endpoint = Some(base_url.to_string());
} }
@@ -538,13 +536,12 @@ fn merge_gemini_config(
} }
// Auto-fill homepage from endpoint // Auto-fill homepage from endpoint
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty()) if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
&& request.endpoint.is_some() if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
&& !request.endpoint.as_ref().unwrap().is_empty() request.homepage = infer_homepage_from_endpoint(endpoint);
{ if request.homepage.is_none() {
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap()); request.homepage = Some("https://ai.google.dev".to_string());
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::fs;
use std::path::PathBuf; use std::path::PathBuf;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Gemini 配置目录路径(支持设置覆盖) /// 获取 Gemini 配置目录路径(支持设置覆盖)
pub fn get_gemini_dir() -> PathBuf { pub fn get_gemini_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_gemini_override_dir() { if let Some(custom) = crate::settings::get_gemini_override_dir() {
return custom; return custom;
} }
dirs::home_dir() get_home_dir().join(".gemini")
.expect("无法获取用户主目录")
.join(".gemini")
} }
/// 获取 Gemini .env 文件路径 /// 获取 Gemini .env 文件路径
+27
View File
@@ -134,6 +134,33 @@ pub fn set_mcp_servers_map(
obj.remove("homepage"); obj.remove("homepage");
obj.remove("docs"); 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)); 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 serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc; use std::sync::Arc;
@@ -106,7 +107,6 @@ impl CircuitBreaker {
/// 更新熔断器配置(热更新,不重置状态) /// 更新熔断器配置(热更新,不重置状态)
pub async fn update_config(&self, new_config: CircuitBreakerConfig) { pub async fn update_config(&self, new_config: CircuitBreakerConfig) {
*self.config.write().await = new_config; *self.config.write().await = new_config;
log::debug!("Circuit breaker config updated");
} }
/// 判断当前 Provider 是否“可被纳入候选链路” /// 判断当前 Provider 是否“可被纳入候选链路”
@@ -128,7 +128,8 @@ impl CircuitBreaker {
if opened_at.elapsed().as_secs() >= config.timeout_seconds { if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态 drop(config); // 释放读锁再转换状态
log::info!( 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; self.transition_to_half_open().await;
return true; return true;
@@ -155,7 +156,8 @@ impl CircuitBreaker {
if opened_at.elapsed().as_secs() >= config.timeout_seconds { if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态 drop(config); // 释放读锁再转换状态
log::info!( 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; self.transition_to_half_open().await;
@@ -197,25 +199,17 @@ impl CircuitBreaker {
self.consecutive_failures.store(0, Ordering::SeqCst); self.consecutive_failures.store(0, Ordering::SeqCst);
self.total_requests.fetch_add(1, Ordering::SeqCst); self.total_requests.fetch_add(1, Ordering::SeqCst);
match state { if state == CircuitState::HalfOpen {
CircuitState::HalfOpen => { let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
log::debug!(
"Circuit breaker HalfOpen: {} consecutive successes (threshold: {})",
successes,
config.success_threshold
);
if successes >= config.success_threshold { if successes >= config.success_threshold {
drop(config); // 释放读锁再转换状态 drop(config); // 释放读锁再转换状态
log::info!("Circuit breaker transitioning from HalfOpen to Closed (success threshold reached)"); log::info!(
self.transition_to_closed().await; "[{}] 熔断器 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); self.consecutive_successes.store(0, Ordering::SeqCst);
log::debug!(
"Circuit breaker {:?}: {} consecutive failures (threshold: {})",
state,
failures,
config.failure_threshold
);
// 检查是否应该打开熔断器 // 检查是否应该打开熔断器
match state { match state {
CircuitState::HalfOpen => { CircuitState::HalfOpen => {
// HalfOpen 状态下失败,立即转为 Open // HalfOpen 状态下失败,立即转为 Open
log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open"); log::warn!(
"[{}] 熔断器 HalfOpen 探测失败 → Open",
log_cb::HALF_OPEN_PROBE_FAILED
);
drop(config); drop(config);
self.transition_to_open().await; self.transition_to_open().await;
} }
@@ -255,9 +245,8 @@ impl CircuitBreaker {
// 检查连续失败次数 // 检查连续失败次数
if failures >= config.failure_threshold { if failures >= config.failure_threshold {
log::warn!( log::warn!(
"Circuit breaker opening due to {} consecutive failures (threshold: {})", "[{}] 熔断器触发: 连续失败 {failures} 次 → Open",
failures, log_cb::TRIGGERED_FAILURES
config.failure_threshold
); );
drop(config); // 释放读锁再转换状态 drop(config); // 释放读锁再转换状态
self.transition_to_open().await; self.transition_to_open().await;
@@ -268,18 +257,12 @@ impl CircuitBreaker {
if total >= config.min_requests { if total >= config.min_requests {
let error_rate = failed as f64 / total as f64; 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 { if error_rate >= config.error_rate_threshold {
log::warn!( log::warn!(
"Circuit breaker opening due to high error rate: {:.2}% (threshold: {:.2}%)", "[{}] 熔断器触发: 错误率 {:.1}% → Open",
error_rate * 100.0, log_cb::TRIGGERED_ERROR_RATE,
config.error_rate_threshold * 100.0 error_rate * 100.0
); );
drop(config); // 释放读锁再转换状态 drop(config); // 释放读锁再转换状态
self.transition_to_open().await; self.transition_to_open().await;
@@ -312,22 +295,16 @@ impl CircuitBreaker {
/// 重置熔断器(手动恢复) /// 重置熔断器(手动恢复)
#[allow(dead_code)] #[allow(dead_code)]
pub async fn reset(&self) { 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; self.transition_to_closed().await;
} }
fn allow_half_open_probe(&self) -> AllowResult { fn allow_half_open_probe(&self) -> AllowResult {
// 半开状态限流:只允许有限请求通过进行探测 // 半开状态限流:只允许有限请求通过进行探测
// 默认最多允许 1 个请求(可在配置中扩展)
let max_half_open_requests = 1u32; let max_half_open_requests = 1u32;
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst); let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
if current < max_half_open_requests { if current < max_half_open_requests {
log::debug!(
"Circuit breaker HalfOpen: allowing probe request ({}/{})",
current + 1,
max_half_open_requests
);
AllowResult { AllowResult {
allowed: true, allowed: true,
used_half_open_permit: true, used_half_open_permit: true,
@@ -335,9 +312,6 @@ impl CircuitBreaker {
} else { } else {
// 超过限额,回退计数,拒绝请求 // 超过限额,回退计数,拒绝请求
self.half_open_requests.fetch_sub(1, Ordering::SeqCst); self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
log::debug!(
"Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
);
AllowResult { AllowResult {
allowed: false, allowed: false,
used_half_open_permit: false, used_half_open_permit: false,
@@ -349,8 +323,6 @@ impl CircuitBreaker {
let mut current = self.half_open_requests.load(Ordering::SeqCst); let mut current = self.half_open_requests.load(Ordering::SeqCst);
loop { loop {
if current == 0 { if current == 0 {
// 理论上不应该发生:说明调用方传入的 used_half_open_permit 与实际占用不一致
log::debug!("Circuit breaker HalfOpen permit already released (counter=0)");
return; return;
} }
+12
View File
@@ -17,6 +17,12 @@ pub enum ProxyError {
#[error("地址绑定失败: {0}")] #[error("地址绑定失败: {0}")]
BindFailed(String), BindFailed(String),
#[error("停止超时")]
StopTimeout,
#[error("停止失败: {0}")]
StopFailed(String),
#[error("请求转发失败: {0}")] #[error("请求转发失败: {0}")]
ForwardFailed(String), ForwardFailed(String),
@@ -113,6 +119,12 @@ impl IntoResponse for ProxyError {
ProxyError::BindFailed(_) => { ProxyError::BindFailed(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) (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::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
ProxyError::NoAvailableProvider => { ProxyError::NoAvailableProvider => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string()) (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 { let app_enabled = match self.db.get_proxy_config_for_app(app_type).await {
Ok(config) => config.enabled, Ok(config) => config.enabled,
Err(e) => { Err(e) => {
log::warn!("[Failover] 无法读取 {app_type} 配置: {e},跳过切换"); log::warn!("[FO-002] 无法读取 {app_type} 配置: {e},跳过切换");
return Ok(false); return Ok(false);
} }
}; };
if !app_enabled { if !app_enabled {
log::info!("[Failover] {app_type} 未被代理接管(enabled=false,跳过切换"); log::debug!("[Failover] {app_type} 未启用代理,跳过切换");
return Ok(false); return Ok(false);
} }
log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})"); log::info!("[FO-001] 切换: {app_type} {provider_name}");
// 1. 更新数据库 is_current // 1. 更新数据库 is_current
self.db.set_current_provider(app_type, provider_id)?; self.db.set_current_provider(app_type, provider_id)?;
@@ -117,7 +117,7 @@ impl FailoverSwitchManager {
.update_live_backup_from_provider(app_type, &provider) .update_live_backup_from_provider(app_type, &provider)
.await .await
{ {
log::warn!("[Failover] 更新 Live 备份失败: {e}"); log::warn!("[FO-003] Live 备份更新失败: {e}");
} }
} }
@@ -138,12 +138,10 @@ impl FailoverSwitchManager {
"source": "failover" // 标识来源是故障转移 "source": "failover" // 标识来源是故障转移
}); });
if let Err(e) = app.emit("provider-switched", event_data) { 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) Ok(true)
} }
} }
+10
View File
@@ -305,6 +305,14 @@ impl RequestForwarder {
Some(format!("Provider {} 失败: {}", provider.name, e)); 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_error = Some(e);
last_provider = Some(provider.clone()); last_provider = Some(provider.clone());
// 继续尝试下一个供应商 // 继续尝试下一个供应商
@@ -360,6 +368,8 @@ impl RequestForwarder {
} }
} }
log::warn!("[{app_type_str}] [FWD-002] 所有 Provider 均失败");
Err(ForwardError { Err(ForwardError {
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded), error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
provider: last_provider, provider: last_provider,
+2 -3
View File
@@ -127,7 +127,7 @@ impl RequestContext {
.cloned() .cloned()
.ok_or(ProxyError::NoAvailableProvider)?; .ok_or(ProxyError::NoAvailableProvider)?;
log::info!( log::debug!(
"[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}", "[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}",
tag, tag,
provider.name, provider.name,
@@ -168,7 +168,6 @@ impl RequestContext {
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .to_string();
log::info!("[{}] 从 URI 提取模型: {}", self.tag, self.request_model);
self self
} }
@@ -190,7 +189,7 @@ impl RequestContext {
) )
} else { } else {
// 故障转移关闭:不启用超时配置 // 故障转移关闭:不启用超时配置
log::info!( log::debug!(
"[{}] Failover disabled, timeout configs are bypassed", "[{}] Failover disabled, timeout configs are bypassed",
self.tag self.tag
); );
+7 -54
View File
@@ -98,16 +98,6 @@ pub async fn handle_messages(
let adapter = get_adapter(&AppType::Claude); let adapter = get_adapter(&AppType::Claude);
let needs_transform = adapter.needs_transform(&ctx.provider); 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 特有:格式转换处理 // Claude 特有:格式转换处理
if needs_transform { if needs_transform {
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await; return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
@@ -131,8 +121,6 @@ async fn handle_claude_transform(
if is_stream { if is_stream {
// 流式响应转换 (OpenAI SSE → Anthropic SSE) // 流式响应转换 (OpenAI SSE → Anthropic SSE)
log::info!("[Claude] 开始流式响应转换 (OpenAI SSE → Anthropic SSE)");
let stream = response.bytes_stream(); let stream = response.bytes_stream();
let sse_stream = create_anthropic_sse_stream(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); let body = axum::body::Body::from_stream(logged_stream);
log::info!("[Claude] ====== 请求结束 (流式转换) ======");
return Ok((headers, body).into_response()); return Ok((headers, body).into_response());
} }
// 非流式响应转换 (OpenAI → Anthropic) // 非流式响应转换 (OpenAI → Anthropic)
log::info!("[Claude] 开始转换响应 (OpenAI → Anthropic)");
let response_headers = response.headers().clone(); let response_headers = response.headers().clone();
let body_bytes = response.bytes().await.map_err(|e| { 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); 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| { let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}"); log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}");
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}")) 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| { let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| {
log::error!("[Claude] 转换响应失败: {e}"); log::error!("[Claude] 转换响应失败: {e}");
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) { if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {
let model = 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); 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}")) ProxyError::TransformError(format!("Failed to serialize response: {e}"))
})?; })?;
log::info!(
"[Claude] 返回转换后的响应, 长度: {} bytes",
response_body.len()
);
let body = axum::body::Body::from(response_body); let body = axum::body::Body::from(response_body);
builder.body(body).map_err(|e| { builder.body(body).map_err(|e| {
log::error!("[Claude] 构建响应失败: {e}"); log::error!("[Claude] 构建响应失败: {e}");
@@ -307,8 +271,6 @@ pub async fn handle_chat_completions(
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
let mut ctx = let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; 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()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(false);
log::info!(
"[Codex] 请求模型: {}, 流式: {}",
ctx.request_model,
is_stream
);
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let result = match forwarder let result = match forwarder
.forward_with_retry( .forward_with_retry(
@@ -347,8 +303,6 @@ pub async fn handle_chat_completions(
ctx.provider = result.provider; ctx.provider = result.provider;
let response = result.response; let response = result.response;
log::info!("[Codex] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
} }
@@ -390,8 +344,6 @@ pub async fn handle_responses(
ctx.provider = result.provider; ctx.provider = result.provider;
let response = result.response; let response = result.response;
log::info!("[Codex] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
} }
@@ -417,8 +369,6 @@ pub async fn handle_gemini(
.map(|pq| pq.as_str()) .map(|pq| pq.as_str())
.unwrap_or(uri.path()); .unwrap_or(uri.path());
log::info!("[Gemini] 请求端点: {endpoint}");
let is_stream = body let is_stream = body
.get("stream") .get("stream")
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
@@ -448,8 +398,6 @@ pub async fn handle_gemini(
ctx.provider = result.provider; ctx.provider = result.provider;
let response = result.response; let response = result.response;
log::info!("[Gemini] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
} }
@@ -508,7 +456,12 @@ async fn log_usage(
Ok(Some(p)) => { Ok(Some(p)) => {
if let Some(meta) = p.meta { if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier { 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 { } else {
Decimal::from(1) Decimal::from(1)
} }
@@ -535,6 +488,6 @@ async fn log_usage(
None, // provider_type None, // provider_type
is_streaming, 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; pub mod handler_context;
mod handlers; mod handlers;
mod health; mod health;
pub mod log_codes;
pub mod model_mapper; pub mod model_mapper;
pub mod provider_router; pub mod provider_router;
pub mod providers; pub mod providers;
+1 -1
View File
@@ -127,7 +127,7 @@ pub fn apply_model_mapping(
let mapped = mapping.map_model(original, has_thinking); let mapped = mapping.map_model(original, has_thinking);
if mapped != *original { if mapped != *original {
log::info!("[ModelMapper] 模型映射: {original} → {mapped}"); log::debug!("[ModelMapper] 模型映射: {original} → {mapped}");
body["model"] = serde_json::json!(mapped); body["model"] = serde_json::json!(mapped);
return (body, Some(original.clone()), Some(mapped)); return (body, Some(original.clone()), Some(mapped));
} }
+14 -103
View File
@@ -39,15 +39,9 @@ impl ProviderRouter {
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取) // 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await { let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
Ok(config) => { Ok(config) => config.auto_failover_enabled,
let enabled = config.auto_failover_enabled;
log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}");
enabled
}
Err(e) => { Err(e) => {
log::error!( log::error!("[{app_type}] 读取 proxy_config 失败: {e},默认禁用故障转移");
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
);
false false
} }
}; };
@@ -56,85 +50,37 @@ impl ProviderRouter {
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序 // 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
let failover_providers = self.db.get_failover_providers(app_type)?; let failover_providers = self.db.get_failover_providers(app_type)?;
total_providers = failover_providers.len(); 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 { for provider in failover_providers {
// 检查熔断器状态
let circuit_key = format!("{}:{}", app_type, provider.id); let circuit_key = format!("{}:{}", app_type, provider.id);
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
let state = breaker.get_state().await;
if breaker.is_available().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); result.push(provider);
} else { } else {
circuit_open_count += 1; circuit_open_count += 1;
log::debug!(
"[{}] Queue provider {} circuit breaker open (state: {:?}), skipping",
app_type,
provider.name,
state
);
} }
} }
} else { } 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_id) = self.db.get_current_provider(app_type)? {
if let Some(current) = self.db.get_provider_by_id(&current_id, 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; total_providers = 1;
result.push(current); 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() { if result.is_empty() {
// 区分两种情况:全部熔断 vs 未配置供应商
if total_providers > 0 && circuit_open_count == total_providers { 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); return Err(AppError::AllProvidersCircuitOpen);
} else { } else {
log::warn!("[{app_type}] 未配置供应商或故障转移队列为空"); log::warn!("[{app_type}] [FO-005] 未配置供应商");
return Err(AppError::NoProvidersConfigured); return Err(AppError::NoProvidersConfigured);
} }
} }
log::info!(
"[{}] Provider chain: {} provider(s) available",
app_type,
result.len()
);
Ok(result) Ok(result)
} }
@@ -161,15 +107,10 @@ impl ProviderRouter {
success: bool, success: bool,
error_msg: Option<String>, error_msg: Option<String>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
// 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用) // 1. 按应用独立获取熔断器配置
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await { let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
Ok(app_config) => app_config.circuit_failure_threshold, Ok(app_config) => app_config.circuit_failure_threshold,
Err(e) => { Err(_) => 5, // 默认值
log::warn!(
"Failed to load circuit config for {app_type}, using default threshold: {e}"
);
5 // 默认值
}
}; };
// 2. 更新熔断器状态 // 2. 更新熔断器状态
@@ -178,14 +119,8 @@ impl ProviderRouter {
if success { if success {
breaker.record_success(used_half_open_permit).await; breaker.record_success(used_half_open_permit).await;
log::debug!("Provider {provider_id} request succeeded");
} else { } else {
breaker.record_failure(used_half_open_permit).await; breaker.record_failure(used_half_open_permit).await;
log::warn!(
"Provider {} request failed: {}",
provider_id,
error_msg.as_deref().unwrap_or("Unknown error")
);
} }
// 3. 更新数据库健康状态(使用配置的阈值) // 3. 更新数据库健康状态(使用配置的阈值)
@@ -206,7 +141,6 @@ impl ProviderRouter {
pub async fn reset_circuit_breaker(&self, circuit_key: &str) { pub async fn reset_circuit_breaker(&self, circuit_key: &str) {
let breakers = self.circuit_breakers.read().await; let breakers = self.circuit_breakers.read().await;
if let Some(breaker) = breakers.get(circuit_key) { if let Some(breaker) = breakers.get(circuit_key) {
log::info!("Manually resetting circuit breaker for {circuit_key}");
breaker.reset().await; breaker.reset().await;
} }
} }
@@ -218,18 +152,11 @@ impl ProviderRouter {
} }
/// 更新所有熔断器的配置(热更新) /// 更新所有熔断器的配置(热更新)
///
/// 当用户在 UI 中修改熔断器配置后调用此方法,
/// 所有现有的熔断器会立即使用新配置
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) { pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
let breakers = self.circuit_breakers.read().await; let breakers = self.circuit_breakers.read().await;
let count = breakers.len();
for breaker in breakers.values() { for breaker in breakers.values() {
breaker.update_config(config.clone()).await; 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 { let config = match self.db.get_proxy_config_for_app(app_type).await {
Ok(app_config) => { Ok(app_config) => crate::proxy::circuit_breaker::CircuitBreakerConfig {
log::debug!( failure_threshold: app_config.circuit_failure_threshold,
"Loading circuit breaker config for {key} (app={app_type}): \ success_threshold: app_config.circuit_success_threshold,
failure_threshold={}, success_threshold={}, timeout={}s", timeout_seconds: app_config.circuit_timeout_seconds as u64,
app_config.circuit_failure_threshold, error_rate_threshold: app_config.circuit_error_rate_threshold,
app_config.circuit_success_threshold, min_requests: app_config.circuit_min_requests,
app_config.circuit_timeout_seconds },
); Err(_) => crate::proxy::circuit_breaker::CircuitBreakerConfig::default(),
crate::proxy::circuit_breaker::CircuitBreakerConfig {
failure_threshold: app_config.circuit_failure_threshold,
success_threshold: app_config.circuit_success_threshold,
timeout_seconds: app_config.circuit_timeout_seconds as u64,
error_rate_threshold: app_config.circuit_error_rate_threshold,
min_requests: app_config.circuit_min_requests,
}
}
Err(e) => {
log::warn!(
"Failed to load circuit breaker config for {key} (app={app_type}): {e}, using default"
);
crate::proxy::circuit_breaker::CircuitBreakerConfig::default()
}
}; };
log::debug!("Creating new circuit breaker for {key} with config: {config:?}");
let breaker = Arc::new(CircuitBreaker::new(config)); let breaker = Arc::new(CircuitBreaker::new(config));
breakers.insert(key.to_string(), breaker.clone()); breakers.insert(key.to_string(), breaker.clone());
+13 -2
View File
@@ -62,7 +62,8 @@ impl ClaudeAdapter {
let normalized = value.trim().to_lowercase(); let normalized = value.trim().to_lowercase();
normalized == "true" || normalized == "1" 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)); assert!(!adapter.needs_transform(&anthropic_provider));
// OpenRouter provider without explicit setting now defaults to passthrough (no transform)
let openrouter_provider = create_provider(json!({ let openrouter_provider = create_provider(json!({
"env": { "env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api" "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!({ let openrouter_disabled = create_provider(json!({
"env": { "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 current_block_type: Option<String> = None;
let mut tool_call_id = None; let mut tool_call_id = None;
log::info!("[Claude/OpenRouter] ====== 开始流式响应转换 ======");
tokio::pin!(stream); tokio::pin!(stream);
while let Some(chunk) = stream.next().await { while let Some(chunk) = stream.next().await {
@@ -96,25 +94,18 @@ pub fn create_anthropic_sse_stream(
for l in line.lines() { for l in line.lines() {
if let Some(data) = l.strip_prefix("data: ") { if let Some(data) = l.strip_prefix("data: ") {
if data.trim() == "[DONE]" { 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 event = json!({"type": "message_stop"});
let sse_data = format!("event: message_stop\ndata: {}\n\n", let sse_data = format!("event: message_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default()); 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)); yield Ok(Bytes::from(sse_data));
continue; continue;
} }
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) { if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
// 记录原始 OpenAI 事件(格式化显示) // 仅在 DEBUG 级别简短记录 SSE 事件
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(data) { log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
log::info!(
"[Claude/OpenRouter] <<< OpenAI SSE 事件:\n{}",
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
);
} else {
log::info!("[Claude/OpenRouter] <<< OpenAI SSE 数据: {data}");
}
if message_id.is_none() { if message_id.is_none() {
message_id = Some(chunk.id.clone()); message_id = Some(chunk.id.clone());
+13 -20
View File
@@ -46,8 +46,6 @@ pub async fn handle_streaming(
state: &ProxyState, state: &ProxyState,
parser_config: &UsageParserConfig, parser_config: &UsageParserConfig,
) -> Response { ) -> Response {
log::info!("[{}] 流式透传响应 (SSE)", ctx.tag);
let status = response.status(); let status = response.status();
let mut builder = axum::response::Response::builder().status(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) { 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) { if let Some(usage) = (parser_config.response_parser)(&json_value) {
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型 // 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
@@ -137,7 +129,7 @@ pub async fn handle_non_streaming(
); );
} }
} else { } else {
log::info!( log::debug!(
"[{}] <<< 响应 (非 JSON): {} bytes", "[{}] <<< 响应 (非 JSON): {} bytes",
ctx.tag, ctx.tag,
body_bytes.len() 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); let mut builder = axum::response::Response::builder().status(status);
for (key, value) in response_headers.iter() { for (key, value) in response_headers.iter() {
@@ -382,7 +372,12 @@ async fn log_usage_internal(
Ok(Some(p)) => { Ok(Some(p)) => {
if let Some(meta) = p.meta { if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier { 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 { } else {
Decimal::from(1) Decimal::from(1)
} }
@@ -418,7 +413,7 @@ async fn log_usage_internal(
None, // provider_type None, // provider_type
is_streaming, 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 { if let Some(c) = &collector {
c.push(json_value.clone()).await; c.push(json_value.clone()).await;
} }
log::info!( log::debug!(
"[{}] <<< SSE 事件:\n{}", "[{}] <<< SSE 事件: {}",
tag, tag,
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string()) data.chars().take(100).collect::<String>()
); );
} else { } else {
log::info!("[{tag}] <<< SSE 数据: {data}"); log::debug!("[{tag}] <<< SSE 数据: {}", data.chars().take(100).collect::<String>());
} }
} else { } 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() { if let Some(c) = collector.take() {
c.finish().await; c.finish().await;
} }
+20 -8
View File
@@ -3,8 +3,8 @@
//! 基于Axum的HTTP服务器,处理代理请求 //! 基于Axum的HTTP服务器,处理代理请求
use super::{ use super::{
failover_switch::FailoverSwitchManager, handlers, provider_router::ProviderRouter, types::*, failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
ProxyError, provider_router::ProviderRouter, types::*, ProxyError,
}; };
use crate::database::Database; use crate::database::Database;
use axum::{ use axum::{
@@ -95,7 +95,7 @@ impl ProxyServer {
.await .await
.map_err(|e| ProxyError::BindFailed(e.to_string()))?; .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); *self.shutdown_tx.write().await = Some(shutdown_tx);
@@ -146,13 +146,25 @@ impl ProxyServer {
// 2. 等待服务器任务结束(带 5 秒超时保护) // 2. 等待服务器任务结束(带 5 秒超时保护)
if let Some(handle) = self.server_handle.write().await.take() { if let Some(handle) = self.server_handle.write().await.take() {
match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await { match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await {
Ok(Ok(())) => log::info!("代理服务器已完全停止"), Ok(Ok(())) => {
Ok(Err(e)) => log::warn!("代理服务器任务异常终止: {e}"), log::info!("[{}] 代理服务器已完全停止", log_srv::STOPPED);
Err(_) => log::warn!("代理服务器停止超时(5秒),强制继续"), 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 { 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)?; let pricing = self.get_model_pricing(&model)?;
if pricing.is_none() { if pricing.is_none() {
log::warn!("模型 {model} 的定价信息未找到,成本将记录为 0"); log::warn!("[USG-002] 模型定价未找到,成本将记录为 0");
} }
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier); let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
+1 -1
View File
@@ -85,7 +85,7 @@ impl PromptService {
if !content_exists { if !content_exists {
let timestamp = std::time::SystemTime::now() let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap() .unwrap_or_default()
.as_secs() as i64; .as_secs() as i64;
let backup_id = format!("backup-{timestamp}"); let backup_id = format!("backup-{timestamp}");
let backup_prompt = Prompt { let backup_prompt = Prompt {
+9
View File
@@ -148,6 +148,15 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// MCP sync // MCP sync
McpService::sync_all_enabled(state)?; 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(()) Ok(())
} }
+1 -1
View File
@@ -160,7 +160,7 @@ impl SkillService {
.user_agent("cc-switch") .user_agent("cc-switch")
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
.build() .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, conn: &Connection,
model_id: &str, model_id: &str,
) -> Result<Option<(String, String, String, String)>, AppError> { ) -> 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('/') .rsplit_once('/')
.map(|(_, rest)| rest) .map_or(model_id, |(_, r)| r)
.unwrap_or(model_id);
let cleaned = without_prefix
.split(':') .split(':')
.next() .next()
.map(str::trim) .unwrap_or(model_id)
.unwrap_or(without_prefix); .trim()
.replace('@', "-");
// 2) 精确匹配清洗后的名称 // 精确匹配清洗后的名称
let exact = conn let exact = conn
.query_row( .query_row(
"SELECT input_cost_per_million, output_cost_per_million, "SELECT input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing FROM model_pricing
WHERE model_id = ?1", WHERE model_id = ?1",
[cleaned], [&cleaned],
|row| { |row| {
Ok(( Ok((
row.get::<_, String>(0)?, row.get::<_, String>(0)?,
@@ -952,6 +952,13 @@ mod tests {
"带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905" "带前缀+冒号后缀的模型应清洗后匹配到 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")?; let result = find_model_pricing_row(&conn, "unknown-model-123")?;
assert!(result.is_none(), "不应该匹配不存在的模型"); assert!(result.is_none(), "不应该匹配不存在的模型");
+23 -10
View File
@@ -92,12 +92,9 @@ impl Default for AppSettings {
} }
impl AppSettings { impl AppSettings {
fn settings_path() -> PathBuf { fn settings_path() -> Option<PathBuf> {
// settings.json 保留用于旧版本迁移和无数据库场景 // settings.json 保留用于旧版本迁移和无数据库场景
dirs::home_dir() dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json"))
.expect("无法获取用户主目录")
.join(".cc-switch")
.join("settings.json")
} }
fn normalize_paths(&mut self) { fn normalize_paths(&mut self) {
@@ -131,7 +128,9 @@ impl AppSettings {
} }
fn load_from_file() -> Self { 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) { if let Ok(content) = fs::read_to_string(&path) {
match serde_json::from_str::<AppSettings>(&content) { match serde_json::from_str::<AppSettings>(&content) {
Ok(mut settings) => { Ok(mut settings) => {
@@ -156,7 +155,9 @@ impl AppSettings {
fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
let mut normalized = settings.clone(); let mut normalized = settings.clone();
normalized.normalize_paths(); 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() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; 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 { 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> { pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
new_settings.normalize_paths(); new_settings.normalize_paths();
save_settings_file(&new_settings)?; 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; *guard = new_settings;
Ok(()) Ok(())
} }
@@ -209,7 +219,10 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
/// 用于导入配置等场景,确保内存缓存与文件同步 /// 用于导入配置等场景,确保内存缓存与文件同步
pub fn reload_settings() -> Result<(), AppError> { pub fn reload_settings() -> Result<(), AppError> {
let fresh_settings = AppSettings::load_from_file(); 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; *guard = fresh_settings;
Ok(()) Ok(())
} }
+7 -4
View File
@@ -624,10 +624,12 @@ function App() {
<Settings className="w-4 h-4" /> <Settings className="w-4 h-4" />
</Button> </Button>
</div> </div>
<UpdateBadge onClick={() => { <UpdateBadge
setSettingsDefaultTab("about"); onClick={() => {
setCurrentView("settings"); setSettingsDefaultTab("about");
}} /> setCurrentView("settings");
}}
/>
</> </>
)} )}
</div> </div>
@@ -806,6 +808,7 @@ function App() {
{effectiveUsageProvider && ( {effectiveUsageProvider && (
<UsageScriptModal <UsageScriptModal
key={effectiveUsageProvider.id}
provider={effectiveUsageProvider} provider={effectiveUsageProvider}
appId={activeApp} appId={activeApp}
isOpen={Boolean(usageProvider)} isOpen={Boolean(usageProvider)}
@@ -236,7 +236,7 @@ export function ProviderForm({
} catch { } catch {
// ignore // ignore
} }
return true; return false; // OpenRouter now supports Claude Code compatible API, no need for transform
}, [isOpenRouterProvider, settingsConfigValue]); }, [isOpenRouterProvider, settingsConfigValue]);
const handleOpenRouterCompatChange = useCallback( const handleOpenRouterCompatChange = useCallback(
@@ -864,7 +864,7 @@ export function ProviderForm({
defaultOpusModel={defaultOpusModel} defaultOpusModel={defaultOpusModel}
onModelChange={handleModelChange} onModelChange={handleModelChange}
speedTestEndpoints={speedTestEndpoints} speedTestEndpoints={speedTestEndpoints}
showOpenRouterCompatToggle={isOpenRouterProvider} showOpenRouterCompatToggle={false}
openRouterCompatEnabled={openRouterCompatEnabled} openRouterCompatEnabled={openRouterCompatEnabled}
onOpenRouterCompatChange={handleOpenRouterCompatChange} onOpenRouterCompatChange={handleOpenRouterCompatChange}
/> />
@@ -43,11 +43,7 @@ export function useApiKeyState({
return; return;
} }
// 仅当配置确实包含 API Key 字段时才同步(避免无意清空用户正在输入的 key // 从配置中提取 API Key(如果不存在则返回空字符串
if (!hasApiKeyField(initialConfig, appType)) {
return;
}
const extracted = getApiKeyFromConfig(initialConfig, appType); const extracted = getApiKeyFromConfig(initialConfig, appType);
if (extracted !== apiKey) { if (extracted !== apiKey) {
setApiKey(extracted); setApiKey(extracted);
@@ -41,8 +41,9 @@ export function useBaseUrlState({
try { try {
const config = JSON.parse(settingsConfig || "{}"); const config = JSON.parse(settingsConfig || "{}");
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL; const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
if (typeof envUrl === "string" && envUrl && envUrl.trim() !== baseUrl) { const nextUrl = typeof envUrl === "string" ? envUrl.trim() : "";
setBaseUrl(envUrl.trim()); if (nextUrl !== baseUrl) {
setBaseUrl(nextUrl);
} }
} catch { } catch {
// ignore // ignore
+180 -85
View File
@@ -21,52 +21,157 @@ export function AutoFailoverConfigPanel({
const { data: config, isLoading, error } = useAppProxyConfig(appType); const { data: config, isLoading, error } = useAppProxyConfig(appType);
const updateConfig = useUpdateAppProxyConfig(); const updateConfig = useUpdateAppProxyConfig();
// 使用字符串状态以支持完全清空数字输入框
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
autoFailoverEnabled: false, autoFailoverEnabled: false,
maxRetries: 3, maxRetries: "3",
streamingFirstByteTimeout: 30, streamingFirstByteTimeout: "30",
streamingIdleTimeout: 60, streamingIdleTimeout: "60",
nonStreamingTimeout: 300, nonStreamingTimeout: "300",
circuitFailureThreshold: 5, circuitFailureThreshold: "5",
circuitSuccessThreshold: 2, circuitSuccessThreshold: "2",
circuitTimeoutSeconds: 60, circuitTimeoutSeconds: "60",
circuitErrorRateThreshold: 0.5, circuitErrorRateThreshold: "50", // 存储百分比值
circuitMinRequests: 10, circuitMinRequests: "10",
}); });
useEffect(() => { useEffect(() => {
if (config) { if (config) {
setFormData({ setFormData({
autoFailoverEnabled: config.autoFailoverEnabled, autoFailoverEnabled: config.autoFailoverEnabled,
maxRetries: config.maxRetries, maxRetries: String(config.maxRetries),
streamingFirstByteTimeout: config.streamingFirstByteTimeout, streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),
streamingIdleTimeout: config.streamingIdleTimeout, streamingIdleTimeout: String(config.streamingIdleTimeout),
nonStreamingTimeout: config.nonStreamingTimeout, nonStreamingTimeout: String(config.nonStreamingTimeout),
circuitFailureThreshold: config.circuitFailureThreshold, circuitFailureThreshold: String(config.circuitFailureThreshold),
circuitSuccessThreshold: config.circuitSuccessThreshold, circuitSuccessThreshold: String(config.circuitSuccessThreshold),
circuitTimeoutSeconds: config.circuitTimeoutSeconds, circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),
circuitErrorRateThreshold: config.circuitErrorRateThreshold, circuitErrorRateThreshold: String(
circuitMinRequests: config.circuitMinRequests, Math.round(config.circuitErrorRateThreshold * 100),
),
circuitMinRequests: String(config.circuitMinRequests),
}); });
} }
}, [config]); }, [config]);
const handleSave = async () => { const handleSave = async () => {
if (!config) return; 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 { try {
await updateConfig.mutateAsync({ await updateConfig.mutateAsync({
appType, appType,
enabled: config.enabled, enabled: config.enabled,
autoFailoverEnabled: formData.autoFailoverEnabled, autoFailoverEnabled: formData.autoFailoverEnabled,
maxRetries: formData.maxRetries, maxRetries: raw.maxRetries,
streamingFirstByteTimeout: formData.streamingFirstByteTimeout, streamingFirstByteTimeout: raw.streamingFirstByteTimeout,
streamingIdleTimeout: formData.streamingIdleTimeout, streamingIdleTimeout: raw.streamingIdleTimeout,
nonStreamingTimeout: formData.nonStreamingTimeout, nonStreamingTimeout: raw.nonStreamingTimeout,
circuitFailureThreshold: formData.circuitFailureThreshold, circuitFailureThreshold: raw.circuitFailureThreshold,
circuitSuccessThreshold: formData.circuitSuccessThreshold, circuitSuccessThreshold: raw.circuitSuccessThreshold,
circuitTimeoutSeconds: formData.circuitTimeoutSeconds, circuitTimeoutSeconds: raw.circuitTimeoutSeconds,
circuitErrorRateThreshold: formData.circuitErrorRateThreshold, circuitErrorRateThreshold: raw.circuitErrorRateThreshold / 100,
circuitMinRequests: formData.circuitMinRequests, circuitMinRequests: raw.circuitMinRequests,
}); });
toast.success( toast.success(
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"), t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
@@ -83,15 +188,17 @@ export function AutoFailoverConfigPanel({
if (config) { if (config) {
setFormData({ setFormData({
autoFailoverEnabled: config.autoFailoverEnabled, autoFailoverEnabled: config.autoFailoverEnabled,
maxRetries: config.maxRetries, maxRetries: String(config.maxRetries),
streamingFirstByteTimeout: config.streamingFirstByteTimeout, streamingFirstByteTimeout: String(config.streamingFirstByteTimeout),
streamingIdleTimeout: config.streamingIdleTimeout, streamingIdleTimeout: String(config.streamingIdleTimeout),
nonStreamingTimeout: config.nonStreamingTimeout, nonStreamingTimeout: String(config.nonStreamingTimeout),
circuitFailureThreshold: config.circuitFailureThreshold, circuitFailureThreshold: String(config.circuitFailureThreshold),
circuitSuccessThreshold: config.circuitSuccessThreshold, circuitSuccessThreshold: String(config.circuitSuccessThreshold),
circuitTimeoutSeconds: config.circuitTimeoutSeconds, circuitTimeoutSeconds: String(config.circuitTimeoutSeconds),
circuitErrorRateThreshold: config.circuitErrorRateThreshold, circuitErrorRateThreshold: String(
circuitMinRequests: config.circuitMinRequests, Math.round(config.circuitErrorRateThreshold * 100),
),
circuitMinRequests: String(config.circuitMinRequests),
}); });
} }
}; };
@@ -142,13 +249,9 @@ export function AutoFailoverConfigPanel({
min="0" min="0"
max="10" max="10"
value={formData.maxRetries} value={formData.maxRetries}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value); setFormData({ ...formData, maxRetries: e.target.value })
setFormData({ }
...formData,
maxRetries: isNaN(val) ? 0 : val,
});
}}
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -169,13 +272,12 @@ export function AutoFailoverConfigPanel({
min="1" min="1"
max="20" max="20"
value={formData.circuitFailureThreshold} value={formData.circuitFailureThreshold}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
circuitFailureThreshold: isNaN(val) ? 1 : Math.max(1, val), circuitFailureThreshold: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -208,13 +310,12 @@ export function AutoFailoverConfigPanel({
min="0" min="0"
max="180" max="180"
value={formData.streamingFirstByteTimeout} value={formData.streamingFirstByteTimeout}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
streamingFirstByteTimeout: isNaN(val) ? 0 : val, streamingFirstByteTimeout: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -235,13 +336,12 @@ export function AutoFailoverConfigPanel({
min="0" min="0"
max="600" max="600"
value={formData.streamingIdleTimeout} value={formData.streamingIdleTimeout}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
streamingIdleTimeout: isNaN(val) ? 0 : val, streamingIdleTimeout: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -262,13 +362,12 @@ export function AutoFailoverConfigPanel({
min="0" min="0"
max="1800" max="1800"
value={formData.nonStreamingTimeout} value={formData.nonStreamingTimeout}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
nonStreamingTimeout: isNaN(val) ? 0 : val, nonStreamingTimeout: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -298,13 +397,12 @@ export function AutoFailoverConfigPanel({
min="1" min="1"
max="10" max="10"
value={formData.circuitSuccessThreshold} value={formData.circuitSuccessThreshold}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
circuitSuccessThreshold: isNaN(val) ? 1 : Math.max(1, val), circuitSuccessThreshold: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -322,16 +420,15 @@ export function AutoFailoverConfigPanel({
<Input <Input
id={`timeoutSeconds-${appType}`} id={`timeoutSeconds-${appType}`}
type="number" type="number"
min="10" min="0"
max="300" max="300"
value={formData.circuitTimeoutSeconds} value={formData.circuitTimeoutSeconds}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
circuitTimeoutSeconds: isNaN(val) ? 10 : Math.max(10, val), circuitTimeoutSeconds: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -352,14 +449,13 @@ export function AutoFailoverConfigPanel({
min="0" min="0"
max="100" max="100"
step="5" step="5"
value={Math.round(formData.circuitErrorRateThreshold * 100)} value={formData.circuitErrorRateThreshold}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
circuitErrorRateThreshold: isNaN(val) ? 0.5 : val / 100, circuitErrorRateThreshold: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -380,13 +476,12 @@ export function AutoFailoverConfigPanel({
min="5" min="5"
max="100" max="100"
value={formData.circuitMinRequests} value={formData.circuitMinRequests}
onChange={(e) => { onChange={(e) =>
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
circuitMinRequests: isNaN(val) ? 5 : Math.max(5, val), circuitMinRequests: e.target.value,
}); })
}} }
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -7,42 +7,141 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next";
/** /**
* 熔断器配置面板 * 熔断器配置面板
* 允许用户调整熔断器参数 * 允许用户调整熔断器参数
*/ */
export function CircuitBreakerConfigPanel() { export function CircuitBreakerConfigPanel() {
const { t } = useTranslation();
const { data: config, isLoading } = useCircuitBreakerConfig(); const { data: config, isLoading } = useCircuitBreakerConfig();
const updateConfig = useUpdateCircuitBreakerConfig(); const updateConfig = useUpdateCircuitBreakerConfig();
// 使用字符串状态以支持完全清空输入框
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
failureThreshold: 5, failureThreshold: "5",
successThreshold: 2, successThreshold: "2",
timeoutSeconds: 60, timeoutSeconds: "60",
errorRateThreshold: 0.5, errorRateThreshold: "50", // 存储百分比值
minRequests: 10, minRequests: "10",
}); });
// 当配置加载完成时更新表单数据 // 当配置加载完成时更新表单数据
useEffect(() => { useEffect(() => {
if (config) { 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]); }, [config]);
const handleSave = async () => { 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 { try {
await updateConfig.mutateAsync(formData); await updateConfig.mutateAsync({
toast.success("熔断器配置已保存", { closeButton: true }); 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) { } catch (error) {
toast.error("保存失败: " + String(error)); toast.error(
t("circuitBreaker.saveFailed", "保存失败") + ": " + String(error),
);
} }
}; };
const handleReset = () => { const handleReset = () => {
if (config) { 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" max="20"
value={formData.failureThreshold} value={formData.failureThreshold}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({ ...formData, failureThreshold: e.target.value })
...formData,
failureThreshold: parseInt(e.target.value) || 5,
})
} }
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -89,14 +185,11 @@ export function CircuitBreakerConfigPanel() {
<Input <Input
id="timeoutSeconds" id="timeoutSeconds"
type="number" type="number"
min="10" min="0"
max="300" max="300"
value={formData.timeoutSeconds} value={formData.timeoutSeconds}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({ ...formData, timeoutSeconds: e.target.value })
...formData,
timeoutSeconds: parseInt(e.target.value) || 60,
})
} }
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -114,10 +207,7 @@ export function CircuitBreakerConfigPanel() {
max="10" max="10"
value={formData.successThreshold} value={formData.successThreshold}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({ ...formData, successThreshold: e.target.value })
...formData,
successThreshold: parseInt(e.target.value) || 2,
})
} }
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -134,12 +224,9 @@ export function CircuitBreakerConfigPanel() {
min="0" min="0"
max="100" max="100"
step="5" step="5"
value={Math.round(formData.errorRateThreshold * 100)} value={formData.errorRateThreshold}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({ ...formData, errorRateThreshold: e.target.value })
...formData,
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
})
} }
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -157,10 +244,7 @@ export function CircuitBreakerConfigPanel() {
max="100" max="100"
value={formData.minRequests} value={formData.minRequests}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({ ...formData, minRequests: e.target.value })
...formData,
minRequests: parseInt(e.target.value) || 10,
})
} }
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
+72 -16
View File
@@ -38,15 +38,15 @@ export function ProxyPanel() {
const { data: globalConfig } = useGlobalProxyConfig(); const { data: globalConfig } = useGlobalProxyConfig();
const updateGlobalConfig = useUpdateGlobalProxyConfig(); const updateGlobalConfig = useUpdateGlobalProxyConfig();
// 监听地址/端口的本地状态 // 监听地址/端口的本地状态(端口用字符串以支持完全清空)
const [listenAddress, setListenAddress] = useState("127.0.0.1"); const [listenAddress, setListenAddress] = useState("127.0.0.1");
const [listenPort, setListenPort] = useState(15721); const [listenPort, setListenPort] = useState("15721");
// 同步全局配置到本地状态 // 同步全局配置到本地状态
useEffect(() => { useEffect(() => {
if (globalConfig) { if (globalConfig) {
setListenAddress(globalConfig.listenAddress); setListenAddress(globalConfig.listenAddress);
setListenPort(globalConfig.listenPort); setListenPort(String(globalConfig.listenPort));
} }
}, [globalConfig]); }, [globalConfig]);
@@ -102,12 +102,57 @@ export function ProxyPanel() {
const handleSaveBasicConfig = async () => { const handleSaveBasicConfig = async () => {
if (!globalConfig) return; 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 { try {
await updateGlobalConfig.mutateAsync({ await updateGlobalConfig.mutateAsync({
...globalConfig, ...globalConfig,
listenAddress, listenAddress: normalizedAddress,
listenPort, listenPort: port,
}); });
// 同步更新本地状态为规范化后的值
setListenAddress(normalizedAddress);
toast.success( toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }), t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
{ closeButton: true }, { 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 ( return (
<> <>
<section className="space-y-6"> <section className="space-y-6">
@@ -147,14 +199,14 @@ export function ProxyPanel() {
</p> </p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <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"> <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> </code>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
navigator.clipboard.writeText( navigator.clipboard.writeText(
`http://${status.address}:${status.port}`, formatAddressForUrl(status.address, status.port),
); );
toast.success( toast.success(
t("proxy.panel.addressCopied", { t("proxy.panel.addressCopied", {
@@ -389,9 +441,12 @@ export function ProxyPanel() {
id="listen-address" id="listen-address"
value={listenAddress} value={listenAddress}
onChange={(e) => setListenAddress(e.target.value)} onChange={(e) => setListenAddress(e.target.value)}
placeholder={t("proxy.settings.fields.listenAddress.placeholder", { placeholder={t(
defaultValue: "127.0.0.1", "proxy.settings.fields.listenAddress.placeholder",
})} {
defaultValue: "127.0.0.1",
},
)}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenAddress.description", { {t("proxy.settings.fields.listenAddress.description", {
@@ -411,12 +466,13 @@ export function ProxyPanel() {
id="listen-port" id="listen-port"
type="number" type="number"
value={listenPort} value={listenPort}
onChange={(e) => onChange={(e) => setListenPort(e.target.value)}
setListenPort(parseInt(e.target.value) || 15721) placeholder={t(
} "proxy.settings.fields.listenPort.placeholder",
placeholder={t("proxy.settings.fields.listenPort.placeholder", { {
defaultValue: "15721", defaultValue: "15721",
})} },
)}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenPort.description", { {t("proxy.settings.fields.listenPort.description", {
+30 -18
View File
@@ -17,10 +17,11 @@ export function ModelTestConfigPanel() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [config, setConfig] = useState<StreamCheckConfig>({ // 使用字符串状态以支持完全清空数字输入框
timeoutSecs: 45, const [config, setConfig] = useState({
maxRetries: 2, timeoutSecs: "45",
degradedThresholdMs: 6000, maxRetries: "2",
degradedThresholdMs: "6000",
claudeModel: "claude-haiku-4-5-20251001", claudeModel: "claude-haiku-4-5-20251001",
codexModel: "gpt-5.1-codex@low", codexModel: "gpt-5.1-codex@low",
geminiModel: "gemini-3-pro-preview", geminiModel: "gemini-3-pro-preview",
@@ -35,7 +36,14 @@ export function ModelTestConfigPanel() {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const data = await getStreamCheckConfig(); 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) { } catch (e) {
setError(String(e)); setError(String(e));
} finally { } finally {
@@ -44,9 +52,22 @@ export function ModelTestConfigPanel() {
} }
async function handleSave() { async function handleSave() {
// 解析数字,空值使用默认值,0 是有效值
const parseNum = (val: string, defaultVal: number) => {
const n = parseInt(val);
return isNaN(n) ? defaultVal : n;
};
try { try {
setIsSaving(true); 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"), { toast.success(t("streamCheck.configSaved"), {
closeButton: true, closeButton: true,
}); });
@@ -132,10 +153,7 @@ export function ModelTestConfigPanel() {
max={120} max={120}
value={config.timeoutSecs} value={config.timeoutSecs}
onChange={(e) => onChange={(e) =>
setConfig({ setConfig({ ...config, timeoutSecs: e.target.value })
...config,
timeoutSecs: parseInt(e.target.value) || 45,
})
} }
/> />
</div> </div>
@@ -149,10 +167,7 @@ export function ModelTestConfigPanel() {
max={5} max={5}
value={config.maxRetries} value={config.maxRetries}
onChange={(e) => onChange={(e) =>
setConfig({ setConfig({ ...config, maxRetries: e.target.value })
...config,
maxRetries: parseInt(e.target.value) || 2,
})
} }
/> />
</div> </div>
@@ -169,10 +184,7 @@ export function ModelTestConfigPanel() {
step={1000} step={1000}
value={config.degradedThresholdMs} value={config.degradedThresholdMs}
onChange={(e) => onChange={(e) =>
setConfig({ setConfig({ ...config, degradedThresholdMs: e.target.value })
...config,
degradedThresholdMs: parseInt(e.target.value) || 6000,
})
} }
/> />
</div> </div>
+1 -3
View File
@@ -405,9 +405,7 @@ export const providerPresets: ProviderPreset[] = [
}, },
}, },
// 请求地址候选(用于地址管理/测速) // 请求地址候选(用于地址管理/测速)
endpointCandidates: [ endpointCandidates: ["https://api.aigocode.com"],
"https://api.aigocode.com",
],
category: "third_party", category: "third_party",
isPartner: true, // 合作伙伴 isPartner: true, // 合作伙伴
partnerPromotionKey: "aigocode", // 促销信息 i18n key partnerPromotionKey: "aigocode", // 促销信息 i18n key
+5 -1
View File
@@ -181,7 +181,11 @@ requires_openai_auth = true`,
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
category: "third_party", category: "third_party",
auth: generateThirdPartyAuth(""), auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig("aigocode", "https://api.aigocode.com/openai", "gpt-5.2"), config: generateThirdPartyConfig(
"aigocode",
"https://api.aigocode.com/openai",
"gpt-5.2",
),
endpointCandidates: ["https://api.aigocode.com"], endpointCandidates: ["https://api.aigocode.com"],
isPartner: true, // 合作伙伴 isPartner: true, // 合作伙伴
partnerPromotionKey: "aigocode", // 促销信息 i18n key partnerPromotionKey: "aigocode", // 促销信息 i18n key
+17 -1
View File
@@ -1107,7 +1107,12 @@
"toast": { "toast": {
"saved": "Proxy configuration saved", "saved": "Proxy configuration saved",
"saveFailed": "Save failed: {{error}}" "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}}", "switchFailed": "Switch failed: {{error}}",
"failover": { "failover": {
@@ -1134,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.", "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", "configSaved": "Auto failover config saved",
"configSaveFailed": "Failed to save", "configSaveFailed": "Failed to save",
"validationFailed": "The following fields are out of valid range: {{fields}}",
"retrySettings": "Retry & Timeout Settings", "retrySettings": "Retry & Timeout Settings",
"failureThreshold": "Failure Threshold", "failureThreshold": "Failure Threshold",
"failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)", "failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)",
@@ -1185,6 +1191,16 @@
"streamingIdle": "Streaming Idle Timeout", "streamingIdle": "Streaming Idle Timeout",
"nonStreaming": "Non-Streaming 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": { "universalProvider": {
"title": "Universal Provider", "title": "Universal Provider",
"description": "Universal providers manage Claude, Codex, and Gemini configurations simultaneously. Changes are automatically synced to all enabled apps.", "description": "Universal providers manage Claude, Codex, and Gemini configurations simultaneously. Changes are automatically synced to all enabled apps.",
+17 -1
View File
@@ -1107,7 +1107,12 @@
"toast": { "toast": {
"saved": "代理配置已保存", "saved": "代理配置已保存",
"saveFailed": "保存失败: {{error}}" "saveFailed": "保存失败: {{error}}"
} },
"invalidPort": "端口无效,请输入 1024-65535 之间的数字",
"invalidAddress": "地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost",
"configSaved": "代理配置已保存",
"configSaveFailed": "保存配置失败",
"restartRequired": "修改地址或端口后需要重启代理服务才能生效"
}, },
"switchFailed": "切换失败: {{error}}", "switchFailed": "切换失败: {{error}}",
"failover": { "failover": {
@@ -1134,6 +1139,7 @@
"info": "当故障转移队列中配置了多个供应商时,系统会在请求失败时按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会打开并在一段时间内跳过该供应商。", "info": "当故障转移队列中配置了多个供应商时,系统会在请求失败时按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会打开并在一段时间内跳过该供应商。",
"configSaved": "自动故障转移配置已保存", "configSaved": "自动故障转移配置已保存",
"configSaveFailed": "保存失败", "configSaveFailed": "保存失败",
"validationFailed": "以下字段超出有效范围: {{fields}}",
"retrySettings": "重试与超时设置", "retrySettings": "重试与超时设置",
"failureThreshold": "失败阈值", "failureThreshold": "失败阈值",
"failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)", "failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)",
@@ -1185,6 +1191,16 @@
"streamingIdle": "流式静默超时", "streamingIdle": "流式静默超时",
"nonStreaming": "非流式超时" "nonStreaming": "非流式超时"
}, },
"circuitBreaker": {
"failureThreshold": "失败阈值",
"successThreshold": "成功阈值",
"timeoutSeconds": "超时时间",
"errorRateThreshold": "错误率阈值",
"minRequests": "最小请求数",
"validationFailed": "以下字段超出有效范围: {{fields}}",
"configSaved": "熔断器配置已保存",
"saveFailed": "保存失败"
},
"universalProvider": { "universalProvider": {
"title": "统一供应商", "title": "统一供应商",
"description": "统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。", "description": "统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。",