Feat/usage model extraction (#455)

* feat(proxy): extract model name from API response for accurate usage tracking

- Add model field extraction in TokenUsage parsing for Claude, OpenAI, and Codex
- Prioritize response model over request model in usage logging
- Update model extractors to use parsed usage.model first
- Add tests for model extraction in stream and non-stream responses

* feat(proxy): implement streaming timeout control with validation

- Add first byte timeout (0 or 1-180s) for streaming requests
- Add idle timeout (0 or 60-600s) for streaming data gaps
- Add non-streaming timeout (0 or 60-1800s) for total request
- Implement timeout logic in response processor
- Add 1800s global timeout fallback when disabled
- Add database schema migration for timeout fields
- Add i18n translations for timeout settings

* feat(proxy): add model mapping module for provider-based model substitution

- Add model_mapper.rs with ModelMapping struct to extract model configs from Provider
- Support ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL, and default models for haiku/sonnet/opus
- Implement thinking mode detection for reasoning model priority
- Include comprehensive unit tests for all mapping scenarios

* fix(proxy): bypass circuit breaker for single provider scenario

When failover is disabled (single provider), circuit breaker open state
would block all requests causing poor UX. Now bypasses circuit breaker
check in this scenario. Also integrates model mapping into request flow.

* feat(ui): add reasoning model field to Claude provider form

Add ANTHROPIC_REASONING_MODEL configuration field for Claude providers,
allowing users to specify a dedicated model for thinking/reasoning tasks.

* feat(proxy): add openrouter_compat_mode for optional format conversion

Add configurable OpenRouter compatibility mode that enables Anthropic to
OpenAI format conversion. When enabled, rewrites endpoint to /v1/chat/completions
and transforms request/response formats. Defaults to enabled for OpenRouter.

* feat(ui): add OpenRouter compatibility mode toggle

Add UI toggle for OpenRouter providers to enable/disable compatibility
mode which uses OpenAI Chat Completions format with SSE conversion.

* feat(stream-check): use provider-configured model for health checks

Extract model from provider's settings_config (ANTHROPIC_MODEL, GEMINI_MODEL,
or Codex config.toml) instead of always using default test models.

* refactor(ui): remove timeout settings from AutoFailoverConfigPanel

Remove streaming/non-streaming timeout configuration from failover panel
as these settings have been moved to a dedicated location.

* refactor(database): migrate proxy_config to per-app three-row structure

Replace singleton proxy_config table with app_type primary key structure,
allowing independent proxy settings for Claude, Codex, and Gemini.
Add GlobalProxyConfig queries and per-app config management in DAO layer.

* feat(proxy): add GlobalProxyConfig and AppProxyConfig types

Add new type definitions for the refactored proxy configuration:
- GlobalProxyConfig: shared settings (enabled, address, port, logging)
- AppProxyConfig: per-app settings (failover, timeouts, circuit breaker)

* refactor(proxy): update service layer for per-app config structure

Adapt proxy service, handler context, and provider router to use
the new per-app configuration model. Read enabled/timeout settings
from proxy_config table instead of settings table.

* feat(commands): add global and per-app proxy config commands

Add new Tauri commands for the refactored proxy configuration:
- get_global_proxy_config / update_global_proxy_config
- get_proxy_config_for_app / update_proxy_config_for_app
Update startup restore logic to read from proxy_config table.

* feat(api): add frontend API and Query hooks for proxy config

Add TypeScript wrappers and TanStack Query hooks for:
- Global proxy config (address, port, logging)
- Per-app proxy config (failover, timeouts, circuit breaker)
- Proxy takeover status management

* refactor(ui): redesign proxy panel with inline config controls

Replace ProxySettingsDialog with inline controls in ProxyPanel.
Add per-app takeover switches and global address/port settings.
Simplify AutoFailoverConfigPanel by removing timeout settings.

* feat(i18n): add proxy takeover translations and update types

Add i18n strings for proxy takeover status in zh/en/ja.
Update TypeScript types for GlobalProxyConfig and AppProxyConfig.

* refactor(proxy): load circuit breaker config per-app instead of globally

Extract app_type from router key and read circuit breaker settings
from the corresponding proxy_config row for each application.
This commit is contained in:
YoVinchen
2025-12-25 10:40:11 +08:00
committed by GitHub
parent 7d495aa772
commit e6f18ba801
37 changed files with 2813 additions and 1095 deletions
+97 -53
View File
@@ -43,7 +43,22 @@ impl ProxyService {
/// 启动代理服务器
pub async fn start(&self) -> Result<ProxyServerInfo, String> {
// 1. 获取配置
// 1. 启动时自动设置 proxy_enabled = true
let mut global_config = self
.db
.get_global_proxy_config()
.await
.map_err(|e| format!("获取全局代理配置失败: {e}"))?;
if !global_config.proxy_enabled {
global_config.proxy_enabled = true;
self.db
.update_global_proxy_config(global_config.clone())
.await
.map_err(|e| format!("更新代理总开关失败: {e}"))?;
}
// 2. 获取配置
let config = self
.db
.get_proxy_config()
@@ -115,14 +130,7 @@ impl ProxyService {
return Err(e);
}
// 5. 设置 settings 表中所有应用的接管状态(用于重启后自动恢复)
for app in ["claude", "codex", "gemini"] {
if let Err(e) = self.db.set_proxy_takeover_enabled(app, true) {
log::warn!("设置 {app} 接管状态失败: {e}");
}
}
// 6. 启动代理服务器
// 5. 启动代理服务器
match self.start().await {
Ok(info) => Ok(info),
Err(e) => {
@@ -132,8 +140,6 @@ impl ProxyService {
Ok(()) => {
let _ = self.db.set_live_takeover_active(false).await;
let _ = self.db.delete_all_live_backups().await;
// 清除 settings 状态
let _ = self.db.clear_all_proxy_takeover();
}
Err(restore_err) => {
log::error!("恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}");
@@ -146,29 +152,30 @@ impl ProxyService {
/// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)
pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {
let claude = self
// 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测
let claude_enabled = self
.db
.get_live_backup("claude")
.get_proxy_config_for_app("claude")
.await
.map_err(|e| format!("获取 Claude 接管状态失败: {e}"))?
.is_some();
let codex = self
.map(|c| c.enabled)
.unwrap_or(false);
let codex_enabled = self
.db
.get_live_backup("codex")
.get_proxy_config_for_app("codex")
.await
.map_err(|e| format!("获取 Codex 接管状态失败: {e}"))?
.is_some();
let gemini = self
.map(|c| c.enabled)
.unwrap_or(false);
let gemini_enabled = self
.db
.get_live_backup("gemini")
.get_proxy_config_for_app("gemini")
.await
.map_err(|e| format!("获取 Gemini 接管状态失败: {e}"))?
.is_some();
.map(|c| c.enabled)
.unwrap_or(false);
Ok(ProxyTakeoverStatus {
claude,
codex,
gemini,
claude: claude_enabled,
codex: codex_enabled,
gemini: gemini_enabled,
})
}
@@ -187,13 +194,13 @@ impl ProxyService {
}
// 2) 已接管则直接返回(幂等)
if self
let current_config = self
.db
.get_live_backup(app_type_str)
.get_proxy_config_for_app(app_type_str)
.await
.map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))?
.is_some()
{
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
if current_config.enabled {
return Ok(());
}
@@ -223,25 +230,32 @@ impl ProxyService {
return Err(e);
}
// 6) 设置 settings 表中的接管状态
// 6) 设置 proxy_config.enabled = true
let mut updated_config = self
.db
.get_proxy_config_for_app(app_type_str)
.await
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
updated_config.enabled = true;
self.db
.set_proxy_takeover_enabled(app_type_str, true)
.map_err(|e| format!("设置 {app_type_str} 接管状态失败: {e}"))?;
.update_proxy_config_for_app(updated_config)
.await
.map_err(|e| format!("设置 {app_type_str} enabled 状态失败: {e}"))?;
// 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)
let _ = self.db.set_live_takeover_active(true).await;
return Ok(());
}
// 关闭接管:无备份则视为未接管(幂等)
let has_backup = self
// 关闭接管:检查 enabled 状态
let current_config = self
.db
.get_live_backup(app_type_str)
.get_proxy_config_for_app(app_type_str)
.await
.map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))?
.is_some();
if !has_backup {
return Ok(());
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
if !current_config.enabled {
return Ok(()); // 未接管,幂等返回
}
// 1) 恢复 Live 配置
@@ -253,10 +267,17 @@ impl ProxyService {
.await
.map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?;
// 3) 清除 settings 表中该应用的接管状态
// 3) 设置 proxy_config.enabled = false
let mut updated_config = self
.db
.get_proxy_config_for_app(app_type_str)
.await
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
updated_config.enabled = false;
self.db
.set_proxy_takeover_enabled(app_type_str, false)
.map_err(|e| format!("清除 {app_type_str} 接管状态失败: {e}"))?;
.update_proxy_config_for_app(updated_config)
.await
.map_err(|e| format!("清除 {app_type_str} enabled 状态失败: {e}"))?;
// 4) 清除该应用的健康状态(关闭代理时重置队列状态)
self.db
@@ -265,12 +286,14 @@ impl ProxyService {
.map_err(|e| format!("清除 {app_type_str} 健康状态失败: {e}"))?;
// 5) 若无其它接管,更新旧标志,并停止代理服务
let has_any_backup = self
// 检查是否还有其它 app 的 enabled = true
let any_enabled = self
.db
.has_any_live_backup()
.is_live_takeover_active()
.await
.map_err(|e| format!("检查 Live 备份失败: {e}"))?;
if !has_any_backup {
.map_err(|e| format!("检查接管状态失败: {e}"))?;
if !any_enabled {
let _ = self.db.set_live_takeover_active(false).await;
if self.is_running().await {
@@ -502,6 +525,20 @@ impl ProxyService {
.await
.map_err(|e| format!("停止代理服务器失败: {e}"))?;
// 停止时设置 proxy_enabled = false
let mut global_config = self
.db
.get_global_proxy_config()
.await
.map_err(|e| format!("获取全局代理配置失败: {e}"))?;
if global_config.proxy_enabled {
global_config.proxy_enabled = false;
if let Err(e) = self.db.update_global_proxy_config(global_config).await {
log::warn!("更新代理总开关失败: {e}");
}
}
log::info!("代理服务器已停止");
Ok(())
} else {
@@ -527,10 +564,17 @@ impl ProxyService {
.await
.map_err(|e| format!("清除接管状态失败: {e}"))?;
// 4. 清除 settings 表中的代理状态(用户手动关闭,不需要下次自动恢复)
self.db
.clear_all_proxy_takeover()
.map_err(|e| format!("清除代理状态失败: {e}"))?;
// 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)
for app_type in ["claude", "codex", "gemini"] {
if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {
if config.enabled {
config.enabled = false;
if let Err(e) = self.db.update_proxy_config_for_app(config).await {
log::warn!("清除 {app_type} enabled 状态失败: {e}");
}
}
}
}
// 5. 删除备份
self.db
@@ -562,7 +606,7 @@ impl ProxyService {
self.restore_live_configs().await?;
// 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)
// 注意:仅更新 proxy_config 表,不清除 settings 表中的 proxy_takeover_* 状态
// 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复
if let Ok(mut config) = self.db.get_proxy_config().await {
config.live_takeover_active = false;
let _ = self.db.update_proxy_config(config).await;
+48 -3
View File
@@ -3,6 +3,7 @@
//! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。
use futures::StreamExt;
use regex::Regex;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -141,15 +142,17 @@ impl StreamCheckService {
.build()
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
let model_to_test = Self::resolve_test_model(app_type, provider, config);
let result = match app_type {
AppType::Claude => {
Self::check_claude_stream(&client, &base_url, &auth, &config.claude_model).await
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
}
AppType::Codex => {
Self::check_codex_stream(&client, &base_url, &auth, &config.codex_model).await
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
}
AppType::Gemini => {
Self::check_gemini_stream(&client, &base_url, &auth, &config.gemini_model).await
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
}
};
@@ -379,6 +382,48 @@ impl StreamCheckService {
AppError::Message(e.to_string())
}
}
fn resolve_test_model(
app_type: &AppType,
provider: &Provider,
config: &StreamCheckConfig,
) -> String {
match app_type {
AppType::Claude => Self::extract_env_model(provider, "ANTHROPIC_MODEL")
.unwrap_or_else(|| config.claude_model.clone()),
AppType::Codex => {
Self::extract_codex_model(provider).unwrap_or_else(|| config.codex_model.clone())
}
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
.unwrap_or_else(|| config.gemini_model.clone()),
}
}
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
provider
.settings_config
.get("env")
.and_then(|env| env.get(key))
.and_then(|value| value.as_str())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn extract_codex_model(provider: &Provider) -> Option<String> {
let config_text = provider
.settings_config
.get("config")
.and_then(|value| value.as_str())?;
if config_text.trim().is_empty() {
return None;
}
let re = Regex::new(r#"^model\s*=\s*["']([^"']+)["']"#).ok()?;
re.captures(config_text)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().trim().to_string())
.filter(|value| !value.is_empty())
}
}
#[cfg(test)]