Compare commits

...

11 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
YoVinchen
d33f99aca4 fix(proxy): improve validation and error handling in proxy config panels
- Add StopTimeout/StopFailed error types for proper stop() error reporting
- Replace silent clamp with validation-and-block in config panels
- Add listenAddress format validation in ProxyPanel
- Use log_codes constants instead of hardcoded strings
- Use once_cell::Lazy for regex precompilation
2026-01-11 14:12:57 +08:00
YoVinchen
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
35 changed files with 737 additions and 492 deletions

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 路径

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())
} }

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> {

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,

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

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());
} }
} }

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 文件路径

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;
} }

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

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

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,

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

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}");
} }
} }

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";
}

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;

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));
} }

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());

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());

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;
} }

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 {

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

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 {

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()),
} }
} }

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(), "不应该匹配不存在的模型");

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(())
} }

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>

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">

View File

@@ -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">

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() {
} }
}; };
// 格式化地址用于 URLIPv6 需要方括号)
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", {

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>

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

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

View File

@@ -70,7 +70,7 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
"https://www.packyapi.com", "https://www.packyapi.com",
], ],
icon: "packycode", icon: "packycode",
}, },
{ {
name: "Cubence", name: "Cubence",
websiteUrl: "https://cubence.com", websiteUrl: "https://cubence.com",

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.",

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 的配置。修改后会自动同步到所有启用的应用。",