Files
CC-Switch/src-tauri/src/commands/proxy.rs
T
Dex Miller 5376ea042b Feat/usage improvements (#508)
* i18n: update cache terminology across all languages

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

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

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

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

This provides better visibility into cache usage patterns over time.

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

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

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

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

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

This keeps usage data fresh without manual refresh.

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

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

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

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

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

Fixes clippy::uninlined_format_args warnings.

* feat(proxy): enhance provider router logging

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

This improves debugging of provider selection and failover behavior.

* feat(database): update model pricing data

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

* fix(usage): correct Gemini output token calculation

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Align usage stats to sliding windows

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

* feat(proxy): enable transparent passthrough for headers

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

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

- Add SessionIdExtractor to parse session ID from Claude/Codex requests
- Support extraction from metadata.user_id, headers, previous_response_id
- Pass session_id through RequestContext to usage logger
- Enable request correlation by session in proxy_request_logs
2025-12-31 22:57:00 +08:00

295 lines
9.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 代理服务相关的 Tauri 命令
//!
//! 提供前端调用的 API 接口
use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState;
/// 启动代理服务器(仅启动服务,不接管 Live 配置)
#[tauri::command]
pub async fn start_proxy_server(
state: tauri::State<'_, AppState>,
) -> Result<ProxyServerInfo, String> {
state.proxy_service.start().await
}
/// 停止代理服务器(恢复 Live 配置)
#[tauri::command]
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
state.proxy_service.stop_with_restore().await
}
/// 获取各应用接管状态
#[tauri::command]
pub async fn get_proxy_takeover_status(
state: tauri::State<'_, AppState>,
) -> Result<ProxyTakeoverStatus, String> {
state.proxy_service.get_takeover_status().await
}
/// 为指定应用开启/关闭接管
#[tauri::command]
pub async fn set_proxy_takeover_for_app(
state: tauri::State<'_, AppState>,
app_type: String,
enabled: bool,
) -> Result<(), String> {
state
.proxy_service
.set_takeover_for_app(&app_type, enabled)
.await
}
/// 获取代理服务器状态
#[tauri::command]
pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result<ProxyStatus, String> {
state.proxy_service.get_status().await
}
/// 获取代理配置
#[tauri::command]
pub async fn get_proxy_config(state: tauri::State<'_, AppState>) -> Result<ProxyConfig, String> {
state.proxy_service.get_config().await
}
/// 更新代理配置
#[tauri::command]
pub async fn update_proxy_config(
state: tauri::State<'_, AppState>,
config: ProxyConfig,
) -> Result<(), String> {
state.proxy_service.update_config(&config).await
}
// ==================== Global & Per-App Config ====================
/// 获取全局代理配置
///
/// 返回统一的全局配置字段(代理开关、监听地址、端口、日志开关)
#[tauri::command]
pub async fn get_global_proxy_config(
state: tauri::State<'_, AppState>,
) -> Result<GlobalProxyConfig, String> {
let db = &state.db;
db.get_global_proxy_config()
.await
.map_err(|e| e.to_string())
}
/// 更新全局代理配置
///
/// 更新统一的全局配置字段,会同时更新三行(claude/codex/gemini
#[tauri::command]
pub async fn update_global_proxy_config(
state: tauri::State<'_, AppState>,
config: GlobalProxyConfig,
) -> Result<(), String> {
let db = &state.db;
db.update_global_proxy_config(config)
.await
.map_err(|e| e.to_string())
}
/// 获取指定应用的代理配置
///
/// 返回应用级配置(enabled、auto_failover、超时、熔断器等)
#[tauri::command]
pub async fn get_proxy_config_for_app(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<AppProxyConfig, String> {
let db = &state.db;
db.get_proxy_config_for_app(&app_type)
.await
.map_err(|e| e.to_string())
}
/// 更新指定应用的代理配置
///
/// 更新应用级配置(enabled、auto_failover、超时、熔断器等)
#[tauri::command]
pub async fn update_proxy_config_for_app(
state: tauri::State<'_, AppState>,
config: AppProxyConfig,
) -> Result<(), String> {
let db = &state.db;
db.update_proxy_config_for_app(config)
.await
.map_err(|e| e.to_string())
}
/// 检查代理服务器是否正在运行
#[tauri::command]
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
Ok(state.proxy_service.is_running().await)
}
/// 检查是否处于 Live 接管模式
#[tauri::command]
pub async fn is_live_takeover_active(state: tauri::State<'_, AppState>) -> Result<bool, String> {
state.proxy_service.is_takeover_active().await
}
/// 代理模式下切换供应商(热切换)
#[tauri::command]
pub async fn switch_proxy_provider(
state: tauri::State<'_, AppState>,
app_type: String,
provider_id: String,
) -> Result<(), String> {
state
.proxy_service
.switch_proxy_target(&app_type, &provider_id)
.await
}
// ==================== 故障转移相关命令 ====================
/// 获取供应商健康状态
#[tauri::command]
pub async fn get_provider_health(
state: tauri::State<'_, AppState>,
provider_id: String,
app_type: String,
) -> Result<ProviderHealth, String> {
let db = &state.db;
db.get_provider_health(&provider_id, &app_type)
.await
.map_err(|e| e.to_string())
}
/// 重置熔断器
///
/// 重置后会检查是否应该切回队列中优先级更高的供应商:
/// 1. 检查自动故障转移是否开启
/// 2. 如果恢复的供应商在队列中优先级更高(queue_order 更小),则自动切换
#[tauri::command]
pub async fn reset_circuit_breaker(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
provider_id: String,
app_type: String,
) -> Result<(), String> {
// 1. 重置数据库健康状态
let db = &state.db;
db.update_provider_health(&provider_id, &app_type, true, None)
.await
.map_err(|e| e.to_string())?;
// 2. 如果代理正在运行,重置内存中的熔断器状态
state
.proxy_service
.reset_provider_circuit_breaker(&provider_id, &app_type)
.await?;
// 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取)
// 只有当该应用已被代理接管(enabled=true)且开启了自动故障转移时才执行
let (app_enabled, auto_failover_enabled) = match db.get_proxy_config_for_app(&app_type).await {
Ok(config) => (config.enabled, config.auto_failover_enabled),
Err(e) => {
log::error!("[{app_type}] Failed to read proxy_config: {e}, defaulting to disabled");
(false, false)
}
};
if app_enabled && auto_failover_enabled && state.proxy_service.is_running().await {
// 获取当前供应商 ID
let current_id = db
.get_current_provider(&app_type)
.map_err(|e| e.to_string())?;
if let Some(current_id) = current_id {
// 获取故障转移队列
let queue = db
.get_failover_queue(&app_type)
.map_err(|e| e.to_string())?;
// 找到恢复的供应商和当前供应商在队列中的位置(使用 sort_index
let restored_order = queue
.iter()
.find(|item| item.provider_id == provider_id)
.and_then(|item| item.sort_index);
let current_order = queue
.iter()
.find(|item| item.provider_id == current_id)
.and_then(|item| item.sort_index);
// 如果恢复的供应商优先级更高(sort_index 更小),则切换
if let (Some(restored), Some(current)) = (restored_order, current_order) {
if restored < current {
log::info!(
"[Recovery] 供应商 {provider_id} 已恢复且优先级更高 (P{restored} vs P{current}),自动切换"
);
// 获取供应商名称用于日志和事件
let provider_name = db
.get_all_providers(&app_type)
.ok()
.and_then(|providers| providers.get(&provider_id).map(|p| p.name.clone()))
.unwrap_or_else(|| provider_id.clone());
// 创建故障转移切换管理器并执行切换
let switch_manager =
crate::proxy::failover_switch::FailoverSwitchManager::new(db.clone());
if let Err(e) = switch_manager
.try_switch(Some(&app_handle), &app_type, &provider_id, &provider_name)
.await
{
log::error!("[Recovery] 自动切换失败: {e}");
}
}
}
}
}
Ok(())
}
/// 获取熔断器配置
#[tauri::command]
pub async fn get_circuit_breaker_config(
state: tauri::State<'_, AppState>,
) -> Result<CircuitBreakerConfig, String> {
let db = &state.db;
db.get_circuit_breaker_config()
.await
.map_err(|e| e.to_string())
}
/// 更新熔断器配置
#[tauri::command]
pub async fn update_circuit_breaker_config(
state: tauri::State<'_, AppState>,
config: CircuitBreakerConfig,
) -> Result<(), String> {
let db = &state.db;
// 1. 更新数据库配置
db.update_circuit_breaker_config(&config)
.await
.map_err(|e| e.to_string())?;
// 2. 如果代理正在运行,热更新内存中的熔断器配置
state
.proxy_service
.update_circuit_breaker_configs(config)
.await?;
Ok(())
}
/// 获取熔断器统计信息(仅当代理服务器运行时)
#[tauri::command]
pub async fn get_circuit_breaker_stats(
state: tauri::State<'_, AppState>,
provider_id: String,
app_type: String,
) -> Result<Option<CircuitBreakerStats>, String> {
// 这个功能需要访问运行中的代理服务器的内存状态
// 目前先返回 None,后续可以通过 ProxyService 暴露接口来实现
let _ = (state, provider_id, app_type);
Ok(None)
}