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
+267 -61
View File
@@ -8,62 +8,66 @@ use crate::proxy::types::*;
use super::super::{lock_conn, Database};
impl Database {
// ==================== Proxy Config ====================
// ==================== Global Proxy Config ====================
/// 获取代理配置
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
// 在一个作用域内获取锁并查询,确保锁在await之前释放
/// 获取全局代理配置(统一字段)
///
/// 从 claude 行读取(三行镜像一致)
pub async fn get_global_proxy_config(&self) -> Result<GlobalProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT listen_address, listen_port, max_retries,
request_timeout, enable_logging, live_takeover_active
FROM proxy_config WHERE id = 1",
"SELECT proxy_enabled, listen_address, listen_port, enable_logging
FROM proxy_config WHERE app_type = 'claude'",
[],
|row| {
Ok(ProxyConfig {
listen_address: row.get(0)?,
listen_port: row.get::<_, i32>(1)? as u16,
max_retries: row.get::<_, i32>(2)? as u8,
request_timeout: row.get::<_, i32>(3)? as u64,
enable_logging: row.get::<_, i32>(4)? != 0,
live_takeover_active: row.get::<_, i32>(5).unwrap_or(0) != 0,
Ok(GlobalProxyConfig {
proxy_enabled: row.get::<_, i32>(0)? != 0,
listen_address: row.get(1)?,
listen_port: row.get::<_, i32>(2)? as u16,
enable_logging: row.get::<_, i32>(3)? != 0,
})
},
)
}; // conn锁在这里释放
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,插入默认配置
let default_config = ProxyConfig::default();
self.update_proxy_config(default_config.clone()).await?;
Ok(default_config)
// 如果不存在,创建默认配置
self.init_proxy_config_rows().await?;
Ok(GlobalProxyConfig {
proxy_enabled: false,
listen_address: "127.0.0.1".to_string(),
listen_port: 5000,
enable_logging: true,
})
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新代理配置
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
/// 更新全局代理配置(镜像写三行)
pub async fn update_global_proxy_config(
&self,
config: GlobalProxyConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO proxy_config
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, live_takeover_active, target_app, created_at, updated_at)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
datetime('now'))",
"UPDATE proxy_config SET
proxy_enabled = ?1,
listen_address = ?2,
listen_port = ?3,
enable_logging = ?4,
updated_at = datetime('now')",
rusqlite::params![
0, // 已移除自动启用逻辑,保留列但固定为 0
if config.proxy_enabled { 1 } else { 0 },
config.listen_address,
config.listen_port as i32,
config.max_retries as i32,
config.request_timeout as i32,
if config.enable_logging { 1 } else { 0 },
if config.live_takeover_active { 1 } else { 0 },
"claude", // 兼容旧字段,写入默认值
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -71,27 +75,214 @@ impl Database {
Ok(())
}
/// 设置 Live 接管状态(仅更新 proxy_config 表,兼容旧逻辑)
///
/// 注意:此方法不会清除 settings 表中的 proxy_takeover_* 状态。
/// settings 表的状态由 set_proxy_takeover_enabled 单独管理,用于跨重启保持状态。
pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> {
// 仅更新 proxy_config 表(兼容旧版本)
/// 获取应用级代理配置
pub async fn get_proxy_config_for_app(
&self,
app_type: &str,
) -> Result<AppProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let app_type_owned = app_type.to_string();
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT app_type, enabled, auto_failover_enabled,
max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
FROM proxy_config WHERE app_type = ?1",
[app_type],
|row| {
Ok(AppProxyConfig {
app_type: row.get(0)?,
enabled: row.get::<_, i32>(1)? != 0,
auto_failover_enabled: row.get::<_, i32>(2)? != 0,
max_retries: row.get::<_, i32>(3)? as u32,
streaming_first_byte_timeout: row.get::<_, i32>(4)? as u32,
streaming_idle_timeout: row.get::<_, i32>(5)? as u32,
non_streaming_timeout: row.get::<_, i32>(6)? as u32,
circuit_failure_threshold: row.get::<_, i32>(7)? as u32,
circuit_success_threshold: row.get::<_, i32>(8)? as u32,
circuit_timeout_seconds: row.get::<_, i32>(9)? as u32,
circuit_error_rate_threshold: row.get(10)?,
circuit_min_requests: row.get::<_, i32>(11)? as u32,
})
},
)
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,创建默认配置
self.init_proxy_config_rows().await?;
Ok(AppProxyConfig {
app_type: app_type_owned,
enabled: false,
auto_failover_enabled: false,
max_retries: 3,
streaming_first_byte_timeout: 30,
streaming_idle_timeout: 60,
non_streaming_timeout: 300,
circuit_failure_threshold: 5,
circuit_success_threshold: 2,
circuit_timeout_seconds: 60,
circuit_error_rate_threshold: 0.5,
circuit_min_requests: 10,
})
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新应用级代理配置
pub async fn update_proxy_config_for_app(
&self,
config: AppProxyConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1",
rusqlite::params![if active { 1 } else { 0 }],
"UPDATE proxy_config SET
enabled = ?2,
auto_failover_enabled = ?3,
max_retries = ?4,
streaming_first_byte_timeout = ?5,
streaming_idle_timeout = ?6,
non_streaming_timeout = ?7,
circuit_failure_threshold = ?8,
circuit_success_threshold = ?9,
circuit_timeout_seconds = ?10,
circuit_error_rate_threshold = ?11,
circuit_min_requests = ?12,
updated_at = datetime('now')
WHERE app_type = ?1",
rusqlite::params![
config.app_type,
if config.enabled { 1 } else { 0 },
if config.auto_failover_enabled { 1 } else { 0 },
config.max_retries as i32,
config.streaming_first_byte_timeout as i32,
config.streaming_idle_timeout as i32,
config.non_streaming_timeout as i32,
config.circuit_failure_threshold as i32,
config.circuit_success_threshold as i32,
config.circuit_timeout_seconds as i32,
config.circuit_error_rate_threshold,
config.circuit_min_requests as i32,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 初始化 proxy_config 表的三行数据
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
for app_type in &["claude", "codex", "gemini"] {
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
}
// ==================== Legacy Proxy Config (兼容旧代码) ====================
/// 获取代理配置(兼容旧接口,返回 claude 行的配置)
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT listen_address, listen_port, max_retries,
enable_logging,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
FROM proxy_config WHERE app_type = 'claude'",
[],
|row| {
Ok(ProxyConfig {
listen_address: row.get(0)?,
listen_port: row.get::<_, i32>(1)? as u16,
max_retries: row.get::<_, i32>(2)? as u8,
request_timeout: 300, // 废弃字段,返回默认值
enable_logging: row.get::<_, i32>(3)? != 0,
live_takeover_active: false, // 废弃字段
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(30) as u64,
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(60) as u64,
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(300) as u64,
})
},
)
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,初始化默认配置
self.init_proxy_config_rows().await?;
Ok(ProxyConfig::default())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新代理配置(兼容旧接口,更新所有三行的公共字段)
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 更新所有三行的公共字段
conn.execute(
"UPDATE proxy_config SET
listen_address = ?1,
listen_port = ?2,
max_retries = ?3,
enable_logging = ?4,
streaming_first_byte_timeout = ?5,
streaming_idle_timeout = ?6,
non_streaming_timeout = ?7,
updated_at = datetime('now')",
rusqlite::params![
config.listen_address,
config.listen_port as i32,
config.max_retries as i32,
if config.enable_logging { 1 } else { 0 },
config.streaming_first_byte_timeout as i32,
config.streaming_idle_timeout as i32,
config.non_streaming_timeout as i32,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 设置 Live 接管状态(兼容旧版本,更新 enabled 字段)
pub async fn set_live_takeover_active(&self, _active: bool) -> Result<(), AppError> {
// 不再使用此字段,由 enabled 字段替代
// 保留空实现以兼容旧代码
Ok(())
}
/// 检查是否处于 Live 接管模式
///
/// v3.8.0+:以 settings 表中的 `proxy_takeover_{app_type}` 为真实来源
/// 检查是否有任一 app 的 enabled = true
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
self.has_any_proxy_takeover()
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM proxy_config WHERE enabled = 1",
[],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
}
// ==================== Provider Health ====================
@@ -270,19 +461,22 @@ impl Database {
Ok(())
}
// ==================== Circuit Breaker Config ====================
// ==================== Circuit Breaker Config (Legacy Compatibility) ====================
/// 获取熔断器配置
/// 获取熔断器配置(兼容旧接口,从 claude 行读取)
///
/// 熔断器配置已合并到 proxy_config 表,每 app 独立
/// 此方法保留用于兼容旧代码,建议使用 get_proxy_config_for_app
pub async fn get_circuit_breaker_config(
&self,
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
let conn = lock_conn!(self.conn);
let config = conn
.query_row(
"SELECT failure_threshold, success_threshold, timeout_seconds,
error_rate_threshold, min_requests
FROM circuit_breaker_config WHERE id = 1",
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
FROM proxy_config WHERE app_type = 'claude'",
[],
|row| {
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
@@ -294,27 +488,39 @@ impl Database {
})
},
)
.map_err(|e| AppError::Database(e.to_string()))?;
};
// conn 已在 block 结束时释放
Ok(config)
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,初始化默认配置
self.init_proxy_config_rows().await?;
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig::default())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新熔断器配置
/// 更新熔断器配置(兼容旧接口,更新所有三行)
///
/// 熔断器配置已合并到 proxy_config 表
/// 此方法保留用于兼容旧代码,建议使用 update_proxy_config_for_app
pub async fn update_circuit_breaker_config(
&self,
config: &crate::proxy::circuit_breaker::CircuitBreakerConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 更新所有三行的熔断器配置
conn.execute(
"UPDATE circuit_breaker_config
SET failure_threshold = ?1,
success_threshold = ?2,
timeout_seconds = ?3,
error_rate_threshold = ?4,
min_requests = ?5,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1",
"UPDATE proxy_config SET
circuit_failure_threshold = ?1,
circuit_success_threshold = ?2,
circuit_timeout_seconds = ?3,
circuit_error_rate_threshold = ?4,
circuit_min_requests = ?5,
updated_at = datetime('now')",
rusqlite::params![
config.failure_threshold as i32,
config.success_threshold as i32,
+18 -4
View File
@@ -63,11 +63,13 @@ impl Database {
}
}
// --- 代理接管状态管理 ---
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
/// 获取指定应用的代理接管状态
///
/// 使用 settings 表存储各应用的接管状态,key 格式: `proxy_takeover_{app_type}`
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
/// 此方法仅用于数据库迁移时读取旧数据
#[deprecated(since = "3.9.0", note = "使用 get_proxy_config_for_app().enabled 替代")]
pub fn get_proxy_takeover_enabled(&self, app_type: &str) -> Result<bool, AppError> {
let key = format!("proxy_takeover_{app_type}");
match self.get_setting(&key)? {
@@ -78,8 +80,11 @@ impl Database {
/// 设置指定应用的代理接管状态
///
/// - `true` = 开启代理接管
/// - `false` = 关闭代理接管
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
#[deprecated(
since = "3.9.0",
note = "使用 update_proxy_config_for_app() 修改 enabled 字段"
)]
pub fn set_proxy_takeover_enabled(
&self,
app_type: &str,
@@ -91,6 +96,9 @@ impl Database {
}
/// 检查是否有任一应用开启了代理接管
///
/// **已废弃**: 请使用 `is_live_takeover_active()` 替代
#[deprecated(since = "3.9.0", note = "使用 is_live_takeover_active() 替代")]
pub fn has_any_proxy_takeover(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
@@ -104,6 +112,12 @@ impl Database {
}
/// 清除所有代理接管状态(将所有 proxy_takeover_* 设置为 false
///
/// **已废弃**: settings 表不再用于存储代理状态
#[deprecated(
since = "3.9.0",
note = "使用 update_proxy_config_for_app() 清除各应用的 enabled 字段"
)]
pub fn clear_all_proxy_takeover(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
+284 -215
View File
@@ -55,47 +55,28 @@ impl Database {
// 3. MCP Servers 表
conn.execute(
"CREATE TABLE IF NOT EXISTS mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
server_config TEXT NOT NULL,
description TEXT,
homepage TEXT,
docs TEXT,
tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
)",
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 4. Prompts 表
conn.execute(
"CREATE TABLE IF NOT EXISTS prompts (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT,
enabled BOOLEAN NOT NULL DEFAULT 1,
created_at INTEGER,
updated_at INTEGER,
PRIMARY KEY (id, app_type)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute("CREATE TABLE IF NOT EXISTS prompts (
id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, content TEXT NOT NULL,
description TEXT, enabled BOOLEAN NOT NULL DEFAULT 1, created_at INTEGER, updated_at INTEGER,
PRIMARY KEY (id, app_type)
)", []).map_err(|e| AppError::Database(e.to_string()))?;
// 5. Skills 表
conn.execute(
"CREATE TABLE IF NOT EXISTS skills (
directory TEXT NOT NULL,
app_type TEXT NOT NULL,
installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (directory, app_type)
)",
directory TEXT NOT NULL, app_type TEXT NOT NULL, installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (directory, app_type)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -103,169 +84,124 @@ impl Database {
// 6. Skill Repos 表
conn.execute(
"CREATE TABLE IF NOT EXISTS skill_repos (
owner TEXT NOT NULL,
name TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'main',
enabled BOOLEAN NOT NULL DEFAULT 1,
PRIMARY KEY (owner, name)
)",
owner TEXT NOT NULL, name TEXT NOT NULL, branch TEXT NOT NULL DEFAULT 'main',
enabled BOOLEAN NOT NULL DEFAULT 1, PRIMARY KEY (owner, name)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 7. Settings 表 (通用配置)
// 7. Settings 表
conn.execute(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)",
"CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 8. Proxy Config 表 (代理服务器配置)
// 代理配置表(单例)
// 8. Proxy Config 表(三行结构,app_type 主键)
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", []).map_err(|e| AppError::Database(e.to_string()))?;
// 初始化三行数据(每应用不同默认值)
conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 0,
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000,
max_retries INTEGER NOT NULL DEFAULT 3,
request_timeout INTEGER NOT NULL DEFAULT 300,
enable_logging INTEGER NOT NULL DEFAULT 1,
target_app TEXT NOT NULL DEFAULT 'claude',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 尝试添加 target_app 列(如果表已存在但缺少该列)
// 忽略 "duplicate column name" 错误
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN target_app TEXT NOT NULL DEFAULT 'claude'",
[],
);
// 9. Provider Health 表 (Provider健康状态)
conn.execute(
"CREATE TABLE IF NOT EXISTS provider_health (
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
is_healthy INTEGER NOT NULL DEFAULT 1,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
last_success_at TEXT,
last_failure_at TEXT,
last_error TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (provider_id, app_type),
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
)",
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 10. Proxy Request Logs 表 (详细请求日志)
conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
request_id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0',
output_cost_usd TEXT NOT NULL DEFAULT '0',
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
total_cost_usd TEXT NOT NULL DEFAULT '0',
latency_ms INTEGER NOT NULL,
first_token_ms INTEGER,
duration_ms INTEGER,
status_code INTEGER NOT NULL,
error_message TEXT,
session_id TEXT,
provider_type TEXT,
is_streaming INTEGER NOT NULL DEFAULT 0,
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
created_at INTEGER NOT NULL
)",
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 9. Provider Health 表
conn.execute("CREATE TABLE IF NOT EXISTS provider_health (
provider_id TEXT NOT NULL, app_type TEXT NOT NULL, is_healthy INTEGER NOT NULL DEFAULT 1,
consecutive_failures INTEGER NOT NULL DEFAULT 0, last_success_at TEXT, last_failure_at TEXT,
last_error TEXT, updated_at TEXT NOT NULL,
PRIMARY KEY (provider_id, app_type),
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
)", []).map_err(|e| AppError::Database(e.to_string()))?;
// 10. Proxy Request Logs 表
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL
)", []).map_err(|e| AppError::Database(e.to_string()))?;
conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON proxy_request_logs(provider_id, app_type)", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON proxy_request_logs(created_at)", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_provider
ON proxy_request_logs(provider_id, app_type)",
"CREATE INDEX IF NOT EXISTS idx_request_logs_model ON proxy_request_logs(model)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_created_at
ON proxy_request_logs(created_at)",
"CREATE INDEX IF NOT EXISTS idx_request_logs_session ON proxy_request_logs(session_id)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_model
ON proxy_request_logs(model)",
"CREATE INDEX IF NOT EXISTS idx_request_logs_status ON proxy_request_logs(status_code)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_session
ON proxy_request_logs(session_id)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_status
ON proxy_request_logs(status_code)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 11. Model Pricing 表 (模型定价)
// 11. Model Pricing 表
conn.execute(
"CREATE TABLE IF NOT EXISTS model_pricing (
model_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
input_cost_per_million TEXT NOT NULL,
output_cost_per_million TEXT NOT NULL,
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
)",
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 12. Stream Check Logs 表 (流式健康检查日志)
conn.execute(
"CREATE TABLE IF NOT EXISTS stream_check_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL,
provider_name TEXT NOT NULL,
app_type TEXT NOT NULL,
status TEXT NOT NULL,
success INTEGER NOT NULL,
message TEXT NOT NULL,
response_time_ms INTEGER,
http_status INTEGER,
model_used TEXT,
retry_count INTEGER DEFAULT 0,
tested_at INTEGER NOT NULL
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 12. Stream Check Logs 表
conn.execute("CREATE TABLE IF NOT EXISTS stream_check_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, provider_name TEXT NOT NULL,
app_type TEXT NOT NULL, status TEXT NOT NULL, success INTEGER NOT NULL, message TEXT NOT NULL,
response_time_ms INTEGER, http_status INTEGER, model_used TEXT,
retry_count INTEGER DEFAULT 0, tested_at INTEGER NOT NULL
)", []).map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider
@@ -274,35 +210,13 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 13. Circuit Breaker Config 表 (熔断器配置)
conn.execute(
"CREATE TABLE IF NOT EXISTS circuit_breaker_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
failure_threshold INTEGER NOT NULL DEFAULT 5,
success_threshold INTEGER NOT NULL DEFAULT 2,
timeout_seconds INTEGER NOT NULL DEFAULT 60,
error_rate_threshold REAL NOT NULL DEFAULT 0.5,
min_requests INTEGER NOT NULL DEFAULT 10,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 插入默认熔断器配置
conn.execute(
"INSERT OR IGNORE INTO circuit_breaker_config (id) VALUES (1)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 注意:circuit_breaker_config 已合并到 proxy_config 表中
// 16. Proxy Live Backup 表 (Live 配置备份)
conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_live_backup (
app_type TEXT PRIMARY KEY,
original_config TEXT NOT NULL,
backed_up_at TEXT NOT NULL
)",
app_type TEXT PRIMARY KEY, original_config TEXT NOT NULL, backed_up_at TEXT NOT NULL
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -313,6 +227,20 @@ impl Database {
[],
);
// 尝试添加超时配置列到 proxy_config 表
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
[],
);
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 60",
[],
);
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 300",
[],
);
// 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
Self::add_column_if_missing(
conn,
@@ -475,6 +403,28 @@ impl Database {
"BOOLEAN NOT NULL DEFAULT 0",
)?;
// 添加代理超时配置字段
if Self::table_exists(conn, "proxy_config")? {
Self::add_column_if_missing(
conn,
"proxy_config",
"streaming_first_byte_timeout",
"INTEGER NOT NULL DEFAULT 30",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"streaming_idle_timeout",
"INTEGER NOT NULL DEFAULT 60",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"non_streaming_timeout",
"INTEGER NOT NULL DEFAULT 300",
)?;
}
// 删除旧的 failover_queue 表(如果存在)
conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", [])
.map_err(|e| AppError::Database(format!("删除 failover_queue 索引失败: {e}")))?;
@@ -489,35 +439,18 @@ impl Database {
)
.map_err(|e| AppError::Database(format!("创建 failover 索引失败: {e}")))?;
// proxy_request_logs 表(包含所有字段)
conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
request_id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0',
output_cost_usd TEXT NOT NULL DEFAULT '0',
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
total_cost_usd TEXT NOT NULL DEFAULT '0',
latency_ms INTEGER NOT NULL,
first_token_ms INTEGER,
duration_ms INTEGER,
status_code INTEGER NOT NULL,
error_message TEXT,
session_id TEXT,
provider_type TEXT,
is_streaming INTEGER NOT NULL DEFAULT 0,
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
created_at INTEGER NOT NULL
)",
[],
)?;
// proxy_request_logs 表
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL
)", [])?;
// 为已存在的表添加新字段
Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?;
@@ -539,13 +472,11 @@ impl Database {
// model_pricing 表
conn.execute(
"CREATE TABLE IF NOT EXISTS model_pricing (
model_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
input_cost_per_million TEXT NOT NULL,
output_cost_per_million TEXT NOT NULL,
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
)",
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
)",
[],
)?;
@@ -557,6 +488,144 @@ impl Database {
// 重构 skills 表(添加 app_type 字段)
Self::migrate_skills_table(conn)?;
// 重构 proxy_config 为三行结构(每应用独立配置)
Self::migrate_proxy_config_to_per_app(conn)?;
Ok(())
}
/// 将 proxy_config 迁移为三行结构(每应用独立配置)
fn migrate_proxy_config_to_per_app(conn: &Connection) -> Result<(), AppError> {
// 检查是否已经是新表结构(幂等性)
if !Self::table_exists(conn, "proxy_config")? {
// 表不存在,跳过迁移(新安装)
return Ok(());
}
if Self::has_column(conn, "proxy_config", "app_type")? {
// 已经是三行结构,跳过迁移
log::info!("proxy_config 已经是三行结构,跳过迁移");
return Ok(());
}
// 读取旧配置
let old_config = conn
.query_row(
"SELECT listen_address, listen_port, max_retries, enable_logging,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
FROM proxy_config WHERE id = 1",
[],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i32>(1)?,
row.get::<_, i32>(2)?,
row.get::<_, i32>(3)?,
row.get::<_, i32>(4).unwrap_or(30),
row.get::<_, i32>(5).unwrap_or(60),
row.get::<_, i32>(6).unwrap_or(300),
))
},
)
.unwrap_or_else(|_| ("127.0.0.1".to_string(), 5000, 3, 1, 30, 60, 300));
let old_cb = conn.query_row(
"SELECT failure_threshold, success_threshold, timeout_seconds, error_rate_threshold, min_requests
FROM circuit_breaker_config WHERE id = 1", [],
|row| Ok((row.get::<_, i32>(0)?, row.get::<_, i32>(1)?, row.get::<_, i64>(2)?,
row.get::<_, f64>(3)?, row.get::<_, i32>(4)?))
).unwrap_or((5, 2, 60, 0.5, 10));
let get_bool = |key: &str| -> bool {
conn.query_row("SELECT value FROM settings WHERE key = ?", [key], |r| {
r.get::<_, String>(0)
})
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
};
let apps = [
(
"claude",
get_bool("proxy_takeover_claude"),
get_bool("auto_failover_enabled_claude"),
6,
45,
90,
8,
3,
90,
0.6,
15,
),
(
"codex",
get_bool("proxy_takeover_codex"),
get_bool("auto_failover_enabled_codex"),
3,
old_config.4,
old_config.5,
old_cb.0,
old_cb.1,
old_cb.2,
old_cb.3,
old_cb.4,
),
(
"gemini",
get_bool("proxy_takeover_gemini"),
get_bool("auto_failover_enabled_gemini"),
5,
old_config.4,
old_config.5,
old_cb.0,
old_cb.1,
old_cb.2,
old_cb.3,
old_cb.4,
),
];
// 创建新表
conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
conn.execute("CREATE TABLE proxy_config_new (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", [])?;
// 插入三行配置
for (app, takeover, failover, retries, fb, idle, cb_f, cb_s, cb_t, cb_r, cb_m) in apps {
conn.execute(
"INSERT INTO proxy_config_new (app_type, proxy_enabled, listen_address, listen_port, enable_logging,
enabled, auto_failover_enabled, max_retries, streaming_first_byte_timeout, streaming_idle_timeout,
non_streaming_timeout, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES (?1, 0, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
rusqlite::params![app, old_config.0, old_config.1, old_config.3,
if takeover { 1 } else { 0 }, if failover { 1 } else { 0 },
retries, fb, idle, old_config.6, cb_f, cb_s, cb_t, cb_r, cb_m]
).map_err(|e| AppError::Database(format!("插入 {app} 配置失败: {e}")))?;
}
// 替换表并清理
conn.execute("DROP TABLE IF EXISTS proxy_config", [])?;
conn.execute("ALTER TABLE proxy_config_new RENAME TO proxy_config", [])?;
conn.execute("DROP TABLE IF EXISTS circuit_breaker_config", [])?;
conn.execute("DELETE FROM settings WHERE key LIKE 'proxy_takeover_%'", [])?;
conn.execute(
"DELETE FROM settings WHERE key LIKE 'auto_failover_enabled_%'",
[],
)?;
log::info!("proxy_config 已迁移为三行结构");
Ok(())
}