Compare commits

..

7 Commits

Author SHA1 Message Date
YoVinchen e01ef2f51a Merge branch 'main' into feat/format-transform-refactor 2026-01-26 01:43:57 +08:00
YoVinchen dc8a70b14e feat(i18n): add format transform translations
Add translations for format transform configuration UI in Chinese,
English, and Japanese.
2026-01-25 19:59:07 +08:00
YoVinchen 4ee45d2bc3 feat(ui): add format transform configuration in provider form
- Add FormatTransformConfig type definition
- Add format transform section in ProviderAdvancedConfig component
- Support source/target format selection (Anthropic/OpenAI)
- Add transform streaming toggle option
2026-01-25 19:58:28 +08:00
YoVinchen be246f8596 fix(proxy): prevent duplicate endpoint in Codex adapter URL building
Fix URL construction when base_url already contains the endpoint path
(e.g., base_url="https://api.example.com/v1/chat/completions" with
endpoint="/v1/chat/completions" should not result in double path).
2026-01-25 19:57:50 +08:00
YoVinchen 6046c166cc feat(proxy): integrate format transform module in forwarder
- Use TransformConfig.from_provider() to get transform settings
- Add early validation for missing transformers
- Support transform_streaming flag to force non-streaming mode
- Transform endpoint path based on source/target format
2026-01-25 19:50:29 +08:00
YoVinchen fe4a968eef feat(provider): add FormatTransformConfig for API format conversion
Add FormatTransformConfig struct to support API format transformation
between different providers (e.g., Anthropic ↔ OpenAI). This enables
providers like OpenRouter that use OpenAI-compatible interfaces.
2026-01-25 19:46:22 +08:00
YoVinchen 55301abc00 refactor(proxy): extract format transform into standalone module
- Create new `proxy/transform/` module with clean architecture:
  - `traits.rs`: FormatTransformer trait definition
  - `format.rs`: ApiFormat enum (Anthropic, OpenAI, Gemini)
  - `registry.rs`: TransformerRegistry with global instance
  - `config.rs`: TransformConfig from Provider settings
  - `anthropic_openai/`: Bidirectional Anthropic ↔ OpenAI converters

- Move streaming transform from providers/ to transform/
- Remove legacy transform code from providers/adapter.rs and claude.rs
- Update handlers.rs to use new transform module

This refactor improves maintainability and makes it easier to add
new format converters in the future.
2026-01-25 19:37:30 +08:00
62 changed files with 1859 additions and 3619 deletions
+1 -26
View File
@@ -1,28 +1,3 @@
fn main() {
tauri_build::build();
// Windows: Embed Common Controls v6 manifest for test binaries
//
// When running `cargo test`, the generated test executables don't include
// the standard Tauri application manifest. Without Common Controls v6,
// `tauri::test` calls fail with STATUS_ENTRYPOINT_NOT_FOUND.
//
// This workaround:
// 1. Embeds the manifest into test binaries via /MANIFEST:EMBED
// 2. Uses /MANIFEST:NO for the main binary to avoid duplicate resources
// (Tauri already handles manifest embedding for the app binary)
#[cfg(target_os = "windows")]
{
let manifest_path = std::path::PathBuf::from(
std::env::var("CARGO_MANIFEST_DIR").expect("missing CARGO_MANIFEST_DIR"),
)
.join("common-controls.manifest");
let manifest_arg = format!("/MANIFESTINPUT:{}", manifest_path.display());
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg={}", manifest_arg);
// Avoid duplicate manifest resources in binary builds.
println!("cargo:rustc-link-arg-bins=/MANIFEST:NO");
println!("cargo:rerun-if-changed={}", manifest_path.display());
}
tauri_build::build()
}
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"/>
</dependentAssembly>
</dependency>
</assembly>
-19
View File
@@ -282,25 +282,6 @@ impl AppType {
AppType::OpenCode => "opencode",
}
}
/// Check if this app uses additive mode
///
/// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini)
/// - Additive mode (true): All providers are written to live config (OpenCode)
pub fn is_additive_mode(&self) -> bool {
matches!(self, AppType::OpenCode)
}
/// Return an iterator over all app types
pub fn all() -> impl Iterator<Item = AppType> {
[
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
]
.into_iter()
}
}
impl FromStr for AppType {
+9 -2
View File
@@ -2,14 +2,21 @@
use std::path::PathBuf;
use crate::config::{
atomic_write, delete_file, get_home_dir, sanitize_provider_name, write_json_file,
write_text_file,
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
};
use crate::error::AppError;
use serde_json::Value;
use std::fs;
use std::path::Path;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
+33 -197
View File
@@ -581,19 +581,18 @@ fn write_claude_config(
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
}
/// macOS: 根据用户首选终端启动
/// macOS: 使用 Terminal.app 启动
#[cfg(target_os = "macos")]
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("terminal");
use std::process::Command;
let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy();
// Write the shell script to a temp file
// Write the shell script to a temp file (no escaping needed!)
let script_content = format!(
r#"#!/bin/bash
trap 'rm -f "{config_path}" "{script_file}"' EXIT
@@ -612,34 +611,7 @@ exec bash --norc --noprofile
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
// Try the preferred terminal first, fall back to Terminal.app if it fails
// Note: Kitty doesn't need the -e flag, others do
let result = match terminal {
"iterm2" => launch_macos_iterm2(&script_file),
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
"kitty" => launch_macos_open_app("kitty", &script_file, false),
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
};
// If preferred terminal fails and it's not the default, try Terminal.app as fallback
if result.is_err() && terminal != "terminal" {
log::warn!(
"首选终端 {} 启动失败,回退到 Terminal.app: {:?}",
terminal,
result.as_ref().err()
);
return launch_macos_terminal_app(&script_file);
}
result
}
/// macOS: Terminal.app
#[cfg(target_os = "macos")]
fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
// Simple AppleScript - just execute the script file
let applescript = format!(
r#"tell application "Terminal"
activate
@@ -655,9 +627,12 @@ end tell"#,
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
// Clean up on failure
let _ = std::fs::remove_file(&script_file);
let _ = std::fs::remove_file(config_file);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"Terminal.app 执行失败 (exit code: {:?}): {}",
"AppleScript 执行失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
@@ -666,86 +641,13 @@ end tell"#,
Ok(())
}
/// macOS: iTerm2
#[cfg(target_os = "macos")]
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
let applescript = format!(
r#"tell application "iTerm"
activate
tell current window
create tab with default profile
tell current session
write text "bash '{}'"
end tell
end tell
end tell"#,
script_file.display()
);
let output = Command::new("osascript")
.arg("-e")
.arg(&applescript)
.output()
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"iTerm2 执行失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
}
Ok(())
}
/// macOS: 使用 open -a 启动支持 --args 参数的终端(Alacritty/Kitty/Ghostty
#[cfg(target_os = "macos")]
fn launch_macos_open_app(
app_name: &str,
script_file: &std::path::Path,
use_e_flag: bool,
) -> Result<(), String> {
use std::process::Command;
let mut cmd = Command::new("open");
cmd.arg("-a").arg(app_name).arg("--args");
if use_e_flag {
cmd.arg("-e");
}
cmd.arg("bash").arg(script_file);
let output = cmd
.output()
.map_err(|e| format!("启动 {} 失败: {e}", app_name))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"{} 启动失败 (exit code: {:?}): {}",
app_name,
output.status.code(),
stderr
));
}
Ok(())
}
/// Linux: 根据用户首选终端启动
/// Linux: 尝试使用常见终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let preferred = crate::settings::get_preferred_terminal();
// Default terminal list with their arguments
let default_terminals = [
let terminals = [
("gnome-terminal", vec!["--"]),
("konsole", vec!["-e"]),
("xfce4-terminal", vec!["-e"]),
@@ -753,10 +655,9 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
("lxterminal", vec!["-e"]),
("alacritty", vec!["-e"]),
("kitty", vec!["-e"]),
("ghostty", vec!["-e"]),
];
// Create temp script file
// Create temp script file (same approach as macOS)
let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy();
@@ -778,48 +679,25 @@ exec bash --norc --noprofile
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
// Build terminal list: preferred terminal first (if specified), then defaults
let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred {
// Find the preferred terminal's args from default list
let pref_args = default_terminals
.iter()
.find(|(name, _)| *name == pref.as_str())
.map(|(_, args)| args.iter().map(|s| *s).collect::<Vec<&str>>())
.unwrap_or_else(|| vec!["-e"]); // Default args for unknown terminals
let mut list = vec![(pref.as_str(), pref_args)];
// Add remaining terminals as fallbacks
for (name, args) in &default_terminals {
if *name != pref.as_str() {
list.push((*name, args.iter().map(|s| *s).collect()));
}
}
list
} else {
default_terminals
.iter()
.map(|(name, args)| (*name, args.iter().map(|s| *s).collect()))
.collect()
};
let mut last_error = String::from("未找到可用的终端");
for (terminal, args) in terminals_to_try {
// Check if terminal exists in common paths
let terminal_exists = std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
for (terminal, args) in terminals {
// Check if terminal exists
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
|| std::path::Path::new(&format!("/usr/local/bin/{}", terminal)).exists()
|| which_command(terminal);
if terminal_exists {
{
let result = Command::new(terminal)
.args(&args)
.arg("bash")
.arg(script_file.to_string_lossy().as_ref())
.spawn();
.output();
match result {
Ok(_) => return Ok(()),
Ok(output) if output.status.success() => return Ok(()),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
last_error = format!("启动 {} 失败: {}", terminal, stderr);
}
Err(e) => {
last_error = format!("执行 {} 失败: {}", terminal, e);
}
@@ -833,25 +711,13 @@ exec bash --norc --noprofile
Err(last_error)
}
/// Check if a command exists using `which`
#[cfg(target_os = "linux")]
fn which_command(cmd: &str) -> bool {
use std::process::Command;
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Windows: 根据用户首选终端启动
/// Windows: 创建临时批处理文件启动
#[cfg(target_os = "windows")]
fn launch_windows_terminal(
temp_dir: &std::path::Path,
config_file: &std::path::Path,
) -> Result<(), String> {
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("cmd");
use std::process::Command;
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
@@ -867,53 +733,23 @@ del \"%~f0\" >nul 2>&1
config_path_for_batch, config_path_for_batch, config_path_for_batch
);
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
let bat_path = bat_file.to_string_lossy();
let ps_cmd = format!("& '{}'", bat_path);
// Try the preferred terminal first
let result = match terminal {
"powershell" => run_windows_start_command(
&["powershell", "-NoExit", "-Command", &ps_cmd],
"PowerShell",
),
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"), // "cmd" or default
};
// If preferred terminal fails and it's not the default, try cmd as fallback
if result.is_err() && terminal != "cmd" {
log::warn!(
"首选终端 {} 启动失败,回退到 cmd: {:?}",
terminal,
result.as_ref().err()
);
return run_windows_start_command(&["cmd", "/K", &bat_path], "cmd");
}
result
}
/// Windows: Run a start command with common error handling
#[cfg(target_os = "windows")]
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
use std::process::Command;
let mut full_args = vec!["/C", "start"];
full_args.extend(args);
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
// Use output() to capture errors from the start command
// Use /K instead of /C to keep the window open after execution
let output = Command::new("cmd")
.args(&full_args)
.args(["/C", "start", "cmd", "/K", &bat_file.to_string_lossy()])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("启动 {} 失败: {e}", terminal_name))?;
.map_err(|e| format!("执行 cmd 失败: {e}"))?;
if !output.status.success() {
// Clean up on failure
let _ = std::fs::remove_file(&bat_file);
let _ = std::fs::remove_file(config_file);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"{} 启动失败 (exit code: {:?}): {}",
terminal_name,
"启动 Windows 终端失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
-115
View File
@@ -2,7 +2,6 @@
//!
//! 提供前端调用的 API 接口
use crate::error::AppError;
use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState;
@@ -120,120 +119,6 @@ pub async fn update_proxy_config_for_app(
.map_err(|e| e.to_string())
}
async fn get_default_cost_multiplier_internal(
state: &AppState,
app_type: &str,
) -> Result<String, AppError> {
let db = &state.db;
db.get_default_cost_multiplier(app_type).await
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub async fn get_default_cost_multiplier_test_hook(
state: &AppState,
app_type: &str,
) -> Result<String, AppError> {
get_default_cost_multiplier_internal(state, app_type).await
}
/// 获取默认成本倍率
#[tauri::command]
pub async fn get_default_cost_multiplier(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<String, String> {
get_default_cost_multiplier_internal(&state, &app_type)
.await
.map_err(|e| e.to_string())
}
async fn set_default_cost_multiplier_internal(
state: &AppState,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
let db = &state.db;
db.set_default_cost_multiplier(app_type, value).await
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub async fn set_default_cost_multiplier_test_hook(
state: &AppState,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
set_default_cost_multiplier_internal(state, app_type, value).await
}
/// 设置默认成本倍率
#[tauri::command]
pub async fn set_default_cost_multiplier(
state: tauri::State<'_, AppState>,
app_type: String,
value: String,
) -> Result<(), String> {
set_default_cost_multiplier_internal(&state, &app_type, &value)
.await
.map_err(|e| e.to_string())
}
async fn get_pricing_model_source_internal(
state: &AppState,
app_type: &str,
) -> Result<String, AppError> {
let db = &state.db;
db.get_pricing_model_source(app_type).await
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub async fn get_pricing_model_source_test_hook(
state: &AppState,
app_type: &str,
) -> Result<String, AppError> {
get_pricing_model_source_internal(state, app_type).await
}
/// 获取计费模式来源
#[tauri::command]
pub async fn get_pricing_model_source(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<String, String> {
get_pricing_model_source_internal(&state, &app_type)
.await
.map_err(|e| e.to_string())
}
async fn set_pricing_model_source_internal(
state: &AppState,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
let db = &state.db;
db.set_pricing_model_source(app_type, value).await
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub async fn set_pricing_model_source_test_hook(
state: &AppState,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
set_pricing_model_source_internal(state, app_type, value).await
}
/// 设置计费模式来源
#[tauri::command]
pub async fn set_pricing_model_source(
state: tauri::State<'_, AppState>,
app_type: String,
value: String,
) -> Result<(), String> {
set_pricing_model_source_internal(&state, &app_type, &value)
.await
.map_err(|e| e.to_string())
}
/// 检查代理服务器是否正在运行
#[tauri::command]
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
+5 -12
View File
@@ -6,17 +6,7 @@ use std::path::{Path, PathBuf};
use crate::error::AppError;
/// 获取用户主目录,带回退和日志
///
/// On Windows, respects the `HOME` environment variable (if set) to support
/// test isolation. Falls back to `dirs::home_dir()` otherwise.
pub fn get_home_dir() -> PathBuf {
#[cfg(windows)]
if let Ok(home) = std::env::var("HOME") {
let trimmed = home.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
@@ -81,7 +71,10 @@ pub fn get_app_config_dir() -> PathBuf {
if let Some(custom) = crate::app_store::get_app_config_dir_override() {
return custom;
}
get_home_dir().join(".cc-switch")
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".cc-switch")
}
/// 获取应用配置文件路径
+7 -247
View File
@@ -4,7 +4,6 @@
use crate::error::AppError;
use crate::proxy::types::*;
use rust_decimal::Decimal;
use super::super::{lock_conn, Database};
@@ -76,117 +75,6 @@ impl Database {
Ok(())
}
/// 获取默认成本倍率
pub async fn get_default_cost_multiplier(&self, app_type: &str) -> Result<String, AppError> {
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT default_cost_multiplier FROM proxy_config WHERE app_type = ?1",
[app_type],
|row| row.get(0),
)
};
match result {
Ok(value) => Ok(value),
Err(rusqlite::Error::QueryReturnedNoRows) => {
self.init_proxy_config_rows().await?;
Ok("1".to_string())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 设置默认成本倍率
pub async fn set_default_cost_multiplier(
&self,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::localized(
"error.multiplierEmpty",
"倍率不能为空",
"Multiplier cannot be empty",
));
}
trimmed.parse::<Decimal>().map_err(|e| {
AppError::localized(
"error.invalidMultiplier",
format!("无效倍率: {value} - {e}"),
format!("Invalid multiplier: {value} - {e}"),
)
})?;
// 确保行存在
self.ensure_proxy_config_row_exists(app_type)?;
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE proxy_config SET
default_cost_multiplier = ?2,
updated_at = datetime('now')
WHERE app_type = ?1",
rusqlite::params![app_type, trimmed],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 获取计费模式来源
pub async fn get_pricing_model_source(&self, app_type: &str) -> Result<String, AppError> {
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT pricing_model_source FROM proxy_config WHERE app_type = ?1",
[app_type],
|row| row.get(0),
)
};
match result {
Ok(value) => Ok(value),
Err(rusqlite::Error::QueryReturnedNoRows) => {
self.init_proxy_config_rows().await?;
Ok("response".to_string())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 设置计费模式来源
pub async fn set_pricing_model_source(
&self,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
let trimmed = value.trim();
if !matches!(trimmed, "response" | "request") {
return Err(AppError::localized(
"error.invalidPricingMode",
format!("无效计费模式: {value}"),
format!("Invalid pricing mode: {value}"),
));
}
// 确保行存在
self.ensure_proxy_config_row_exists(app_type)?;
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE proxy_config SET
pricing_model_source = ?2,
updated_at = datetime('now')
WHERE app_type = ?1",
rusqlite::params![app_type, trimmed],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 获取应用级代理配置
pub async fn get_proxy_config_for_app(
&self,
@@ -289,90 +177,17 @@ impl Database {
Ok(())
}
/// 确保指定 app_type 的 proxy_config 行存在(同步版本,用于 set_* 函数)
///
/// 使用与 schema.rs seed 相同的 per-app 默认值
fn ensure_proxy_config_row_exists(&self, app_type: &str) -> Result<(), AppError> {
let conn = self
.conn
.lock()
.map_err(|e| AppError::Lock(e.to_string()))?;
// 根据 app_type 使用不同的默认值(与 schema.rs seed 保持一致)
let (retries, fb_timeout, idle_timeout, cb_fail, cb_succ, cb_timeout, cb_rate, cb_min) =
match app_type {
"claude" => (6, 90, 180, 8, 3, 90, 0.7, 15),
"codex" => (3, 60, 120, 4, 2, 60, 0.6, 10),
"gemini" => (5, 60, 120, 4, 2, 60, 0.6, 10),
_ => (3, 60, 120, 4, 2, 60, 0.6, 10), // 默认值
};
conn.execute(
"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 (?1, ?2, ?3, ?4, 600, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params![
app_type,
retries,
fb_timeout,
idle_timeout,
cb_fail,
cb_succ,
cb_timeout,
cb_rate,
cb_min
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 初始化 proxy_config 表的三行数据
///
/// 使用与 schema.rs seed 相同的 per-app 默认值
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 使用与 schema.rs seed 相同的 per-app 默认值
// claude: 更激进的重试和超时配置
conn.execute(
"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, 90, 180, 600, 8, 3, 90, 0.7, 15)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// codex: 默认配置
conn.execute(
"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, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// gemini: 稍高的重试次数
conn.execute(
"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, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
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(())
}
@@ -847,58 +662,3 @@ impl Database {
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::database::Database;
use crate::error::AppError;
#[tokio::test]
async fn test_default_cost_multiplier_round_trip() -> Result<(), AppError> {
let db = Database::memory()?;
let default = db.get_default_cost_multiplier("claude").await?;
assert_eq!(default, "1");
db.set_default_cost_multiplier("claude", "1.5").await?;
let updated = db.get_default_cost_multiplier("claude").await?;
assert_eq!(updated, "1.5");
Ok(())
}
#[tokio::test]
async fn test_default_cost_multiplier_validation() -> Result<(), AppError> {
let db = Database::memory()?;
let err = db
.set_default_cost_multiplier("claude", "not-a-number")
.await
.unwrap_err();
// AppError::localized returns AppError::Localized variant
assert!(matches!(err, AppError::Localized { key: "error.invalidMultiplier", .. }));
Ok(())
}
#[tokio::test]
async fn test_pricing_model_source_round_trip_and_validation() -> Result<(), AppError> {
let db = Database::memory()?;
let default = db.get_pricing_model_source("claude").await?;
assert_eq!(default, "response");
db.set_pricing_model_source("claude", "request").await?;
let updated = db.get_pricing_model_source("claude").await?;
assert_eq!(updated, "request");
let err = db
.set_pricing_model_source("claude", "invalid")
.await
.unwrap_err();
// AppError::localized returns AppError::Localized variant
assert!(matches!(err, AppError::Localized { key: "error.invalidPricingMode", .. }));
Ok(())
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 5;
pub(crate) const SCHEMA_VERSION: i32 = 4;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
-35
View File
@@ -120,8 +120,6 @@ impl Database {
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, 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.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
pricing_model_source TEXT NOT NULL DEFAULT 'response',
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", []).map_err(|e| AppError::Database(e.to_string()))?;
@@ -172,7 +170,6 @@ impl Database {
// 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,
request_model TEXT,
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',
@@ -355,11 +352,6 @@ impl Database {
Self::migrate_v3_to_v4(conn)?;
Self::set_user_version(conn, 4)?;
}
4 => {
log::info!("迁移数据库从 v4 到 v5(计费模式支持)");
Self::migrate_v4_to_v5(conn)?;
Self::set_user_version(conn, 5)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -529,7 +521,6 @@ impl Database {
// 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,
request_model TEXT,
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',
@@ -686,8 +677,6 @@ impl Database {
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, 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.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
pricing_model_source TEXT NOT NULL DEFAULT 'response',
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", [])?;
@@ -890,30 +879,6 @@ impl Database {
Ok(())
}
/// v4 -> v5 迁移:新增计费模式配置与请求模型字段
fn migrate_v4_to_v5(conn: &Connection) -> Result<(), AppError> {
if Self::table_exists(conn, "proxy_config")? {
Self::add_column_if_missing(
conn,
"proxy_config",
"default_cost_multiplier",
"TEXT NOT NULL DEFAULT '1'",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"pricing_model_source",
"TEXT NOT NULL DEFAULT 'response'",
)?;
}
if Self::table_exists(conn, "proxy_request_logs")? {
Self::add_column_if_missing(conn, "proxy_request_logs", "request_model", "TEXT")?;
}
log::info!("v4 -> v5 迁移完成:已添加计费模式与请求模型字段");
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
+7 -67
View File
@@ -151,7 +151,7 @@ fn normalize_default(default: &Option<String>) -> Option<String> {
}
#[test]
fn schema_migration_sets_user_version_when_missing() {
fn migration_sets_user_version_when_missing() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
@@ -169,7 +169,7 @@ fn schema_migration_sets_user_version_when_missing() {
}
#[test]
fn schema_migration_rejects_future_version() {
fn migration_rejects_future_version() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
@@ -183,7 +183,7 @@ fn schema_migration_rejects_future_version() {
}
#[test]
fn schema_migration_adds_missing_columns_for_providers() {
fn migration_adds_missing_columns_for_providers() {
let conn = Connection::open_in_memory().expect("open memory db");
// 创建旧版 providers 表,缺少新增列
@@ -224,7 +224,7 @@ fn schema_migration_adds_missing_columns_for_providers() {
}
#[test]
fn schema_migration_aligns_column_defaults_and_types() {
fn migration_aligns_column_defaults_and_types() {
let conn = Connection::open_in_memory().expect("open memory db");
conn.execute_batch(LEGACY_SCHEMA_SQL)
.expect("seed old schema");
@@ -268,67 +268,7 @@ fn schema_migration_aligns_column_defaults_and_types() {
}
#[test]
fn schema_create_tables_include_pricing_model_columns() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
let multiplier = get_column_info(&conn, "proxy_config", "default_cost_multiplier");
assert_eq!(multiplier.r#type, "TEXT");
assert_eq!(multiplier.notnull, 1);
assert_eq!(normalize_default(&multiplier.default).as_deref(), Some("1"));
let pricing_source = get_column_info(&conn, "proxy_config", "pricing_model_source");
assert_eq!(pricing_source.r#type, "TEXT");
assert_eq!(pricing_source.notnull, 1);
assert_eq!(
normalize_default(&pricing_source.default).as_deref(),
Some("response")
);
let request_model = get_column_info(&conn, "proxy_request_logs", "request_model");
assert_eq!(request_model.r#type, "TEXT");
assert_eq!(request_model.notnull, 0);
}
#[test]
fn schema_migration_v4_adds_pricing_model_columns() {
let conn = Connection::open_in_memory().expect("open memory db");
conn.execute_batch(
r#"
CREATE TABLE proxy_config (app_type TEXT PRIMARY KEY);
CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY, model TEXT NOT NULL);
"#,
)
.expect("seed v4 schema");
Database::set_user_version(&conn, 4).expect("set user_version=4");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
let multiplier = get_column_info(&conn, "proxy_config", "default_cost_multiplier");
assert_eq!(multiplier.r#type, "TEXT");
assert_eq!(multiplier.notnull, 1);
assert_eq!(normalize_default(&multiplier.default).as_deref(), Some("1"));
let pricing_source = get_column_info(&conn, "proxy_config", "pricing_model_source");
assert_eq!(pricing_source.r#type, "TEXT");
assert_eq!(pricing_source.notnull, 1);
assert_eq!(
normalize_default(&pricing_source.default).as_deref(),
Some("response")
);
let request_model = get_column_info(&conn, "proxy_request_logs", "request_model");
assert_eq!(request_model.r#type, "TEXT");
assert_eq!(request_model.notnull, 0);
assert_eq!(
Database::get_user_version(&conn).expect("version after migration"),
SCHEMA_VERSION
);
}
#[test]
fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
let conn = Connection::open_in_memory().expect("open memory db");
// 模拟测试版 v2user_version=2,但 proxy_config 仍是单例结构(无 app_type
@@ -493,7 +433,7 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
}
#[test]
fn schema_dry_run_does_not_write_to_disk() {
fn dry_run_does_not_write_to_disk() {
// Create minimal valid config for migration
let mut apps = HashMap::new();
apps.insert("claude".to_string(), ProviderManager::default());
@@ -567,7 +507,7 @@ fn dry_run_validates_schema_compatibility() {
}
#[test]
fn schema_model_pricing_is_seeded_on_init() {
fn model_pricing_is_seeded_on_init() {
let db = Database::memory().expect("create memory db");
let conn = db.conn.lock().expect("lock conn");
+9 -1
View File
@@ -1,10 +1,18 @@
use crate::config::{get_home_dir, write_text_file};
use crate::config::write_text_file;
use crate::error::AppError;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Gemini 配置目录路径(支持设置覆盖)
pub fn get_gemini_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_gemini_override_dir() {
-22
View File
@@ -745,24 +745,6 @@ pub fn run() {
restore_proxy_state_on_startup(&state).await;
});
// 静默启动:根据设置决定是否显示主窗口
let settings = crate::settings::get_settings();
if let Some(window) = app.get_webview_window("main") {
if settings.silent_startup {
// 静默启动模式:保持窗口隐藏
let _ = window.hide();
#[cfg(target_os = "windows")]
let _ = window.set_skip_taskbar(true);
#[cfg(target_os = "macos")]
tray::apply_tray_policy(app.handle(), false);
log::info!("静默启动模式:主窗口已隐藏");
} else {
// 正常启动模式:显示窗口
let _ = window.show();
log::info!("正常启动模式:主窗口已显示");
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -895,10 +877,6 @@ pub fn run() {
commands::update_global_proxy_config,
commands::get_proxy_config_for_app,
commands::update_proxy_config_for_app,
commands::get_default_cost_multiplier,
commands::set_default_cost_multiplier,
commands::get_pricing_model_source,
commands::set_pricing_model_source,
commands::is_proxy_running,
commands::is_live_takeover_active,
commands::switch_proxy_provider,
+20 -267
View File
@@ -191,6 +191,23 @@ pub struct ProviderProxyConfig {
pub proxy_password: Option<String>,
}
/// 格式转换配置(用于 OpenRouter 等需要 API 格式转换的供应商)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FormatTransformConfig {
/// 是否启用格式转换
#[serde(default)]
pub enabled: bool,
/// 源格式:anthropic, openai, gemini
#[serde(rename = "sourceFormat", skip_serializing_if = "Option::is_none")]
pub source_format: Option<String>,
/// 目标格式:anthropic, openai, gemini
#[serde(rename = "targetFormat", skip_serializing_if = "Option::is_none")]
pub target_format: Option<String>,
/// 是否转换流式响应(默认 true)
#[serde(rename = "transformStreaming", skip_serializing_if = "Option::is_none")]
pub transform_streaming: Option<bool>,
}
/// 供应商元数据
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
@@ -215,9 +232,6 @@ pub struct ProviderMeta {
/// 成本倍数(用于计算实际成本)
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
pub cost_multiplier: Option<String>,
/// 计费模式来源(response/request
#[serde(rename = "pricingModelSource", skip_serializing_if = "Option::is_none")]
pub pricing_model_source: Option<String>,
/// 每日消费限额(USD
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
pub limit_daily_usd: Option<String>,
@@ -230,6 +244,9 @@ pub struct ProviderMeta {
/// 供应商单独的代理配置
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
pub proxy_config: Option<ProviderProxyConfig>,
/// 格式转换配置(用于 OpenRouter 等需要 API 格式转换的供应商)
#[serde(rename = "formatTransform", skip_serializing_if = "Option::is_none")]
pub format_transform: Option<FormatTransformConfig>,
}
impl ProviderManager {
@@ -617,267 +634,3 @@ pub struct OpenCodeModelLimit {
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<u64>,
}
#[cfg(test)]
mod tests {
use super::{
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
ProviderManager, ProviderMeta, UniversalProvider,
};
use serde_json::json;
#[test]
fn provider_meta_serializes_pricing_model_source() {
let mut meta = ProviderMeta::default();
meta.pricing_model_source = Some("response".to_string());
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
assert_eq!(
value
.get("pricingModelSource")
.and_then(|item| item.as_str()),
Some("response")
);
assert!(value.get("pricing_model_source").is_none());
}
#[test]
fn provider_meta_omits_pricing_model_source_when_none() {
let meta = ProviderMeta::default();
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
assert!(value.get("pricingModelSource").is_none());
}
#[test]
fn provider_with_id_populates_defaults() {
let settings_config = json!({
"env": { "API_KEY": "test" }
});
let provider = Provider::with_id(
"provider-1".to_string(),
"Provider".to_string(),
settings_config.clone(),
Some("https://example.com".to_string()),
);
assert_eq!(provider.id, "provider-1");
assert_eq!(provider.name, "Provider");
assert_eq!(provider.settings_config, settings_config);
assert_eq!(provider.website_url.as_deref(), Some("https://example.com"));
assert!(provider.category.is_none());
assert!(provider.created_at.is_none());
assert!(provider.sort_index.is_none());
assert!(provider.notes.is_none());
assert!(provider.meta.is_none());
assert!(provider.icon.is_none());
assert!(provider.icon_color.is_none());
assert!(!provider.in_failover_queue);
}
#[test]
fn provider_manager_get_all_providers_returns_map() {
let mut manager = ProviderManager::default();
let provider = Provider::with_id(
"provider-1".to_string(),
"Provider".to_string(),
json!({ "env": {} }),
None,
);
manager.providers.insert("provider-1".to_string(), provider);
assert_eq!(manager.get_all_providers().len(), 1);
assert!(manager.get_all_providers().contains_key("provider-1"));
}
#[test]
fn universal_provider_to_claude_provider_uses_models() {
let mut universal = UniversalProvider::new(
"u1".to_string(),
"Universal".to_string(),
"newapi".to_string(),
"https://api.example.com".to_string(),
"api-key".to_string(),
);
universal.apps.claude = true;
universal.models.claude = Some(ClaudeModelConfig {
model: Some("claude-main".to_string()),
haiku_model: Some("claude-haiku".to_string()),
sonnet_model: Some("claude-sonnet".to_string()),
opus_model: Some("claude-opus".to_string()),
});
let provider = universal.to_claude_provider().expect("claude provider");
assert_eq!(provider.id, "universal-claude-u1");
assert_eq!(provider.name, "Universal");
assert_eq!(provider.category.as_deref(), Some("aggregator"));
assert_eq!(
provider
.settings_config
.pointer("/env/ANTHROPIC_MODEL")
.and_then(|item| item.as_str()),
Some("claude-main")
);
assert_eq!(
provider
.settings_config
.pointer("/env/ANTHROPIC_DEFAULT_HAIKU_MODEL")
.and_then(|item| item.as_str()),
Some("claude-haiku")
);
assert_eq!(
provider
.settings_config
.pointer("/env/ANTHROPIC_DEFAULT_SONNET_MODEL")
.and_then(|item| item.as_str()),
Some("claude-sonnet")
);
assert_eq!(
provider
.settings_config
.pointer("/env/ANTHROPIC_DEFAULT_OPUS_MODEL")
.and_then(|item| item.as_str()),
Some("claude-opus")
);
}
#[test]
fn universal_provider_to_claude_provider_disabled_returns_none() {
let universal = UniversalProvider::new(
"u1".to_string(),
"Universal".to_string(),
"newapi".to_string(),
"https://api.example.com".to_string(),
"api-key".to_string(),
);
assert!(universal.to_claude_provider().is_none());
}
#[test]
fn universal_provider_to_codex_provider_appends_v1() {
let mut universal = UniversalProvider::new(
"u1".to_string(),
"Universal".to_string(),
"newapi".to_string(),
"https://api.example.com".to_string(),
"api-key".to_string(),
);
universal.apps.codex = true;
universal.models.codex = Some(CodexModelConfig {
model: Some("gpt-4o-mini".to_string()),
reasoning_effort: Some("low".to_string()),
});
let provider = universal.to_codex_provider().expect("codex provider");
let config = provider
.settings_config
.get("config")
.and_then(|item| item.as_str())
.expect("config toml");
assert!(config.contains("base_url = \"https://api.example.com/v1\""));
assert_eq!(
provider
.settings_config
.pointer("/auth/OPENAI_API_KEY")
.and_then(|item| item.as_str()),
Some("api-key")
);
}
#[test]
fn universal_provider_to_codex_provider_keeps_v1_suffix() {
let mut universal = UniversalProvider::new(
"u1".to_string(),
"Universal".to_string(),
"newapi".to_string(),
"https://api.example.com/v1".to_string(),
"api-key".to_string(),
);
universal.apps.codex = true;
let provider = universal.to_codex_provider().expect("codex provider");
let config = provider
.settings_config
.get("config")
.and_then(|item| item.as_str())
.expect("config toml");
assert!(config.contains("base_url = \"https://api.example.com/v1\""));
}
#[test]
fn universal_provider_to_codex_provider_disabled_returns_none() {
let universal = UniversalProvider::new(
"u1".to_string(),
"Universal".to_string(),
"newapi".to_string(),
"https://api.example.com".to_string(),
"api-key".to_string(),
);
assert!(universal.to_codex_provider().is_none());
}
#[test]
fn universal_provider_to_gemini_provider_defaults_model() {
let mut universal = UniversalProvider::new(
"u1".to_string(),
"Universal".to_string(),
"newapi".to_string(),
"https://api.example.com".to_string(),
"api-key".to_string(),
);
universal.apps.gemini = true;
let provider = universal.to_gemini_provider().expect("gemini provider");
assert_eq!(
provider
.settings_config
.pointer("/env/GEMINI_MODEL")
.and_then(|item| item.as_str()),
Some("gemini-2.5-pro")
);
}
#[test]
fn universal_provider_to_gemini_provider_uses_model() {
let mut universal = UniversalProvider::new(
"u1".to_string(),
"Universal".to_string(),
"newapi".to_string(),
"https://api.example.com".to_string(),
"api-key".to_string(),
);
universal.apps.gemini = true;
universal.models.gemini = Some(GeminiModelConfig {
model: Some("gemini-custom".to_string()),
});
let provider = universal.to_gemini_provider().expect("gemini provider");
assert_eq!(
provider
.settings_config
.pointer("/env/GEMINI_MODEL")
.and_then(|item| item.as_str()),
Some("gemini-custom")
);
}
#[test]
fn opencode_provider_config_defaults() {
let config = OpenCodeProviderConfig::default();
assert_eq!(config.npm, "@ai-sdk/openai-compatible");
assert!(config.name.is_none());
assert!(config.models.is_empty());
assert!(config.options.base_url.is_none());
assert!(config.options.api_key.is_none());
assert!(config.options.headers.is_none());
assert!(config.options.extra.is_empty());
}
}
+50 -12
View File
@@ -9,6 +9,7 @@ use super::{
provider_router::ProviderRouter,
providers::{get_adapter, ProviderAdapter, ProviderType},
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
transform::{get_transformer, TransformConfig},
types::{ProxyStatus, RectifierConfig},
ProxyError,
};
@@ -558,26 +559,63 @@ impl RequestForwarder {
// 使用适配器提取 base_url
let base_url = adapter.extract_base_url(provider)?;
// 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider);
// 获取格式转换配置
let transform_config = TransformConfig::from_provider(provider);
let needs_transform = transform_config.needs_transform();
let effective_endpoint =
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
"/v1/chat/completions"
} else {
endpoint
};
// 如果需要转换但找不到转换器,直接返回错误(避免静默透传后在响应阶段失败)
let transformer = if needs_transform {
let t = get_transformer(
transform_config.source_format,
transform_config.target_format,
);
if t.is_none() {
log::error!(
"[Forwarder] 格式转换已启用但找不到转换器: {:?} → {:?}",
transform_config.source_format,
transform_config.target_format
);
return Err(ProxyError::TransformError(format!(
"No transformer registered for {:?}{:?}. Please disable format transform or use supported formats (Anthropic ↔ OpenAI).",
transform_config.source_format,
transform_config.target_format
)));
}
t
} else {
None
};
// 确定有效端点
let effective_endpoint = if let Some(ref t) = transformer {
t.transform_endpoint(endpoint)
} else {
endpoint.to_string()
};
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, effective_endpoint);
let url = adapter.build_url(&base_url, &effective_endpoint);
// 应用模型映射(独立于格式转换)
let (mapped_body, _original_model, _mapped_model) =
let (mut mapped_body, _original_model, _mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
// 如果启用格式转换但禁用流式转换,强制将 stream 设为 false
// 避免上游返回 SSE 流但我们无法转换的情况
if needs_transform && !transform_config.transform_streaming {
if let Some(stream_val) = mapped_body.get("stream") {
if stream_val.as_bool() == Some(true) {
log::info!("[Forwarder] transform_streaming=false,强制将 stream 设为 false");
if let Some(obj) = mapped_body.as_object_mut() {
obj.insert("stream".to_string(), serde_json::Value::Bool(false));
}
}
}
}
// 转换请求体(如果需要)
let request_body = if needs_transform {
adapter.transform_request(mapped_body, provider)?
let request_body = if let Some(ref t) = transformer {
t.transform_request(mapped_body)?
} else {
mapped_body
};
+55 -24
View File
@@ -13,16 +13,18 @@ use super::{
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
},
handler_context::RequestContext,
providers::{get_adapter, streaming::create_anthropic_sse_stream, transform},
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
server::ProxyState,
transform::{get_transformer, TransformConfig},
types::*,
usage::parser::TokenUsage,
ProxyError,
};
use crate::app_config::AppType;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use rust_decimal::Decimal;
use serde_json::{json, Value};
use std::str::FromStr;
// ============================================================================
// 健康检查和状态查询(简单端点)
@@ -92,13 +94,20 @@ pub async fn handle_messages(
ctx.provider = result.provider;
let response = result.response;
// 检查是否需要格式转换(OpenRouter 等中转服务
let adapter = get_adapter(&AppType::Claude);
let needs_transform = adapter.needs_transform(&ctx.provider);
// 检查是否需要格式转换(通过 Provider 配置
let transform_config = TransformConfig::from_provider(&ctx.provider);
// Claude 特有:格式转换处理
if needs_transform {
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
if transform_config.needs_transform() {
return handle_claude_transform(
response,
&ctx,
&state,
&body,
is_stream,
&transform_config,
)
.await;
}
// 通用响应处理(透传模式)
@@ -114,13 +123,26 @@ async fn handle_claude_transform(
state: &ProxyState,
_original_body: &Value,
is_stream: bool,
transform_config: &TransformConfig,
) -> Result<axum::response::Response, ProxyError> {
let status = response.status();
if is_stream {
// 获取响应转换器(OpenAI → Anthropic
let response_transformer = get_transformer(
transform_config.target_format,
transform_config.source_format,
)
.ok_or_else(|| {
ProxyError::TransformError(format!(
"No transformer for {:?}{:?}",
transform_config.target_format, transform_config.source_format
))
})?;
if is_stream && transform_config.transform_streaming {
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
let stream = response.bytes_stream();
let sse_stream = create_anthropic_sse_stream(stream);
let sse_stream = response_transformer.transform_stream(Box::pin(stream));
// 创建使用量收集器
let usage_collector = {
@@ -143,7 +165,6 @@ async fn handle_claude_transform(
&provider_id,
"claude",
&model,
&model,
usage,
latency_ms,
first_token_ms,
@@ -201,10 +222,12 @@ async fn handle_claude_transform(
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
})?;
let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| {
log::error!("[Claude] 转换响应失败: {e}");
e
})?;
let anthropic_response = response_transformer
.transform_response(openai_response)
.map_err(|e| {
log::error!("[Claude] 转换响应失败: {e}");
e
})?;
// 记录使用量
if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {
@@ -214,7 +237,6 @@ async fn handle_claude_transform(
.unwrap_or("unknown");
let latency_ms = ctx.latency_ms();
let request_model = ctx.request_model.clone();
tokio::spawn({
let state = state.clone();
let provider_id = ctx.provider.id.clone();
@@ -225,7 +247,6 @@ async fn handle_claude_transform(
&provider_id,
"claude",
&model,
&request_model,
usage,
latency_ms,
None,
@@ -442,7 +463,6 @@ async fn log_usage(
provider_id: &str,
app_type: &str,
model: &str,
request_model: &str,
usage: TokenUsage,
latency_ms: u64,
first_token_ms: Option<u64>,
@@ -453,12 +473,25 @@ async fn log_usage(
let logger = UsageLogger::new(&state.db);
let (multiplier, pricing_model_source) =
logger.resolve_pricing_config(provider_id, app_type).await;
let pricing_model = if pricing_model_source == "request" {
request_model
} else {
model
// 获取 provider 的 cost_multiplier
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
Ok(Some(p)) => {
if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier {
Decimal::from_str(&cm).unwrap_or_else(|e| {
log::warn!(
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
);
Decimal::from(1)
})
} else {
Decimal::from(1)
}
} else {
Decimal::from(1)
}
}
_ => Decimal::from(1),
};
let request_id = uuid::Uuid::new_v4().to_string();
@@ -468,8 +501,6 @@ async fn log_usage(
provider_id.to_string(),
app_type.to_string(),
model.to_string(),
request_model.to_string(),
pricing_model.to_string(),
usage,
multiplier,
latency_ms,
+1
View File
@@ -22,6 +22,7 @@ pub mod response_processor;
pub(crate) mod server;
pub mod session;
pub mod thinking_rectifier;
pub mod transform;
pub(crate) mod types;
pub mod usage;
-46
View File
@@ -6,7 +6,6 @@ use super::auth::AuthInfo;
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
use serde_json::Value;
/// 供应商适配器 Trait
///
@@ -83,49 +82,4 @@ pub trait ProviderAdapter: Send + Sync {
/// # Returns
/// 添加了认证头的 RequestBuilder
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder;
/// 是否需要格式转换
///
/// 默认返回 `false`(透传模式)。
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。
///
/// # Arguments
/// * `provider` - Provider 配置
fn needs_transform(&self, _provider: &Provider) -> bool {
false
}
/// 转换请求体
///
/// 将请求体从一种格式转换为另一种格式(如 Anthropic → OpenAI)。
/// 默认实现直接返回原始请求体(透传)。
///
/// # Arguments
/// * `body` - 原始请求体
/// * `provider` - Provider 配置(用于获取模型映射等)
///
/// # Returns
/// * `Ok(Value)` - 转换后的请求体
/// * `Err(ProxyError)` - 转换失败
fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> {
Ok(body)
}
/// 转换响应体
///
/// 将响应体从一种格式转换为另一种格式(如 OpenAI → Anthropic)。
/// 默认实现直接返回原始响应体(透传)。
///
/// # Arguments
/// * `body` - 原始响应体
///
/// # Returns
/// * `Ok(Value)` - 转换后的响应体
/// * `Err(ProxyError)` - 转换失败
///
/// Note: 响应转换将在 handler 层集成,目前预留接口
#[allow(dead_code)]
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
Ok(body)
}
}
-77
View File
@@ -48,25 +48,6 @@ impl ClaudeAdapter {
false
}
/// 检测 OpenRouter 是否启用兼容模式
fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool {
if !self.is_openrouter(provider) {
return false;
}
let raw = provider.settings_config.get("openrouter_compat_mode");
match raw {
Some(serde_json::Value::Bool(enabled)) => *enabled,
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
Some(serde_json::Value::String(value)) => {
let normalized = value.trim().to_lowercase();
normalized == "true" || normalized == "1"
}
// OpenRouter now supports Claude Code compatible API, default to passthrough
_ => false,
}
}
/// 检测是否为仅 Bearer 认证模式
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
// 检查 settings_config 中的 auth_mode
@@ -252,27 +233,6 @@ impl ProviderAdapter for ClaudeAdapter {
_ => request,
}
}
fn needs_transform(&self, _provider: &Provider) -> bool {
// NOTE:
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
// Anthropic ↔ OpenAI 的格式转换。
//
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
self.is_openrouter_compat_enabled(_provider)
}
fn transform_request(
&self,
body: serde_json::Value,
provider: &Provider,
) -> Result<serde_json::Value, ProxyError> {
super::transform::anthropic_to_openai(body, provider)
}
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
super::transform::openai_to_anthropic(body)
}
}
#[cfg(test)]
@@ -454,41 +414,4 @@ mod tests {
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
}
#[test]
fn test_needs_transform() {
let adapter = ClaudeAdapter::new();
let anthropic_provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}));
assert!(!adapter.needs_transform(&anthropic_provider));
// OpenRouter provider without explicit setting now defaults to passthrough (no transform)
let openrouter_provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
}
}));
assert!(!adapter.needs_transform(&openrouter_provider));
// OpenRouter provider with explicit compat mode enabled should transform
let openrouter_enabled = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
},
"openrouter_compat_mode": true
}));
assert!(adapter.needs_transform(&openrouter_enabled));
let openrouter_disabled = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
},
"openrouter_compat_mode": false
}));
assert!(!adapter.needs_transform(&openrouter_disabled));
}
}
+41
View File
@@ -141,6 +141,20 @@ impl ProviderAdapter for CodexAdapter {
let base_trimmed = base_url.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
// 检查 base_url 是否已包含 endpoint 的核心路径
// 例如:base_url = "https://api.example.com/v1/chat/completions"
// endpoint = "/v1/chat/completions"
// 此时不应再拼接,直接返回 base_url
let endpoint_core = endpoint_trimmed
.trim_start_matches("v1/")
.trim_start_matches("v1");
let endpoint_core = endpoint_core.trim_start_matches('/');
// 如果 base_url 已经以 endpoint 核心路径结尾,直接返回 base_url
if !endpoint_core.is_empty() && base_trimmed.ends_with(endpoint_core) {
return base_trimmed.to_string();
}
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
// 去除重复的 /v1/v1
@@ -231,6 +245,33 @@ mod tests {
assert_eq!(url, "https://www.packyapi.com/v1/responses");
}
#[test]
fn test_build_url_base_already_has_chat_completions() {
let adapter = CodexAdapter::new();
// base_url 已包含 chat/completions,不应再拼接
let url = adapter.build_url(
"https://api.example.com/v1/chat/completions",
"/v1/chat/completions",
);
assert_eq!(url, "https://api.example.com/v1/chat/completions");
}
#[test]
fn test_build_url_base_already_has_responses() {
let adapter = CodexAdapter::new();
// base_url 已包含 responses,不应再拼接
let url = adapter.build_url("https://api.example.com/v1/responses", "/v1/responses");
assert_eq!(url, "https://api.example.com/v1/responses");
}
#[test]
fn test_build_url_base_without_endpoint() {
let adapter = CodexAdapter::new();
// base_url 不包含 endpoint,应正常拼接
let url = adapter.build_url("https://api.example.com/v1", "/v1/chat/completions");
assert_eq!(url, "https://api.example.com/v1/chat/completions");
}
// 官方客户端检测测试
#[test]
fn test_is_official_client_vscode() {
-2
View File
@@ -17,8 +17,6 @@ mod claude;
mod codex;
mod gemini;
pub mod models;
pub mod streaming;
pub mod transform;
use crate::app_config::AppType;
use crate::provider::Provider;
-640
View File
@@ -1,640 +0,0 @@
//! 格式转换模块
//!
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
//! 参考: anthropic-proxy-rs
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use serde_json::{json, Value};
/// 从 Provider 配置中获取模型映射
fn get_model_from_provider(model: &str, provider: &Provider, body: &Value) -> String {
let env = provider.settings_config.get("env");
let model_lower = model.to_lowercase();
// 检测 thinking 参数
let has_thinking = body
.get("thinking")
.and_then(|v| v.as_object())
.and_then(|o| o.get("type"))
.and_then(|t| t.as_str())
== Some("enabled");
if let Some(env) = env {
// 如果启用 thinking,优先使用推理模型
if has_thinking {
if let Some(m) = env
.get("ANTHROPIC_REASONING_MODEL")
.and_then(|v| v.as_str())
{
log::debug!("[Transform] 使用推理模型: {m}");
return m.to_string();
}
}
// 根据模型类型选择配置模型
if model_lower.contains("haiku") {
if let Some(m) = env
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
.and_then(|v| v.as_str())
{
return m.to_string();
}
}
if model_lower.contains("opus") {
if let Some(m) = env
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
.and_then(|v| v.as_str())
{
return m.to_string();
}
}
if model_lower.contains("sonnet") {
if let Some(m) = env
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
.and_then(|v| v.as_str())
{
return m.to_string();
}
}
// 默认使用 ANTHROPIC_MODEL
if let Some(m) = env.get("ANTHROPIC_MODEL").and_then(|v| v.as_str()) {
return m.to_string();
}
}
model.to_string()
}
/// Anthropic 请求 → OpenAI 请求
pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result<Value, ProxyError> {
let mut result = json!({});
// 模型映射:使用 Provider 配置中的模型(支持 thinking 参数)
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
let mapped_model = get_model_from_provider(model, provider, &body);
result["model"] = json!(mapped_model);
}
let mut messages = Vec::new();
// 处理 system prompt
if let Some(system) = body.get("system") {
if let Some(text) = system.as_str() {
// 单个字符串
messages.push(json!({"role": "system", "content": text}));
} else if let Some(arr) = system.as_array() {
// 多个 system message
for msg in arr {
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
messages.push(json!({"role": "system", "content": text}));
}
}
}
}
// 转换 messages
if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) {
for msg in msgs {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
let content = msg.get("content");
let converted = convert_message_to_openai(role, content)?;
messages.extend(converted);
}
}
result["messages"] = json!(messages);
// 转换参数
if let Some(v) = body.get("max_tokens") {
result["max_tokens"] = v.clone();
}
if let Some(v) = body.get("temperature") {
result["temperature"] = v.clone();
}
if let Some(v) = body.get("top_p") {
result["top_p"] = v.clone();
}
if let Some(v) = body.get("stop_sequences") {
result["stop"] = v.clone();
}
if let Some(v) = body.get("stream") {
result["stream"] = v.clone();
}
// 转换 tools (过滤 BatchTool)
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
let openai_tools: Vec<Value> = tools
.iter()
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
.map(|t| {
json!({
"type": "function",
"function": {
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
"description": t.get("description"),
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
}
})
})
.collect();
if !openai_tools.is_empty() {
result["tools"] = json!(openai_tools);
}
}
if let Some(v) = body.get("tool_choice") {
result["tool_choice"] = v.clone();
}
Ok(result)
}
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
fn convert_message_to_openai(
role: &str,
content: Option<&Value>,
) -> Result<Vec<Value>, ProxyError> {
let mut result = Vec::new();
let content = match content {
Some(c) => c,
None => {
result.push(json!({"role": role, "content": null}));
return Ok(result);
}
};
// 字符串内容
if let Some(text) = content.as_str() {
result.push(json!({"role": role, "content": text}));
return Ok(result);
}
// 数组内容(多模态/工具调用)
if let Some(blocks) = content.as_array() {
let mut content_parts = Vec::new();
let mut tool_calls = Vec::new();
for block in blocks {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
content_parts.push(json!({"type": "text", "text": text}));
}
}
"image" => {
if let Some(source) = block.get("source") {
let media_type = source
.get("media_type")
.and_then(|m| m.as_str())
.unwrap_or("image/png");
let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
content_parts.push(json!({
"type": "image_url",
"image_url": {"url": format!("data:{};base64,{}", media_type, data)}
}));
}
}
"tool_use" => {
let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
let input = block.get("input").cloned().unwrap_or(json!({}));
tool_calls.push(json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": serde_json::to_string(&input).unwrap_or_default()
}
}));
}
"tool_result" => {
// tool_result 变成单独的 tool role 消息
let tool_use_id = block
.get("tool_use_id")
.and_then(|i| i.as_str())
.unwrap_or("");
let content_val = block.get("content");
let content_str = match content_val {
Some(Value::String(s)) => s.clone(),
Some(v) => serde_json::to_string(v).unwrap_or_default(),
None => String::new(),
};
result.push(json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": content_str
}));
}
"thinking" => {
// 跳过 thinking blocks
}
_ => {}
}
}
// 添加带内容和/或工具调用的消息
if !content_parts.is_empty() || !tool_calls.is_empty() {
let mut msg = json!({"role": role});
// 内容处理
if content_parts.is_empty() {
msg["content"] = Value::Null;
} else if content_parts.len() == 1 {
if let Some(text) = content_parts[0].get("text") {
msg["content"] = text.clone();
} else {
msg["content"] = json!(content_parts);
}
} else {
msg["content"] = json!(content_parts);
}
// 工具调用
if !tool_calls.is_empty() {
msg["tool_calls"] = json!(tool_calls);
}
result.push(msg);
}
return Ok(result);
}
// 其他情况直接透传
result.push(json!({"role": role, "content": content}));
Ok(result)
}
/// 清理 JSON schema(移除不支持的 format
fn clean_schema(mut schema: Value) -> Value {
if let Some(obj) = schema.as_object_mut() {
// 移除 "format": "uri"
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
obj.remove("format");
}
// 递归清理嵌套 schema
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
for (_, value) in properties.iter_mut() {
*value = clean_schema(value.clone());
}
}
if let Some(items) = obj.get_mut("items") {
*items = clean_schema(items.clone());
}
}
schema
}
/// OpenAI 响应 → Anthropic 响应
pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
let choices = body
.get("choices")
.and_then(|c| c.as_array())
.ok_or_else(|| ProxyError::TransformError("No choices in response".to_string()))?;
let choice = choices
.first()
.ok_or_else(|| ProxyError::TransformError("Empty choices array".to_string()))?;
let message = choice
.get("message")
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
let mut content = Vec::new();
// 文本内容
if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
if !text.is_empty() {
content.push(json!({"type": "text", "text": text}));
}
}
// 工具调用
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
for tc in tool_calls {
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
let empty_obj = json!({});
let func = tc.get("function").unwrap_or(&empty_obj);
let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("");
let args_str = func
.get("arguments")
.and_then(|a| a.as_str())
.unwrap_or("{}");
let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
content.push(json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input
}));
}
}
// 映射 finish_reason → stop_reason
let stop_reason = choice
.get("finish_reason")
.and_then(|r| r.as_str())
.map(|r| match r {
"stop" => "end_turn",
"length" => "max_tokens",
"tool_calls" => "tool_use",
other => other,
});
// usage
let usage = body.get("usage").cloned().unwrap_or(json!({}));
let input_tokens = usage
.get("prompt_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let output_tokens = usage
.get("completion_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let result = json!({
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
"type": "message",
"role": "assistant",
"content": content,
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
"stop_reason": stop_reason,
"stop_sequence": null,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens
}
});
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_provider(env_config: Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Provider".to_string(),
settings_config: json!({"env": env_config}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
fn create_openrouter_provider() -> Provider {
create_provider(json!({
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"ANTHROPIC_MODEL": "anthropic/claude-sonnet-4.5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4.5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-4.5",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-4.5"
}))
}
#[test]
fn test_anthropic_to_openai_simple() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
// opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL
assert_eq!(result["model"], "anthropic/claude-opus-4.5");
assert_eq!(result["max_tokens"], 1024);
assert_eq!(result["messages"][0]["role"], "user");
assert_eq!(result["messages"][0]["content"], "Hello");
}
#[test]
fn test_anthropic_to_openai_with_system() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": "You are a helpful assistant.",
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
"You are a helpful assistant."
);
assert_eq!(result["messages"][1]["role"], "user");
}
#[test]
fn test_anthropic_to_openai_with_tools() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "What's the weather?"}],
"tools": [{
"name": "get_weather",
"description": "Get weather info",
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
}
#[test]
fn test_anthropic_to_openai_tool_use() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{
"role": "assistant",
"content": [
{"type": "text", "text": "Let me check"},
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
]
}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "assistant");
assert!(msg.get("tool_calls").is_some());
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
}
#[test]
fn test_anthropic_to_openai_tool_result() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_123", "content": "Sunny, 25°C"}
]
}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "tool");
assert_eq!(msg["tool_call_id"], "call_123");
assert_eq!(msg["content"], "Sunny, 25°C");
}
#[test]
fn test_openai_to_anthropic_simple() {
let input = json!({
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["id"], "chatcmpl-123");
assert_eq!(result["type"], "message");
assert_eq!(result["content"][0]["type"], "text");
assert_eq!(result["content"][0]["text"], "Hello!");
assert_eq!(result["stop_reason"], "end_turn");
assert_eq!(result["usage"]["input_tokens"], 10);
assert_eq!(result["usage"]["output_tokens"], 5);
}
#[test]
fn test_openai_to_anthropic_with_tool_calls() {
let input = json!({
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_123",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"location\": \"Tokyo\"}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["content"][0]["type"], "tool_use");
assert_eq!(result["content"][0]["id"], "call_123");
assert_eq!(result["content"][0]["name"], "get_weather");
assert_eq!(result["content"][0]["input"]["location"], "Tokyo");
assert_eq!(result["stop_reason"], "tool_use");
}
#[test]
fn test_model_mapping_from_provider() {
let provider = create_openrouter_provider();
let body = json!({"model": "test"});
// sonnet 模型
assert_eq!(
get_model_from_provider("claude-sonnet-4-5-20250929", &provider, &body),
"anthropic/claude-sonnet-4.5"
);
// haiku 模型
assert_eq!(
get_model_from_provider("claude-haiku-4-5-20250929", &provider, &body),
"anthropic/claude-haiku-4.5"
);
// opus 模型
assert_eq!(
get_model_from_provider("claude-opus-4-5", &provider, &body),
"anthropic/claude-opus-4.5"
);
}
#[test]
fn test_anthropic_to_openai_model_mapping() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
}
#[test]
fn test_thinking_parameter_detection() {
let mut provider = create_openrouter_provider();
// 添加推理模型配置
if let Some(env) = provider.settings_config.get_mut("env") {
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
}
let input = json!({
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"thinking": {"type": "enabled"},
"messages": [{"role": "user", "content": "Solve this problem"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
// 应该使用推理模型
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5:extended");
}
#[test]
fn test_thinking_parameter_disabled() {
let mut provider = create_openrouter_provider();
if let Some(env) = provider.settings_config.get_mut("env") {
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
}
let input = json!({
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"thinking": {"type": "disabled"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
// 应该使用普通模型
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
}
}
+23 -209
View File
@@ -13,8 +13,10 @@ use axum::response::{IntoResponse, Response};
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use reqwest::header::HeaderMap;
use rust_decimal::Decimal;
use serde_json::Value;
use std::{
str::FromStr,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
@@ -126,15 +128,7 @@ pub async fn handle_non_streaming(
ctx.request_model.clone()
};
spawn_log_usage(
state,
ctx,
usage,
&model,
&ctx.request_model,
status.as_u16(),
false,
);
spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false);
} else {
let model = json_value
.get("model")
@@ -146,7 +140,6 @@ pub async fn handle_non_streaming(
ctx,
TokenUsage::default(),
&model,
&ctx.request_model,
status.as_u16(),
false,
);
@@ -166,7 +159,6 @@ pub async fn handle_non_streaming(
ctx,
TokenUsage::default(),
&ctx.request_model,
&ctx.request_model,
status.as_u16(),
false,
);
@@ -301,7 +293,6 @@ fn create_usage_collector(
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
let request_model = request_model.clone();
tokio::spawn(async move {
log_usage_internal(
@@ -309,7 +300,6 @@ fn create_usage_collector(
&provider_id,
app_type_str,
&model,
&request_model,
usage,
latency_ms,
first_token_ms,
@@ -325,7 +315,6 @@ fn create_usage_collector(
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
let request_model = request_model.clone();
tokio::spawn(async move {
log_usage_internal(
@@ -333,7 +322,6 @@ fn create_usage_collector(
&provider_id,
app_type_str,
&model,
&request_model,
TokenUsage::default(),
latency_ms,
first_token_ms,
@@ -354,7 +342,6 @@ fn spawn_log_usage(
ctx: &RequestContext,
usage: TokenUsage,
model: &str,
request_model: &str,
status_code: u16,
is_streaming: bool,
) {
@@ -362,7 +349,6 @@ fn spawn_log_usage(
let provider_id = ctx.provider.id.clone();
let app_type_str = ctx.app_type_str.to_string();
let model = model.to_string();
let request_model = request_model.to_string();
let latency_ms = ctx.latency_ms();
let session_id = ctx.session_id.clone();
@@ -372,7 +358,6 @@ fn spawn_log_usage(
&provider_id,
&app_type_str,
&model,
&request_model,
usage,
latency_ms,
None,
@@ -391,7 +376,6 @@ async fn log_usage_internal(
provider_id: &str,
app_type: &str,
model: &str,
request_model: &str,
usage: TokenUsage,
latency_ms: u64,
first_token_ms: Option<u64>,
@@ -402,12 +386,26 @@ async fn log_usage_internal(
use super::usage::logger::UsageLogger;
let logger = UsageLogger::new(&state.db);
let (multiplier, pricing_model_source) =
logger.resolve_pricing_config(provider_id, app_type).await;
let pricing_model = if pricing_model_source == "request" {
request_model
} else {
model
// 获取 provider 的 cost_multiplier
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
Ok(Some(p)) => {
if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier {
Decimal::from_str(&cm).unwrap_or_else(|e| {
log::warn!(
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
);
Decimal::from(1)
})
} else {
Decimal::from(1)
}
} else {
Decimal::from(1)
}
}
_ => Decimal::from(1),
};
let request_id = uuid::Uuid::new_v4().to_string();
@@ -426,8 +424,6 @@ async fn log_usage_internal(
provider_id.to_string(),
app_type.to_string(),
model.to_string(),
request_model.to_string(),
pricing_model.to_string(),
usage,
multiplier,
latency_ms,
@@ -560,185 +556,3 @@ fn format_headers(headers: &HeaderMap) -> String {
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
use crate::error::AppError;
use crate::provider::ProviderMeta;
use crate::proxy::failover_switch::FailoverSwitchManager;
use crate::proxy::provider_router::ProviderRouter;
use crate::proxy::types::{ProxyConfig, ProxyStatus};
use rust_decimal::Decimal;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::RwLock;
fn build_state(db: Arc<Database>) -> ProxyState {
ProxyState {
db: db.clone(),
config: Arc::new(RwLock::new(ProxyConfig::default())),
status: Arc::new(RwLock::new(ProxyStatus::default())),
start_time: Arc::new(RwLock::new(None)),
current_providers: Arc::new(RwLock::new(HashMap::new())),
provider_router: Arc::new(ProviderRouter::new(db.clone())),
app_handle: None,
failover_manager: Arc::new(FailoverSwitchManager::new(db)),
}
}
fn seed_pricing(db: &Database) -> Result<(), AppError> {
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["resp-model", "Resp Model", "1.0", "0"],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["req-model", "Req Model", "2.0", "0"],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
fn insert_provider(
db: &Database,
id: &str,
app_type: &str,
meta: ProviderMeta,
) -> Result<(), AppError> {
let meta_json =
serde_json::to_string(&meta).map_err(|e| AppError::Database(e.to_string()))?;
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT INTO providers (id, app_type, name, settings_config, meta)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![id, app_type, "Test Provider", "{}", meta_json],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
#[tokio::test]
async fn test_log_usage_uses_provider_override_config() -> Result<(), AppError> {
let db = Arc::new(Database::memory()?);
let app_type = "claude";
db.set_default_cost_multiplier(app_type, "1.5").await?;
db.set_pricing_model_source(app_type, "response").await?;
seed_pricing(&db)?;
let mut meta = ProviderMeta::default();
meta.cost_multiplier = Some("2".to_string());
meta.pricing_model_source = Some("request".to_string());
insert_provider(&db, "provider-1", app_type, meta)?;
let state = build_state(db.clone());
let usage = TokenUsage {
input_tokens: 1_000_000,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
};
log_usage_internal(
&state,
"provider-1",
app_type,
"resp-model",
"req-model",
usage,
10,
None,
false,
200,
None,
)
.await;
let conn = crate::database::lock_conn!(db.conn);
let (model, request_model, total_cost, cost_multiplier): (String, String, String, String) =
conn.query_row(
"SELECT model, request_model, total_cost_usd, cost_multiplier
FROM proxy_request_logs WHERE provider_id = ?1",
["provider-1"],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.map_err(|e| AppError::Database(e.to_string()))?;
assert_eq!(model, "resp-model");
assert_eq!(request_model, "req-model");
assert_eq!(
Decimal::from_str(&cost_multiplier).unwrap(),
Decimal::from_str("2").unwrap()
);
assert_eq!(
Decimal::from_str(&total_cost).unwrap(),
Decimal::from_str("4").unwrap()
);
Ok(())
}
#[tokio::test]
async fn test_log_usage_falls_back_to_global_defaults() -> Result<(), AppError> {
let db = Arc::new(Database::memory()?);
let app_type = "claude";
db.set_default_cost_multiplier(app_type, "1.5").await?;
db.set_pricing_model_source(app_type, "response").await?;
seed_pricing(&db)?;
let meta = ProviderMeta::default();
insert_provider(&db, "provider-2", app_type, meta)?;
let state = build_state(db.clone());
let usage = TokenUsage {
input_tokens: 1_000_000,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
};
log_usage_internal(
&state,
"provider-2",
app_type,
"resp-model",
"req-model",
usage,
10,
None,
false,
200,
None,
)
.await;
let conn = crate::database::lock_conn!(db.conn);
let (total_cost, cost_multiplier): (String, String) = conn
.query_row(
"SELECT total_cost_usd, cost_multiplier
FROM proxy_request_logs WHERE provider_id = ?1",
["provider-2"],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(|e| AppError::Database(e.to_string()))?;
assert_eq!(
Decimal::from_str(&cost_multiplier).unwrap(),
Decimal::from_str("1.5").unwrap()
);
assert_eq!(
Decimal::from_str(&total_cost).unwrap(),
Decimal::from_str("1.5").unwrap()
);
Ok(())
}
}
@@ -0,0 +1,10 @@
//! Anthropic ↔ OpenAI 格式转换模块
//!
//! 提供 Anthropic Messages API 和 OpenAI Chat Completions API 之间的双向转换
mod request;
mod response;
pub mod streaming;
pub use request::AnthropicToOpenAITransformer;
pub use response::OpenAIToAnthropicTransformer;
@@ -0,0 +1,397 @@
//! Anthropic → OpenAI 请求转换器
//!
//! 将 Anthropic Messages API 请求转换为 OpenAI Chat Completions API 格式
use crate::proxy::error::ProxyError;
use crate::proxy::transform::{format::ApiFormat, traits::FormatTransformer};
use bytes::Bytes;
use futures::stream::Stream;
use serde_json::{json, Value};
use std::pin::Pin;
/// Anthropic → OpenAI 请求转换器
pub struct AnthropicToOpenAITransformer;
impl AnthropicToOpenAITransformer {
pub fn new() -> Self {
Self
}
}
impl Default for AnthropicToOpenAITransformer {
fn default() -> Self {
Self::new()
}
}
impl FormatTransformer for AnthropicToOpenAITransformer {
fn name(&self) -> &'static str {
"Anthropic→OpenAI"
}
fn source_format(&self) -> ApiFormat {
ApiFormat::Anthropic
}
fn target_format(&self) -> ApiFormat {
ApiFormat::OpenAI
}
fn transform_request(&self, body: Value) -> Result<Value, ProxyError> {
anthropic_to_openai(body)
}
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
// 请求转换器不处理响应,直接透传
Ok(body)
}
fn transform_stream(
&self,
_stream: Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send>>,
) -> Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> {
// 请求转换器不处理流
Box::pin(futures::stream::empty())
}
fn transform_endpoint(&self, endpoint: &str) -> String {
// /v1/messages → /v1/chat/completions
if endpoint == "/v1/messages" {
"/v1/chat/completions".to_string()
} else {
endpoint.to_string()
}
}
}
/// Anthropic 请求 → OpenAI 请求
fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
let mut result = json!({});
// 模型直接透传(模型映射由 model_mapper 模块独立处理)
if let Some(model) = body.get("model") {
result["model"] = model.clone();
}
let mut messages = Vec::new();
// 处理 system prompt
if let Some(system) = body.get("system") {
if let Some(text) = system.as_str() {
// 单个字符串
messages.push(json!({"role": "system", "content": text}));
} else if let Some(arr) = system.as_array() {
// 多个 system message
for msg in arr {
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
messages.push(json!({"role": "system", "content": text}));
}
}
}
}
// 转换 messages
if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) {
for msg in msgs {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
let content = msg.get("content");
let converted = convert_message_to_openai(role, content)?;
messages.extend(converted);
}
}
result["messages"] = json!(messages);
// 转换参数
if let Some(v) = body.get("max_tokens") {
result["max_tokens"] = v.clone();
}
if let Some(v) = body.get("temperature") {
result["temperature"] = v.clone();
}
if let Some(v) = body.get("top_p") {
result["top_p"] = v.clone();
}
if let Some(v) = body.get("stop_sequences") {
result["stop"] = v.clone();
}
if let Some(v) = body.get("stream") {
result["stream"] = v.clone();
}
// 转换 tools (过滤 BatchTool)
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
let openai_tools: Vec<Value> = tools
.iter()
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
.map(|t| {
json!({
"type": "function",
"function": {
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
"description": t.get("description"),
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
}
})
})
.collect();
if !openai_tools.is_empty() {
result["tools"] = json!(openai_tools);
}
}
if let Some(v) = body.get("tool_choice") {
result["tool_choice"] = v.clone();
}
Ok(result)
}
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
fn convert_message_to_openai(
role: &str,
content: Option<&Value>,
) -> Result<Vec<Value>, ProxyError> {
let mut result = Vec::new();
let content = match content {
Some(c) => c,
None => {
result.push(json!({"role": role, "content": null}));
return Ok(result);
}
};
// 字符串内容
if let Some(text) = content.as_str() {
result.push(json!({"role": role, "content": text}));
return Ok(result);
}
// 数组内容(多模态/工具调用)
if let Some(blocks) = content.as_array() {
let mut content_parts = Vec::new();
let mut tool_calls = Vec::new();
for block in blocks {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
content_parts.push(json!({"type": "text", "text": text}));
}
}
"image" => {
if let Some(source) = block.get("source") {
let media_type = source
.get("media_type")
.and_then(|m| m.as_str())
.unwrap_or("image/png");
let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
content_parts.push(json!({
"type": "image_url",
"image_url": {"url": format!("data:{};base64,{}", media_type, data)}
}));
}
}
"tool_use" => {
let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
let input = block.get("input").cloned().unwrap_or(json!({}));
tool_calls.push(json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": serde_json::to_string(&input).unwrap_or_default()
}
}));
}
"tool_result" => {
// tool_result 变成单独的 tool role 消息
let tool_use_id = block
.get("tool_use_id")
.and_then(|i| i.as_str())
.unwrap_or("");
let content_val = block.get("content");
let content_str = match content_val {
Some(Value::String(s)) => s.clone(),
Some(v) => serde_json::to_string(v).unwrap_or_default(),
None => String::new(),
};
result.push(json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": content_str
}));
}
"thinking" => {
// 跳过 thinking blocks
}
_ => {}
}
}
// 添加带内容和/或工具调用的消息
if !content_parts.is_empty() || !tool_calls.is_empty() {
let mut msg = json!({"role": role});
// 内容处理
if content_parts.is_empty() {
msg["content"] = Value::Null;
} else if content_parts.len() == 1 {
if let Some(text) = content_parts[0].get("text") {
msg["content"] = text.clone();
} else {
msg["content"] = json!(content_parts);
}
} else {
msg["content"] = json!(content_parts);
}
// 工具调用
if !tool_calls.is_empty() {
msg["tool_calls"] = json!(tool_calls);
}
result.push(msg);
}
return Ok(result);
}
// 其他情况直接透传
result.push(json!({"role": role, "content": content}));
Ok(result)
}
/// 清理 JSON schema(移除不支持的 format
fn clean_schema(mut schema: Value) -> Value {
if let Some(obj) = schema.as_object_mut() {
// 移除 "format": "uri"
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
obj.remove("format");
}
// 递归清理嵌套 schema
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
for (_, value) in properties.iter_mut() {
*value = clean_schema(value.clone());
}
}
if let Some(items) = obj.get_mut("items") {
*items = clean_schema(items.clone());
}
}
schema
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_anthropic_to_openai_simple() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["model"], "claude-3-opus");
assert_eq!(result["max_tokens"], 1024);
assert_eq!(result["messages"][0]["role"], "user");
assert_eq!(result["messages"][0]["content"], "Hello");
}
#[test]
fn test_anthropic_to_openai_with_system() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": "You are a helpful assistant.",
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
"You are a helpful assistant."
);
assert_eq!(result["messages"][1]["role"], "user");
}
#[test]
fn test_anthropic_to_openai_with_tools() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "What's the weather?"}],
"tools": [{
"name": "get_weather",
"description": "Get weather info",
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
}]
});
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
}
#[test]
fn test_anthropic_to_openai_tool_use() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{
"role": "assistant",
"content": [
{"type": "text", "text": "Let me check"},
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
]
}]
});
let result = anthropic_to_openai(input).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "assistant");
assert!(msg.get("tool_calls").is_some());
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
}
#[test]
fn test_anthropic_to_openai_tool_result() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_123", "content": "Sunny, 25°C"}
]
}]
});
let result = anthropic_to_openai(input).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "tool");
assert_eq!(msg["tool_call_id"], "call_123");
assert_eq!(msg["content"], "Sunny, 25°C");
}
#[test]
fn test_transform_endpoint() {
let transformer = AnthropicToOpenAITransformer::new();
assert_eq!(
transformer.transform_endpoint("/v1/messages"),
"/v1/chat/completions"
);
assert_eq!(transformer.transform_endpoint("/v1/other"), "/v1/other");
}
}
@@ -0,0 +1,237 @@
//! OpenAI → Anthropic 响应转换器
//!
//! 将 OpenAI Chat Completions API 响应转换为 Anthropic Messages API 格式
use crate::proxy::error::ProxyError;
use crate::proxy::transform::{format::ApiFormat, traits::FormatTransformer};
use bytes::Bytes;
use futures::stream::Stream;
use serde_json::{json, Value};
use std::pin::Pin;
use super::streaming::create_anthropic_sse_stream;
/// OpenAI → Anthropic 响应转换器
pub struct OpenAIToAnthropicTransformer;
impl OpenAIToAnthropicTransformer {
pub fn new() -> Self {
Self
}
}
impl Default for OpenAIToAnthropicTransformer {
fn default() -> Self {
Self::new()
}
}
impl FormatTransformer for OpenAIToAnthropicTransformer {
fn name(&self) -> &'static str {
"OpenAI→Anthropic"
}
fn source_format(&self) -> ApiFormat {
ApiFormat::OpenAI
}
fn target_format(&self) -> ApiFormat {
ApiFormat::Anthropic
}
fn transform_request(&self, body: Value) -> Result<Value, ProxyError> {
// 响应转换器不处理请求,直接透传
Ok(body)
}
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
openai_to_anthropic(body)
}
fn transform_stream(
&self,
stream: Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send>>,
) -> Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> {
Box::pin(create_anthropic_sse_stream(stream))
}
}
/// OpenAI 响应 → Anthropic 响应
fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
let choices = body
.get("choices")
.and_then(|c| c.as_array())
.ok_or_else(|| ProxyError::TransformError("No choices in response".to_string()))?;
let choice = choices
.first()
.ok_or_else(|| ProxyError::TransformError("Empty choices array".to_string()))?;
let message = choice
.get("message")
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
let mut content = Vec::new();
// 文本内容
if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
if !text.is_empty() {
content.push(json!({"type": "text", "text": text}));
}
}
// 工具调用
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
for tc in tool_calls {
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
let empty_obj = json!({});
let func = tc.get("function").unwrap_or(&empty_obj);
let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("");
let args_str = func
.get("arguments")
.and_then(|a| a.as_str())
.unwrap_or("{}");
// 解析 arguments JSON,失败时返回错误而不是静默使用空对象
let input: Value = serde_json::from_str(args_str).map_err(|e| {
log::error!("[Transform] tool_calls.arguments 解析失败: {e}, 原始内容: {args_str}");
ProxyError::TransformError(format!(
"Failed to parse tool_calls.arguments: {e}, content: {args_str}"
))
})?;
content.push(json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input
}));
}
}
// 映射 finish_reason → stop_reason
let stop_reason = choice
.get("finish_reason")
.and_then(|r| r.as_str())
.map(|r| match r {
"stop" => "end_turn",
"length" => "max_tokens",
"tool_calls" => "tool_use",
other => other,
});
// usage
let usage = body.get("usage").cloned().unwrap_or(json!({}));
let input_tokens = usage
.get("prompt_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let output_tokens = usage
.get("completion_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let result = json!({
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
"type": "message",
"role": "assistant",
"content": content,
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
"stop_reason": stop_reason,
"stop_sequence": null,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens
}
});
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_openai_to_anthropic_simple() {
let input = json!({
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["id"], "chatcmpl-123");
assert_eq!(result["type"], "message");
assert_eq!(result["content"][0]["type"], "text");
assert_eq!(result["content"][0]["text"], "Hello!");
assert_eq!(result["stop_reason"], "end_turn");
assert_eq!(result["usage"]["input_tokens"], 10);
assert_eq!(result["usage"]["output_tokens"], 5);
}
#[test]
fn test_openai_to_anthropic_with_tool_calls() {
let input = json!({
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_123",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"location\": \"Tokyo\"}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["content"][0]["type"], "tool_use");
assert_eq!(result["content"][0]["id"], "call_123");
assert_eq!(result["content"][0]["name"], "get_weather");
assert_eq!(result["content"][0]["input"]["location"], "Tokyo");
assert_eq!(result["stop_reason"], "tool_use");
}
#[test]
fn test_stop_reason_mapping() {
// stop → end_turn
let input = json!({
"choices": [{"message": {"content": "Hi"}, "finish_reason": "stop"}],
"usage": {}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["stop_reason"], "end_turn");
// length → max_tokens
let input = json!({
"choices": [{"message": {"content": "Hi"}, "finish_reason": "length"}],
"usage": {}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["stop_reason"], "max_tokens");
// tool_calls → tool_use
let input = json!({
"choices": [{"message": {"content": null, "tool_calls": []}, "finish_reason": "tool_calls"}],
"usage": {}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["stop_reason"], "tool_use");
}
}
@@ -6,6 +6,7 @@ use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
/// OpenAI 流式响应数据结构
#[derive(Debug, Deserialize)]
@@ -73,7 +74,8 @@ pub fn create_anthropic_sse_stream(
let mut content_index = 0;
let mut has_sent_message_start = false;
let mut current_block_type: Option<String> = None;
let mut tool_call_id = None;
// 使用 HashMap 按 index 管理多个工具调用的 ID 和 content_index
let mut tool_calls_map: HashMap<usize, (String, usize)> = HashMap::new();
tokio::pin!(stream);
@@ -94,17 +96,17 @@ pub fn create_anthropic_sse_stream(
for l in line.lines() {
if let Some(data) = l.strip_prefix("data: ") {
if data.trim() == "[DONE]" {
log::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
log::debug!("[Transform] <<< OpenAI SSE: [DONE]");
let event = json!({"type": "message_stop"});
let sse_data = format!("event: message_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
log::debug!("[Transform] >>> Anthropic SSE: message_stop");
yield Ok(Bytes::from(sse_data));
continue;
}
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
log::debug!("[Transform] <<< SSE chunk received");
if message_id.is_none() {
message_id = Some(chunk.id.clone());
@@ -210,7 +212,11 @@ pub fn create_anthropic_sse_stream(
// 处理工具调用
if let Some(tool_calls) = &choice.delta.tool_calls {
for tool_call in tool_calls {
let tc_index = tool_call.index;
// 检查是否是新的工具调用(有 id 表示开始新的工具调用)
if let Some(id) = &tool_call.id {
// 关闭当前的 content block(如果有)
if current_block_type.is_some() {
let event = json!({
"type": "content_block_stop",
@@ -222,30 +228,44 @@ pub fn create_anthropic_sse_stream(
content_index += 1;
}
tool_call_id = Some(id.clone());
// 记录这个工具调用的 ID 和对应的 content_index
tool_calls_map.insert(tc_index, (id.clone(), content_index));
current_block_type = Some("tool_use".to_string());
}
// 获取当前工具调用的信息
let (tool_id, tool_content_index) = tool_calls_map
.get(&tc_index)
.cloned()
.unwrap_or_else(|| {
log::warn!(
"[Transform] 收到未知 index 的工具调用 delta: {tc_index}"
);
(String::new(), content_index)
});
if let Some(function) = &tool_call.function {
// 如果有 name,发送 content_block_start
if let Some(name) = &function.name {
let event = json!({
"type": "content_block_start",
"index": content_index,
"index": tool_content_index,
"content_block": {
"type": "tool_use",
"id": tool_call_id.clone().unwrap_or_default(),
"id": tool_id,
"name": name
}
});
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
current_block_type = Some("tool_use".to_string());
}
// 如果有 arguments,发送 content_block_delta
if let Some(args) = &function.arguments {
let event = json!({
"type": "content_block_delta",
"index": content_index,
"index": tool_content_index,
"delta": {
"type": "input_json_delta",
"partial_json": args
+247
View File
@@ -0,0 +1,247 @@
//! 格式转换配置
//!
//! 从 Provider 配置中提取格式转换设置
use super::format::ApiFormat;
use crate::provider::Provider;
use serde::{Deserialize, Serialize};
/// 格式转换配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransformConfig {
/// 是否启用格式转换
pub enabled: bool,
/// 源格式(客户端发送的格式)
pub source_format: ApiFormat,
/// 目标格式(上游服务期望的格式)
pub target_format: ApiFormat,
/// 是否转换流式响应
pub transform_streaming: bool,
}
impl Default for TransformConfig {
fn default() -> Self {
Self {
enabled: false,
source_format: ApiFormat::Anthropic,
target_format: ApiFormat::OpenAI,
transform_streaming: true,
}
}
}
impl TransformConfig {
/// 从 Provider 配置中提取转换配置
///
/// 优先级:
/// 1. ProviderMeta.format_transform(新配置格式,通过前端 UI 设置)
/// 2. settings_config.format_transform(兼容旧配置)
/// 3. settings_config.openrouter_compat_mode(兼容旧配置)
///
/// 注意:如果格式解析失败,将禁用转换并记录警告,而不是静默回退到默认值
pub fn from_provider(provider: &Provider) -> Self {
// 1. 优先从 ProviderMeta 读取(前端 UI 设置的配置)
if let Some(meta) = &provider.meta {
if let Some(ft) = &meta.format_transform {
if ft.enabled {
let source_str = ft.source_format.as_deref();
let target_str = ft.target_format.as_deref();
let source_format = source_str.and_then(ApiFormat::from_str);
let target_format = target_str.and_then(ApiFormat::from_str);
// 如果格式解析失败,禁用转换并记录警告
if source_str.is_some() && source_format.is_none() {
log::warn!(
"[TransformConfig] 无法解析 source_format: {source_str:?},禁用格式转换"
);
return Self::default();
}
if target_str.is_some() && target_format.is_none() {
log::warn!(
"[TransformConfig] 无法解析 target_format: {target_str:?},禁用格式转换"
);
return Self::default();
}
let transform_streaming = ft.transform_streaming.unwrap_or(true);
return Self {
enabled: true,
source_format: source_format.unwrap_or(ApiFormat::Anthropic),
target_format: target_format.unwrap_or(ApiFormat::OpenAI),
transform_streaming,
};
}
}
}
let settings = &provider.settings_config;
// 2. 检查是否显式启用格式转换(settings_config 中的配置)
let format_transform = settings.get("format_transform").and_then(|v| v.as_object());
if let Some(config) = format_transform {
let enabled = config
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if enabled {
let source_str = config.get("source_format").and_then(|v| v.as_str());
let target_str = config.get("target_format").and_then(|v| v.as_str());
let source_format = source_str.and_then(ApiFormat::from_str);
let target_format = target_str.and_then(ApiFormat::from_str);
// 如果格式解析失败,禁用转换并记录警告
if source_str.is_some() && source_format.is_none() {
log::warn!(
"[TransformConfig] 无法解析 source_format: {source_str:?},禁用格式转换"
);
return Self::default();
}
if target_str.is_some() && target_format.is_none() {
log::warn!(
"[TransformConfig] 无法解析 target_format: {target_str:?},禁用格式转换"
);
return Self::default();
}
let transform_streaming = config
.get("transform_streaming")
.and_then(|v| v.as_bool())
.unwrap_or(true);
return Self {
enabled,
source_format: source_format.unwrap_or(ApiFormat::Anthropic),
target_format: target_format.unwrap_or(ApiFormat::OpenAI),
transform_streaming,
};
}
}
// 3. 兼容旧配置:检查 openrouter_compat_mode
let legacy_enabled = settings
.get("openrouter_compat_mode")
.and_then(|v| match v {
serde_json::Value::Bool(b) => Some(*b),
serde_json::Value::Number(n) => Some(n.as_i64().unwrap_or(0) != 0),
serde_json::Value::String(s) => {
let normalized = s.trim().to_lowercase();
Some(normalized == "true" || normalized == "1")
}
_ => None,
})
.unwrap_or(false);
if legacy_enabled {
return Self {
enabled: true,
source_format: ApiFormat::Anthropic,
target_format: ApiFormat::OpenAI,
transform_streaming: true,
};
}
Self::default()
}
/// 检查是否需要转换
pub fn needs_transform(&self) -> bool {
self.enabled && self.source_format != self.target_format
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(settings: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Provider".to_string(),
settings_config: settings,
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_default_config() {
let provider = create_provider(json!({}));
let config = TransformConfig::from_provider(&provider);
assert!(!config.enabled);
assert!(!config.needs_transform());
}
#[test]
fn test_new_format_config() {
let provider = create_provider(json!({
"format_transform": {
"enabled": true,
"source_format": "anthropic",
"target_format": "openai",
"transform_streaming": true
}
}));
let config = TransformConfig::from_provider(&provider);
assert!(config.enabled);
assert_eq!(config.source_format, ApiFormat::Anthropic);
assert_eq!(config.target_format, ApiFormat::OpenAI);
assert!(config.transform_streaming);
assert!(config.needs_transform());
}
#[test]
fn test_legacy_openrouter_compat_mode_bool() {
let provider = create_provider(json!({
"openrouter_compat_mode": true
}));
let config = TransformConfig::from_provider(&provider);
assert!(config.enabled);
assert_eq!(config.source_format, ApiFormat::Anthropic);
assert_eq!(config.target_format, ApiFormat::OpenAI);
}
#[test]
fn test_legacy_openrouter_compat_mode_string() {
let provider = create_provider(json!({
"openrouter_compat_mode": "true"
}));
let config = TransformConfig::from_provider(&provider);
assert!(config.enabled);
}
#[test]
fn test_legacy_openrouter_compat_mode_number() {
let provider = create_provider(json!({
"openrouter_compat_mode": 1
}));
let config = TransformConfig::from_provider(&provider);
assert!(config.enabled);
}
#[test]
fn test_same_format_no_transform() {
let provider = create_provider(json!({
"format_transform": {
"enabled": true,
"source_format": "anthropic",
"target_format": "anthropic"
}
}));
let config = TransformConfig::from_provider(&provider);
assert!(config.enabled);
assert!(!config.needs_transform()); // 相同格式不需要转换
}
}
+67
View File
@@ -0,0 +1,67 @@
//! API 格式枚举定义
//!
//! 定义支持的 API 格式类型,用于格式转换配置
use serde::{Deserialize, Serialize};
/// API 格式枚举
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApiFormat {
/// Anthropic Messages API
Anthropic,
/// OpenAI Chat Completions API
OpenAI,
/// Google Gemini API (预留)
Gemini,
}
impl ApiFormat {
/// 从字符串解析
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"anthropic" | "claude" => Some(Self::Anthropic),
"openai" | "codex" => Some(Self::OpenAI),
"gemini" | "google" => Some(Self::Gemini),
_ => None,
}
}
/// 转换为字符串
pub fn as_str(&self) -> &'static str {
match self {
Self::Anthropic => "anthropic",
Self::OpenAI => "openai",
Self::Gemini => "gemini",
}
}
}
impl std::fmt::Display for ApiFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_str() {
assert_eq!(ApiFormat::from_str("anthropic"), Some(ApiFormat::Anthropic));
assert_eq!(ApiFormat::from_str("claude"), Some(ApiFormat::Anthropic));
assert_eq!(ApiFormat::from_str("openai"), Some(ApiFormat::OpenAI));
assert_eq!(ApiFormat::from_str("codex"), Some(ApiFormat::OpenAI));
assert_eq!(ApiFormat::from_str("gemini"), Some(ApiFormat::Gemini));
assert_eq!(ApiFormat::from_str("google"), Some(ApiFormat::Gemini));
assert_eq!(ApiFormat::from_str("unknown"), None);
}
#[test]
fn test_as_str() {
assert_eq!(ApiFormat::Anthropic.as_str(), "anthropic");
assert_eq!(ApiFormat::OpenAI.as_str(), "openai");
assert_eq!(ApiFormat::Gemini.as_str(), "gemini");
}
}
+36
View File
@@ -0,0 +1,36 @@
//! 通用格式转换模块
//!
//! 提供 API 格式之间的双向转换,支持:
//! - Anthropic ↔ OpenAI
//! - Gemini ↔ OpenAI(预留)
//!
//! ## 使用方式
//!
//! ```rust,ignore
//! use crate::proxy::transform::{config::TransformConfig, registry::get_transformer};
//!
//! let config = TransformConfig::from_provider(&provider);
//! if config.needs_transform() {
//! if let Some(transformer) = get_transformer(config.source_format, config.target_format) {
//! let transformed = transformer.transform_request(body)?;
//! }
//! }
//! ```
pub mod anthropic_openai;
pub mod config;
pub mod format;
pub mod registry;
pub mod traits;
// 公开导出
pub use config::TransformConfig;
pub use registry::get_transformer;
// 以下导出供外部模块使用(如需扩展转换器)
#[allow(unused_imports)]
pub use format::ApiFormat;
#[allow(unused_imports)]
pub use registry::TRANSFORMER_REGISTRY;
#[allow(unused_imports)]
pub use traits::{BidirectionalTransformer, FormatTransformer};
+95
View File
@@ -0,0 +1,95 @@
//! 转换器注册表
//!
//! 管理和获取格式转换器
use super::{format::ApiFormat, traits::FormatTransformer};
use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
/// 转换器注册表
pub struct TransformerRegistry {
transformers: HashMap<(ApiFormat, ApiFormat), Arc<dyn FormatTransformer>>,
}
impl TransformerRegistry {
/// 创建新的注册表
pub fn new() -> Self {
let mut registry = Self {
transformers: HashMap::new(),
};
registry.register_defaults();
registry
}
/// 注册默认转换器
fn register_defaults(&mut self) {
use super::anthropic_openai::{AnthropicToOpenAITransformer, OpenAIToAnthropicTransformer};
// Anthropic → OpenAI
self.register(Arc::new(AnthropicToOpenAITransformer::new()));
// OpenAI → Anthropic
self.register(Arc::new(OpenAIToAnthropicTransformer::new()));
}
/// 注册转换器
pub fn register(&mut self, transformer: Arc<dyn FormatTransformer>) {
let key = (transformer.source_format(), transformer.target_format());
self.transformers.insert(key, transformer);
}
/// 获取转换器
pub fn get(&self, source: ApiFormat, target: ApiFormat) -> Option<Arc<dyn FormatTransformer>> {
self.transformers.get(&(source, target)).cloned()
}
/// 检查是否支持指定的转换
#[cfg(test)]
pub fn supports(&self, source: ApiFormat, target: ApiFormat) -> bool {
self.transformers.contains_key(&(source, target))
}
}
impl Default for TransformerRegistry {
fn default() -> Self {
Self::new()
}
}
/// 全局转换器注册表
pub static TRANSFORMER_REGISTRY: LazyLock<TransformerRegistry> =
LazyLock::new(TransformerRegistry::new);
/// 获取转换器的便捷函数
pub fn get_transformer(source: ApiFormat, target: ApiFormat) -> Option<Arc<dyn FormatTransformer>> {
TRANSFORMER_REGISTRY.get(source, target)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_registry_has_default_transformers() {
let registry = TransformerRegistry::new();
// Anthropic → OpenAI
assert!(registry.supports(ApiFormat::Anthropic, ApiFormat::OpenAI));
// OpenAI → Anthropic
assert!(registry.supports(ApiFormat::OpenAI, ApiFormat::Anthropic));
// 不支持的转换
assert!(!registry.supports(ApiFormat::Gemini, ApiFormat::OpenAI));
}
#[test]
fn test_get_transformer() {
let transformer = get_transformer(ApiFormat::Anthropic, ApiFormat::OpenAI);
assert!(transformer.is_some());
let t = transformer.unwrap();
assert_eq!(t.source_format(), ApiFormat::Anthropic);
assert_eq!(t.target_format(), ApiFormat::OpenAI);
}
}
+47
View File
@@ -0,0 +1,47 @@
//! 格式转换器 Trait 定义
//!
//! 定义通用的格式转换器接口
use super::format::ApiFormat;
use crate::proxy::error::ProxyError;
use bytes::Bytes;
use futures::stream::Stream;
use serde_json::Value;
use std::pin::Pin;
/// 格式转换器 Trait
pub trait FormatTransformer: Send + Sync {
/// 转换器名称(用于日志)
#[allow(dead_code)]
fn name(&self) -> &'static str;
/// 源格式
fn source_format(&self) -> ApiFormat;
/// 目标格式
fn target_format(&self) -> ApiFormat;
/// 转换请求体
fn transform_request(&self, body: Value) -> Result<Value, ProxyError>;
/// 转换非流式响应体
fn transform_response(&self, body: Value) -> Result<Value, ProxyError>;
/// 转换流式响应
fn transform_stream(
&self,
stream: Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send>>,
) -> Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>;
/// 获取转换后的端点路径
fn transform_endpoint(&self, endpoint: &str) -> String {
endpoint.to_string()
}
}
/// 双向转换器 Trait(可选实现)
#[allow(dead_code)]
pub trait BidirectionalTransformer: FormatTransformer {
/// 获取反向转换器
fn reverse(&self) -> Box<dyn FormatTransformer>;
}
+13 -14
View File
@@ -40,7 +40,6 @@ impl CostCalculator {
/// - input_cost: (input_tokens - cache_read_tokens) × 输入价格
/// - cache_read_cost: cache_read_tokens × 缓存读取价格
/// - 这样避免缓存部分被重复计费
/// - total_cost: 各项成本之和 × 倍率(倍率只作用于最终总价)
pub fn calculate(
usage: &TokenUsage,
pricing: &ModelPricing,
@@ -51,20 +50,21 @@ impl CostCalculator {
// 计算实际需要按输入价格计费的 token 数(减去缓存命中部分)
let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens);
// 各项基础成本(不含倍率)
let input_cost =
Decimal::from(billable_input_tokens) * pricing.input_cost_per_million / million;
let output_cost =
Decimal::from(usage.output_tokens) * pricing.output_cost_per_million / million;
let input_cost = Decimal::from(billable_input_tokens) * pricing.input_cost_per_million
/ million
* cost_multiplier;
let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million
/ million
* cost_multiplier;
let cache_read_cost =
Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million;
Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million
* cost_multiplier;
let cache_creation_cost = Decimal::from(usage.cache_creation_tokens)
* pricing.cache_creation_cost_per_million
/ million;
/ million
* cost_multiplier;
// 总成本 = 各项基础成本之和 × 倍率
let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost;
let total_cost = base_total * cost_multiplier;
let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost;
CostBreakdown {
input_cost,
@@ -151,9 +151,8 @@ mod tests {
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
// input_cost: 基础价格(不含倍率)= 1000 * 3.0 / 1M = 0.003
assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap());
// total_cost: 基础价格 × 倍率 = 0.003 * 1.5 = 0.0045
// input: 1000 * 3.0 / 1M * 1.5 = 0.0045
assert_eq!(cost.input_cost, Decimal::from_str("0.0045").unwrap());
assert_eq!(cost.total_cost, Decimal::from_str("0.0045").unwrap());
}
+8 -102
View File
@@ -6,7 +6,7 @@ use crate::database::Database;
use crate::error::AppError;
use crate::services::usage_stats::find_model_pricing_row;
use rust_decimal::Decimal;
use std::{str::FromStr, time::SystemTime};
use std::time::SystemTime;
/// 请求日志
#[derive(Debug, Clone)]
@@ -15,7 +15,6 @@ pub struct RequestLog {
pub provider_id: String,
pub app_type: String,
pub model: String,
pub request_model: String,
pub usage: TokenUsage,
pub cost: Option<CostBreakdown>,
pub latency_ms: u64,
@@ -74,18 +73,17 @@ impl<'a> UsageLogger<'a> {
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
request_id, provider_id, app_type, model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
latency_ms, first_token_ms, status_code, error_message, session_id,
provider_type, is_streaming, cost_multiplier, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23)",
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)",
rusqlite::params![
log.request_id,
log.provider_id,
log.app_type,
log.model,
log.request_model,
log.usage.input_tokens,
log.usage.output_tokens,
log.usage.cache_read_tokens,
@@ -125,13 +123,11 @@ impl<'a> UsageLogger<'a> {
error_message: String,
latency_ms: u64,
) -> Result<(), AppError> {
let request_model = model.clone();
let log = RequestLog {
request_id,
provider_id,
app_type,
model,
request_model,
usage: TokenUsage::default(),
cost: None,
latency_ms,
@@ -164,13 +160,11 @@ impl<'a> UsageLogger<'a> {
session_id: Option<String>,
provider_type: Option<String>,
) -> Result<(), AppError> {
let request_model = model.clone();
let log = RequestLog {
request_id,
provider_id,
app_type,
model,
request_model,
usage: TokenUsage::default(),
cost: None,
latency_ms,
@@ -200,88 +194,6 @@ impl<'a> UsageLogger<'a> {
}
}
/// 获取有效的倍率与计费模式来源(供应商优先,未配置则回退全局默认)
pub async fn resolve_pricing_config(
&self,
provider_id: &str,
app_type: &str,
) -> (Decimal, String) {
let default_multiplier_raw = match self.db.get_default_cost_multiplier(app_type).await {
Ok(value) => value,
Err(e) => {
log::warn!("[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}");
"1".to_string()
}
};
let default_multiplier = match Decimal::from_str(&default_multiplier_raw) {
Ok(value) => value,
Err(e) => {
log::warn!(
"[USG-003] 默认倍率解析失败 (app_type={app_type}): {default_multiplier_raw} - {e}"
);
Decimal::from(1)
}
};
let default_pricing_source_raw = match self.db.get_pricing_model_source(app_type).await {
Ok(value) => value,
Err(e) => {
log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}");
"response".to_string()
}
};
let default_pricing_source =
if matches!(default_pricing_source_raw.as_str(), "response" | "request") {
default_pricing_source_raw
} else {
log::warn!(
"[USG-003] 默认计费模式无效 (app_type={app_type}): {default_pricing_source_raw}"
);
"response".to_string()
};
let provider = self
.db
.get_provider_by_id(provider_id, app_type)
.ok()
.flatten();
let (provider_multiplier, provider_pricing_source) = provider
.as_ref()
.and_then(|p| p.meta.as_ref())
.map(|meta| {
(
meta.cost_multiplier.as_deref(),
meta.pricing_model_source.as_deref(),
)
})
.unwrap_or((None, None));
let cost_multiplier = match provider_multiplier {
Some(value) => match Decimal::from_str(value) {
Ok(parsed) => parsed,
Err(e) => {
log::warn!(
"[USG-003] 供应商倍率解析失败 (provider_id={provider_id}): {value} - {e}"
);
default_multiplier
}
},
None => default_multiplier,
};
let pricing_model_source = match provider_pricing_source {
Some(value) if matches!(value, "response" | "request") => value.to_string(),
Some(value) => {
log::warn!("[USG-003] 供应商计费模式无效 (provider_id={provider_id}): {value}");
default_pricing_source.clone()
}
None => default_pricing_source.clone(),
};
(cost_multiplier, pricing_model_source)
}
/// 计算并记录请求
#[allow(clippy::too_many_arguments)]
pub fn log_with_calculation(
@@ -290,8 +202,6 @@ impl<'a> UsageLogger<'a> {
provider_id: String,
app_type: String,
model: String,
request_model: String,
pricing_model: String,
usage: TokenUsage,
cost_multiplier: Decimal,
latency_ms: u64,
@@ -301,10 +211,10 @@ impl<'a> UsageLogger<'a> {
provider_type: Option<String>,
is_streaming: bool,
) -> Result<(), AppError> {
let pricing = self.get_model_pricing(&pricing_model)?;
let pricing = self.get_model_pricing(&model)?;
if pricing.is_none() {
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0: {pricing_model}");
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0");
}
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
@@ -314,7 +224,6 @@ impl<'a> UsageLogger<'a> {
provider_id,
app_type,
model,
request_model,
usage,
cost,
latency_ms,
@@ -365,8 +274,6 @@ mod tests {
"provider-1".to_string(),
"claude".to_string(),
"test-model".to_string(),
"req-model".to_string(),
"test-model".to_string(),
usage,
Decimal::from(1),
100,
@@ -379,15 +286,14 @@ mod tests {
// 验证记录已插入
let conn = crate::database::lock_conn!(db.conn);
let (count, request_model): (i64, String) = conn
let count: i64 = conn
.query_row(
"SELECT COUNT(*), request_model FROM proxy_request_logs WHERE request_id = 'req-123'",
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = 'req-123'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 1);
assert_eq!(request_model, "req-model");
Ok(())
}
+13 -47
View File
@@ -182,67 +182,33 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
Ok(())
}
/// Sync all providers to live configuration (for additive mode apps)
///
/// Writes all providers from the database to the live configuration file.
/// Used for OpenCode and other additive mode applications.
fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> {
let providers = state.db.get_all_providers(app_type.as_str())?;
for provider in providers.values() {
if let Err(e) = write_live_snapshot(app_type, provider) {
log::warn!(
"Failed to sync {:?} provider '{}' to live: {e}",
app_type,
provider.id
);
// Continue syncing other providers, don't abort
}
}
log::info!(
"Synced {} {:?} providers to live config",
providers.len(),
app_type
);
Ok(())
}
/// Sync current provider to live configuration
///
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
///
/// For additive mode apps (OpenCode), all providers are synced instead of just the current one.
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// Sync providers based on mode
for app_type in AppType::all() {
if app_type.is_additive_mode() {
// Additive mode: sync ALL providers
sync_all_providers_to_live(state, &app_type)?;
} else {
// Switch mode: sync only current provider
let current_id =
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id,
None => continue,
};
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
// Use validated effective current provider
let current_id =
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id,
None => continue,
};
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_snapshot(&app_type, provider)?;
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_snapshot(&app_type, provider)?;
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
}
// MCP sync
McpService::sync_all_enabled(state)?;
// Skill sync
for app_type in AppType::all() {
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
// Continue syncing other apps, don't abort
+48 -64
View File
@@ -94,9 +94,6 @@ pub struct RequestLogDetail {
pub provider_name: Option<String>,
pub app_type: String,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_model: Option<String>,
pub cost_multiplier: String,
pub input_tokens: u32,
pub output_tokens: u32,
pub cache_read_tokens: u32,
@@ -143,7 +140,7 @@ impl Database {
};
let sql = format!(
"SELECT
"SELECT
COUNT(*) as total_requests,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
@@ -221,7 +218,7 @@ impl Database {
}
let sql = "
SELECT
SELECT
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
COUNT(*) as request_count,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
@@ -298,7 +295,7 @@ impl Database {
pub fn get_provider_stats(&self) -> Result<Vec<ProviderStats>, AppError> {
let conn = lock_conn!(self.conn);
let sql = "SELECT
let sql = "SELECT
l.provider_id,
p.name as provider_name,
COUNT(*) as request_count,
@@ -346,7 +343,7 @@ impl Database {
pub fn get_model_stats(&self) -> Result<Vec<ModelStats>, AppError> {
let conn = lock_conn!(self.conn);
let sql = "SELECT
let sql = "SELECT
model,
COUNT(*) as request_count,
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
@@ -427,7 +424,7 @@ impl Database {
// 获取总数
let count_sql = format!(
"SELECT COUNT(*) FROM proxy_request_logs l
"SELECT COUNT(*) FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
{where_clause}"
);
@@ -443,7 +440,6 @@ impl Database {
let sql = format!(
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
l.request_model, l.cost_multiplier,
l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,
l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,
l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
@@ -464,26 +460,22 @@ impl Database {
provider_name: row.get(2)?,
app_type: row.get(3)?,
model: row.get(4)?,
request_model: row.get(5)?,
cost_multiplier: row
.get::<_, Option<String>>(6)?
.unwrap_or_else(|| "1".to_string()),
input_tokens: row.get::<_, i64>(7)? as u32,
output_tokens: row.get::<_, i64>(8)? as u32,
cache_read_tokens: row.get::<_, i64>(9)? as u32,
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
input_cost_usd: row.get(11)?,
output_cost_usd: row.get(12)?,
cache_read_cost_usd: row.get(13)?,
cache_creation_cost_usd: row.get(14)?,
total_cost_usd: row.get(15)?,
is_streaming: row.get::<_, i64>(16)? != 0,
latency_ms: row.get::<_, i64>(17)? as u64,
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
status_code: row.get::<_, i64>(20)? as u16,
error_message: row.get(21)?,
created_at: row.get(22)?,
input_tokens: row.get::<_, i64>(5)? as u32,
output_tokens: row.get::<_, i64>(6)? as u32,
cache_read_tokens: row.get::<_, i64>(7)? as u32,
cache_creation_tokens: row.get::<_, i64>(8)? as u32,
input_cost_usd: row.get(9)?,
output_cost_usd: row.get(10)?,
cache_read_cost_usd: row.get(11)?,
cache_creation_cost_usd: row.get(12)?,
total_cost_usd: row.get(13)?,
is_streaming: row.get::<_, i64>(14)? != 0,
latency_ms: row.get::<_, i64>(15)? as u64,
first_token_ms: row.get::<_, Option<i64>>(16)?.map(|v| v as u64),
duration_ms: row.get::<_, Option<i64>>(17)?.map(|v| v as u64),
status_code: row.get::<_, i64>(18)? as u16,
error_message: row.get(19)?,
created_at: row.get(20)?,
})
})?;
@@ -519,7 +511,6 @@ impl Database {
let result = conn.query_row(
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
l.request_model, l.cost_multiplier,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
is_streaming, latency_ms, first_token_ms, duration_ms,
@@ -535,24 +526,22 @@ impl Database {
provider_name: row.get(2)?,
app_type: row.get(3)?,
model: row.get(4)?,
request_model: row.get(5)?,
cost_multiplier: row.get::<_, Option<String>>(6)?.unwrap_or_else(|| "1".to_string()),
input_tokens: row.get::<_, i64>(7)? as u32,
output_tokens: row.get::<_, i64>(8)? as u32,
cache_read_tokens: row.get::<_, i64>(9)? as u32,
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
input_cost_usd: row.get(11)?,
output_cost_usd: row.get(12)?,
cache_read_cost_usd: row.get(13)?,
cache_creation_cost_usd: row.get(14)?,
total_cost_usd: row.get(15)?,
is_streaming: row.get::<_, i64>(16)? != 0,
latency_ms: row.get::<_, i64>(17)? as u64,
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
status_code: row.get::<_, i64>(20)? as u16,
error_message: row.get(21)?,
created_at: row.get(22)?,
input_tokens: row.get::<_, i64>(5)? as u32,
output_tokens: row.get::<_, i64>(6)? as u32,
cache_read_tokens: row.get::<_, i64>(7)? as u32,
cache_creation_tokens: row.get::<_, i64>(8)? as u32,
input_cost_usd: row.get(9)?,
output_cost_usd: row.get(10)?,
cache_read_cost_usd: row.get(11)?,
cache_creation_cost_usd: row.get(12)?,
total_cost_usd: row.get(13)?,
is_streaming: row.get::<_, i64>(14)? != 0,
latency_ms: row.get::<_, i64>(15)? as u64,
first_token_ms: row.get::<_, Option<i64>>(16)?.map(|v| v as u64),
duration_ms: row.get::<_, Option<i64>>(17)?.map(|v| v as u64),
status_code: row.get::<_, i64>(18)? as u16,
error_message: row.get(19)?,
created_at: row.get(20)?,
})
},
);
@@ -702,26 +691,21 @@ impl Database {
)?;
let million = rust_decimal::Decimal::from(1_000_000u64);
// 与 CostCalculator::calculate 保持一致的计算逻辑:
// 1. input_cost 需要扣除 cache_read_tokens(避免缓存部分被重复计费)
// 2. 各项成本是基础成本(不含倍率)
// 3. 倍率只作用于最终总价
let billable_input_tokens =
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64);
let input_cost =
rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million;
let output_cost =
rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output / million;
let input_cost = rust_decimal::Decimal::from(log.input_tokens as u64) * pricing.input
/ million
* multiplier;
let output_cost = rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output
/ million
* multiplier;
let cache_read_cost = rust_decimal::Decimal::from(log.cache_read_tokens as u64)
* pricing.cache_read
/ million;
/ million
* multiplier;
let cache_creation_cost = rust_decimal::Decimal::from(log.cache_creation_tokens as u64)
* pricing.cache_creation
/ million;
// 总成本 = 基础成本之和 × 倍率
let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost;
let total_cost = base_total * multiplier;
/ million
* multiplier;
let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost;
log.input_cost_usd = format!("{input_cost:.6}");
log.output_cost_usd = format!("{output_cost:.6}");
+1 -32
View File
@@ -79,9 +79,6 @@ pub struct AppSettings {
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
/// 静默启动(程序启动时不显示主窗口,仅托盘运行)
#[serde(default)]
pub silent_startup: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
@@ -117,14 +114,6 @@ pub struct AppSettings {
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
#[serde(default)]
pub skill_sync_method: SyncMethod,
// ===== 终端设置 =====
/// 首选终端应用(可选,默认使用系统默认终端)
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
/// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal)
/// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preferred_terminal: Option<String>,
}
fn default_show_in_tray() -> bool {
@@ -143,7 +132,6 @@ impl Default for AppSettings {
enable_claude_plugin_integration: false,
skip_claude_onboarding: false,
launch_on_startup: false,
silent_startup: false,
language: None,
visible_apps: None,
claude_config_dir: None,
@@ -155,7 +143,6 @@ impl Default for AppSettings {
current_provider_gemini: None,
current_provider_opencode: None,
skill_sync_method: SyncMethod::default(),
preferred_terminal: None,
}
}
}
@@ -163,11 +150,7 @@ impl Default for AppSettings {
impl AppSettings {
fn settings_path() -> Option<PathBuf> {
// settings.json 保留用于旧版本迁移和无数据库场景
Some(
crate::config::get_home_dir()
.join(".cc-switch")
.join("settings.json"),
)
dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json"))
}
fn normalize_paths(&mut self) {
@@ -419,17 +402,3 @@ pub fn get_skill_sync_method() -> SyncMethod {
})
.skill_sync_method
}
// ===== 终端设置管理函数 =====
/// 获取首选终端应用
pub fn get_preferred_terminal() -> Option<String> {
settings_store()
.read()
.unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
})
.preferred_terminal
.clone()
}
+4 -10
View File
@@ -971,18 +971,12 @@ fn export_sql_returns_error_for_invalid_path() {
let state = create_test_state().expect("create test state");
// Try to export to an invalid path (nonexistent parent or invalid name on Windows)
let invalid_parent = if cfg!(windows) {
std::env::temp_dir().join("cc-switch-test-invalid<>dir")
} else {
PathBuf::from("/nonexistent/directory")
};
let invalid_path = invalid_parent.join("export.sql");
// Try to export to an invalid path (parent directory doesn't exist)
let invalid_path = PathBuf::from("/nonexistent/directory/export.sql");
let err = state
.db
.export_sql(&invalid_path)
.expect_err("export to invalid path should fail");
let invalid_prefix = invalid_parent.to_string_lossy();
// The error can be either IoContext or Io depending on where it fails
match err {
@@ -994,8 +988,8 @@ fn export_sql_returns_error_for_invalid_path() {
}
AppError::Io { path, .. } => {
assert!(
path.starts_with(invalid_prefix.as_ref()),
"expected error for {invalid_parent:?}, got: {path:?}"
path.starts_with("/nonexistent"),
"expected error for /nonexistent path, got: {path:?}"
);
}
other => panic!("expected IoContext or Io error, got {other:?}"),
-78
View File
@@ -1,78 +0,0 @@
use cc_switch_lib::{
get_default_cost_multiplier_test_hook, get_pricing_model_source_test_hook,
set_default_cost_multiplier_test_hook, set_pricing_model_source_test_hook, AppError,
};
#[path = "support.rs"]
mod support;
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
// 测试使用 Mutex 进行串行化,跨 await 持锁是预期行为
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn default_cost_multiplier_commands_round_trip() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
let default = get_default_cost_multiplier_test_hook(&state, "claude")
.await
.expect("read default multiplier");
assert_eq!(default, "1");
set_default_cost_multiplier_test_hook(&state, "claude", "1.5")
.await
.expect("set multiplier");
let updated = get_default_cost_multiplier_test_hook(&state, "claude")
.await
.expect("read updated multiplier");
assert_eq!(updated, "1.5");
let err = set_default_cost_multiplier_test_hook(&state, "claude", "not-a-number")
.await
.expect_err("invalid multiplier should error");
// 错误已改为 Localized 类型(支持 i18n
match err {
AppError::Localized { key, .. } => {
assert_eq!(key, "error.invalidMultiplier");
}
other => panic!("expected localized error, got {other:?}"),
}
}
// 测试使用 Mutex 进行串行化,跨 await 持锁是预期行为
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn pricing_model_source_commands_round_trip() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
let default = get_pricing_model_source_test_hook(&state, "claude")
.await
.expect("read default pricing model source");
assert_eq!(default, "response");
set_pricing_model_source_test_hook(&state, "claude", "request")
.await
.expect("set pricing model source");
let updated = get_pricing_model_source_test_hook(&state, "claude")
.await
.expect("read updated pricing model source");
assert_eq!(updated, "request");
let err = set_pricing_model_source_test_hook(&state, "claude", "invalid")
.await
.expect_err("invalid pricing model source should error");
// 错误已改为 Localized 类型(支持 i18n
match err {
AppError::Localized { key, .. } => {
assert_eq!(key, "error.invalidPricingMode");
}
other => panic!("expected localized error, got {other:?}"),
}
}
@@ -5,10 +5,10 @@ import {
ChevronRight,
FlaskConical,
Globe,
Coins,
Eye,
EyeOff,
X,
ArrowLeftRight,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -22,23 +22,19 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { ProviderTestConfig, ProviderProxyConfig } from "@/types";
export type PricingModelSourceOption = "inherit" | "request" | "response";
interface ProviderPricingConfig {
enabled: boolean;
costMultiplier?: string;
pricingModelSource: PricingModelSourceOption;
}
import type {
ProviderTestConfig,
ProviderProxyConfig,
FormatTransformConfig,
} from "@/types";
interface ProviderAdvancedConfigProps {
testConfig: ProviderTestConfig;
proxyConfig: ProviderProxyConfig;
pricingConfig: ProviderPricingConfig;
formatTransform?: FormatTransformConfig;
onTestConfigChange: (config: ProviderTestConfig) => void;
onProxyConfigChange: (config: ProviderProxyConfig) => void;
onPricingConfigChange: (config: ProviderPricingConfig) => void;
onFormatTransformChange?: (config: FormatTransformConfig) => void;
}
/** 从 ProviderProxyConfig 构建完整 URL */
@@ -89,18 +85,18 @@ function parseProxyUrl(url: string): Partial<ProviderProxyConfig> {
export function ProviderAdvancedConfig({
testConfig,
proxyConfig,
pricingConfig,
formatTransform,
onTestConfigChange,
onProxyConfigChange,
onPricingConfigChange,
onFormatTransformChange,
}: ProviderAdvancedConfigProps) {
const { t } = useTranslation();
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
proxyConfig.enabled,
);
const [isPricingConfigOpen, setIsPricingConfigOpen] = useState(
pricingConfig.enabled,
const [isFormatTransformOpen, setIsFormatTransformOpen] = useState(
formatTransform?.enabled ?? false,
);
const [showPassword, setShowPassword] = useState(false);
@@ -120,10 +116,10 @@ export function ProviderAdvancedConfig({
setIsProxyConfigOpen(proxyConfig.enabled);
}, [proxyConfig.enabled]);
// 同步外部 pricingConfig.enabled 变化到展开状态
// 同步外部 formatTransform.enabled 变化到展开状态
useEffect(() => {
setIsPricingConfigOpen(pricingConfig.enabled);
}, [pricingConfig.enabled]);
setIsFormatTransformOpen(formatTransform?.enabled ?? false);
}, [formatTransform?.enabled]);
// 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据)
useEffect(() => {
@@ -479,142 +475,134 @@ export function ProviderAdvancedConfig({
</div>
</div>
{/* 计费配置 */}
<div className="rounded-lg border border-border/50 bg-muted/20">
<button
type="button"
className="flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors"
onClick={() => setIsPricingConfigOpen(!isPricingConfigOpen)}
>
<div className="flex items-center gap-3">
<Coins className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{t("providerAdvanced.pricingConfig", {
defaultValue: "计费配置",
})}
</span>
</div>
<div className="flex items-center gap-3">
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Label
htmlFor="pricing-config-enabled"
className="text-sm text-muted-foreground"
>
{t("providerAdvanced.useCustomPricing", {
defaultValue: "使用单独配置",
})}
</Label>
<Switch
id="pricing-config-enabled"
checked={pricingConfig.enabled}
onCheckedChange={(checked) => {
onPricingConfigChange({ ...pricingConfig, enabled: checked });
if (checked) setIsPricingConfigOpen(true);
}}
/>
{/* 格式转换配置 */}
{onFormatTransformChange && (
<div className="rounded-lg border border-border/50 bg-muted/20">
<button
type="button"
className="flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors"
onClick={() => setIsFormatTransformOpen(!isFormatTransformOpen)}
>
<div className="flex items-center gap-3">
<ArrowLeftRight className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{t("providerAdvanced.formatTransform")}
</span>
</div>
{isPricingConfigOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
</button>
<div
className={cn(
"overflow-hidden transition-all duration-200",
isPricingConfigOpen
? "max-h-[500px] opacity-100"
: "max-h-0 opacity-0",
)}
>
<div className="border-t border-border/50 p-4 space-y-4">
<p className="text-sm text-muted-foreground">
{t("providerAdvanced.pricingConfigDesc", {
defaultValue:
"为此供应商配置单独的计费参数,不启用时使用全局默认配置。",
})}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cost-multiplier">
{t("providerAdvanced.costMultiplier", {
defaultValue: "成本倍率",
})}
</Label>
<Input
id="cost-multiplier"
type="number"
step="0.01"
inputMode="decimal"
value={pricingConfig.costMultiplier || ""}
onChange={(e) =>
onPricingConfigChange({
...pricingConfig,
costMultiplier: e.target.value || undefined,
})
}
placeholder={t("providerAdvanced.costMultiplierPlaceholder", {
defaultValue: "留空使用全局默认(1",
})}
disabled={!pricingConfig.enabled}
/>
<p className="text-xs text-muted-foreground">
{t("providerAdvanced.costMultiplierHint", {
defaultValue: "实际成本 = 基础成本 × 倍率,支持小数如 1.5",
})}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="pricing-model-source">
{t("providerAdvanced.pricingModelSourceLabel", {
defaultValue: "计费模式",
})}
</Label>
<Select
value={pricingConfig.pricingModelSource}
onValueChange={(value) =>
onPricingConfigChange({
...pricingConfig,
pricingModelSource: value as PricingModelSourceOption,
})
}
disabled={!pricingConfig.enabled}
<div className="flex items-center gap-3">
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Label
htmlFor="format-transform-enabled"
className="text-sm text-muted-foreground"
>
<SelectTrigger id="pricing-model-source">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">
{t("providerAdvanced.pricingModelSourceInherit", {
defaultValue: "继承全局默认",
})}
</SelectItem>
<SelectItem value="request">
{t("providerAdvanced.pricingModelSourceRequest", {
defaultValue: "请求模型",
})}
</SelectItem>
<SelectItem value="response">
{t("providerAdvanced.pricingModelSourceResponse", {
defaultValue: "返回模型",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("providerAdvanced.pricingModelSourceHint", {
defaultValue: "选择按请求模型还是返回模型进行定价匹配",
})}
</p>
{t("providerAdvanced.enableFormatTransform")}
</Label>
<Switch
id="format-transform-enabled"
checked={formatTransform?.enabled ?? false}
onCheckedChange={(checked) => {
onFormatTransformChange({
...(formatTransform ?? { enabled: false }),
enabled: checked,
});
if (checked) setIsFormatTransformOpen(true);
}}
/>
</div>
{isFormatTransformOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
</button>
<div
className={cn(
"overflow-hidden transition-all duration-200",
isFormatTransformOpen
? "max-h-[500px] opacity-100"
: "max-h-0 opacity-0",
)}
>
<div className="border-t border-border/50 p-4 space-y-4">
<p className="text-sm text-muted-foreground">
{t("providerAdvanced.formatTransformDesc")}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="source-format">
{t("providerAdvanced.sourceFormat")}
</Label>
<Select
value={formatTransform?.sourceFormat ?? "anthropic"}
onValueChange={(value) =>
onFormatTransformChange({
...(formatTransform ?? { enabled: false }),
sourceFormat: value as "anthropic" | "openai",
})
}
disabled={!formatTransform?.enabled}
>
<SelectTrigger id="source-format">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">
Anthropic (Claude)
</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="target-format">
{t("providerAdvanced.targetFormat")}
</Label>
<Select
value={formatTransform?.targetFormat ?? "openai"}
onValueChange={(value) =>
onFormatTransformChange({
...(formatTransform ?? { enabled: false }),
targetFormat: value as "anthropic" | "openai",
})
}
disabled={!formatTransform?.enabled}
>
<SelectTrigger id="target-format">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">
Anthropic (Claude)
</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
id="transform-streaming"
checked={formatTransform?.transformStreaming ?? true}
onCheckedChange={(checked) =>
onFormatTransformChange({
...(formatTransform ?? { enabled: false }),
transformStreaming: checked,
})
}
disabled={!formatTransform?.enabled}
/>
<Label htmlFor="transform-streaming" className="text-sm">
{t("providerAdvanced.transformStreaming")}
</Label>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -13,6 +13,7 @@ import type {
ProviderMeta,
ProviderTestConfig,
ProviderProxyConfig,
FormatTransformConfig,
} from "@/types";
import {
providerPresets,
@@ -46,10 +47,7 @@ import { BasicFormFields } from "./BasicFormFields";
import { ClaudeFormFields } from "./ClaudeFormFields";
import { CodexFormFields } from "./CodexFormFields";
import { GeminiFormFields } from "./GeminiFormFields";
import {
ProviderAdvancedConfig,
type PricingModelSourceOption,
} from "./ProviderAdvancedConfig";
import { ProviderAdvancedConfig } from "./ProviderAdvancedConfig";
import {
useProviderCategory,
useApiKeyState,
@@ -124,9 +122,6 @@ interface ProviderFormProps {
showButtons?: boolean;
}
const normalizePricingSource = (value?: string): PricingModelSourceOption =>
value === "request" || value === "response" ? value : "inherit";
export function ProviderForm({
appId,
providerId,
@@ -174,19 +169,9 @@ export function ProviderForm({
const [proxyConfig, setProxyConfig] = useState<ProviderProxyConfig>(
() => initialData?.meta?.proxyConfig ?? { enabled: false },
);
const [pricingConfig, setPricingConfig] = useState<{
enabled: boolean;
costMultiplier?: string;
pricingModelSource: PricingModelSourceOption;
}>(() => ({
enabled:
initialData?.meta?.costMultiplier !== undefined ||
initialData?.meta?.pricingModelSource !== undefined,
costMultiplier: initialData?.meta?.costMultiplier,
pricingModelSource: normalizePricingSource(
initialData?.meta?.pricingModelSource,
),
}));
const [formatTransform, setFormatTransform] = useState<FormatTransformConfig>(
() => initialData?.meta?.formatTransform ?? { enabled: false },
);
// 使用 category hook
const { category } = useProviderCategory({
@@ -207,15 +192,6 @@ export function ProviderForm({
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
setPricingConfig({
enabled:
initialData?.meta?.costMultiplier !== undefined ||
initialData?.meta?.pricingModelSource !== undefined,
costMultiplier: initialData?.meta?.costMultiplier,
pricingModelSource: normalizePricingSource(
initialData?.meta?.pricingModelSource,
),
});
}, [appId, initialData]);
const defaultValues: ProviderFormData = useMemo(
@@ -968,13 +944,7 @@ export function ProviderForm({
// 添加高级配置
testConfig: testConfig.enabled ? testConfig : undefined,
proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,
costMultiplier: pricingConfig.enabled
? pricingConfig.costMultiplier
: undefined,
pricingModelSource:
pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit"
? pricingConfig.pricingModelSource
: undefined,
formatTransform: formatTransform.enabled ? formatTransform : undefined,
};
onSubmit(payload);
@@ -1499,10 +1469,10 @@ export function ProviderForm({
<ProviderAdvancedConfig
testConfig={testConfig}
proxyConfig={proxyConfig}
pricingConfig={pricingConfig}
formatTransform={formatTransform}
onTestConfigChange={setTestConfig}
onProxyConfigChange={setProxyConfig}
onPricingConfigChange={setPricingConfig}
onFormatTransformChange={setFormatTransform}
/>
{showButtons && (
@@ -15,7 +15,6 @@ interface DirectorySettingsProps {
claudeDir?: string;
codexDir?: string;
geminiDir?: string;
opencodeDir?: string;
onDirectoryChange: (app: AppId, value?: string) => void;
onBrowseDirectory: (app: AppId) => Promise<void>;
onResetDirectory: (app: AppId) => Promise<void>;
@@ -30,7 +29,6 @@ export function DirectorySettings({
claudeDir,
codexDir,
geminiDir,
opencodeDir,
onDirectoryChange,
onBrowseDirectory,
onResetDirectory,
@@ -119,17 +117,6 @@ export function DirectorySettings({
onBrowse={() => onBrowseDirectory("gemini")}
onReset={() => onResetDirectory("gemini")}
/>
<DirectoryInput
label={t("settings.opencodeConfigDir")}
description={undefined}
value={opencodeDir}
resolvedValue={resolvedDirs.opencode}
placeholder={t("settings.browsePlaceholderOpencode")}
onChange={(val) => onDirectoryChange("opencode", val)}
onBrowse={() => onBrowseDirectory("opencode")}
onReset={() => onResetDirectory("opencode")}
/>
</section>
</>
);
-8
View File
@@ -36,7 +36,6 @@ import { ThemeSettings } from "@/components/settings/ThemeSettings";
import { WindowSettings } from "@/components/settings/WindowSettings";
import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings";
import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings";
import { TerminalSettings } from "@/components/settings/TerminalSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection";
import { AboutSection } from "@/components/settings/AboutSection";
@@ -257,12 +256,6 @@ export function SettingsPage({
handleAutoSave({ skillSyncMethod: method })
}
/>
<TerminalSettings
value={settings.preferredTerminal}
onChange={(terminal) =>
handleAutoSave({ preferredTerminal: terminal })
}
/>
</motion.div>
) : null}
</TabsContent>
@@ -307,7 +300,6 @@ export function SettingsPage({
claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir}
geminiDir={settings.geminiConfigDir}
opencodeDir={settings.opencodeConfigDir}
onDirectoryChange={updateDirectory}
onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory}
@@ -1,111 +0,0 @@
import { useTranslation } from "react-i18next";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { isMac, isWindows, isLinux } from "@/lib/platform";
// Terminal options per platform
const MACOS_TERMINALS = [
{ value: "terminal", labelKey: "settings.terminal.options.macos.terminal" },
{ value: "iterm2", labelKey: "settings.terminal.options.macos.iterm2" },
{ value: "alacritty", labelKey: "settings.terminal.options.macos.alacritty" },
{ value: "kitty", labelKey: "settings.terminal.options.macos.kitty" },
{ value: "ghostty", labelKey: "settings.terminal.options.macos.ghostty" },
] as const;
const WINDOWS_TERMINALS = [
{ value: "cmd", labelKey: "settings.terminal.options.windows.cmd" },
{
value: "powershell",
labelKey: "settings.terminal.options.windows.powershell",
},
{ value: "wt", labelKey: "settings.terminal.options.windows.wt" },
] as const;
const LINUX_TERMINALS = [
{
value: "gnome-terminal",
labelKey: "settings.terminal.options.linux.gnomeTerminal",
},
{ value: "konsole", labelKey: "settings.terminal.options.linux.konsole" },
{
value: "xfce4-terminal",
labelKey: "settings.terminal.options.linux.xfce4Terminal",
},
{ value: "alacritty", labelKey: "settings.terminal.options.linux.alacritty" },
{ value: "kitty", labelKey: "settings.terminal.options.linux.kitty" },
{ value: "ghostty", labelKey: "settings.terminal.options.linux.ghostty" },
] as const;
// Get terminals for the current platform
function getTerminalOptions() {
if (isMac()) {
return MACOS_TERMINALS;
}
if (isWindows()) {
return WINDOWS_TERMINALS;
}
if (isLinux()) {
return LINUX_TERMINALS;
}
// Fallback to macOS options
return MACOS_TERMINALS;
}
// Get default terminal for the current platform
function getDefaultTerminal(): string {
if (isMac()) {
return "terminal";
}
if (isWindows()) {
return "cmd";
}
if (isLinux()) {
return "gnome-terminal";
}
return "terminal";
}
export interface TerminalSettingsProps {
value?: string;
onChange: (value: string) => void;
}
export function TerminalSettings({ value, onChange }: TerminalSettingsProps) {
const { t } = useTranslation();
const terminals = getTerminalOptions();
const defaultTerminal = getDefaultTerminal();
// Use value or default
const currentValue = value || defaultTerminal;
return (
<section className="space-y-2">
<header className="space-y-1">
<h3 className="text-sm font-medium">{t("settings.terminal.title")}</h3>
<p className="text-xs text-muted-foreground">
{t("settings.terminal.description")}
</p>
</header>
<Select value={currentValue} onValueChange={onChange}>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{terminals.map((terminal) => (
<SelectItem key={terminal.value} value={terminal.value}>
{t(terminal.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("settings.terminal.fallbackHint")}
</p>
</section>
);
}
+1 -9
View File
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import type { SettingsFormState } from "@/hooks/useSettings";
import { AppWindow, MonitorUp, Power, EyeOff } from "lucide-react";
import { AppWindow, MonitorUp, Power } from "lucide-react";
import { ToggleRow } from "@/components/ui/toggle-row";
interface WindowSettingsProps {
@@ -27,14 +27,6 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
/>
<ToggleRow
icon={<EyeOff className="h-4 w-4 text-green-500" />}
title={t("settings.silentStartup")}
description={t("settings.silentStartupDescription")}
checked={!!settings.silentStartup}
onCheckedChange={(value) => onChange({ silentStartup: value })}
/>
<ToggleRow
icon={<MonitorUp className="h-4 w-4 text-purple-500" />}
title={t("settings.enableClaudePluginIntegration")}
+137 -372
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
@@ -18,31 +19,10 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useModelPricing, useDeleteModelPricing } from "@/lib/query/usage";
import { PricingEditModal } from "./PricingEditModal";
import type { ModelPricing } from "@/types/usage";
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { proxyApi } from "@/lib/api/proxy";
const PRICING_APPS = ["claude", "codex", "gemini"] as const;
type PricingApp = (typeof PRICING_APPS)[number];
type PricingModelSource = "request" | "response";
interface AppConfig {
multiplier: string;
source: PricingModelSource;
}
type AppConfigState = Record<PricingApp, AppConfig>;
import { Plus, Pencil, Trash2, ChevronDown, ChevronRight } from "lucide-react";
export function PricingConfigPanel() {
const { t } = useTranslation();
@@ -51,137 +31,13 @@ export function PricingConfigPanel() {
const [editingModel, setEditingModel] = useState<ModelPricing | null>(null);
const [isAddingNew, setIsAddingNew] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
// 三个应用的配置状态
const [appConfigs, setAppConfigs] = useState<AppConfigState>({
claude: { multiplier: "1", source: "response" },
codex: { multiplier: "1", source: "response" },
gemini: { multiplier: "1", source: "response" },
});
const [originalConfigs, setOriginalConfigs] = useState<AppConfigState | null>(
null,
);
const [isConfigLoading, setIsConfigLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
// 检查是否有改动
const isDirty =
originalConfigs !== null &&
PRICING_APPS.some(
(app) =>
appConfigs[app].multiplier !== originalConfigs[app].multiplier ||
appConfigs[app].source !== originalConfigs[app].source,
);
// 加载所有应用的配置
useEffect(() => {
let isMounted = true;
const loadAllConfigs = async () => {
setIsConfigLoading(true);
try {
const results = await Promise.all(
PRICING_APPS.map(async (app) => {
const [multiplier, source] = await Promise.all([
proxyApi.getDefaultCostMultiplier(app),
proxyApi.getPricingModelSource(app),
]);
return {
app,
multiplier,
source: (source === "request"
? "request"
: "response") as PricingModelSource,
};
}),
);
if (!isMounted) return;
const newState: AppConfigState = {
claude: { multiplier: "1", source: "response" },
codex: { multiplier: "1", source: "response" },
gemini: { multiplier: "1", source: "response" },
};
for (const result of results) {
newState[result.app] = {
multiplier: result.multiplier,
source: result.source,
};
}
setAppConfigs(newState);
setOriginalConfigs(newState);
} catch (error) {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Unknown error";
toast.error(
t("settings.globalProxy.pricingLoadFailed", { error: message }),
);
} finally {
if (isMounted) setIsConfigLoading(false);
}
};
loadAllConfigs();
return () => {
isMounted = false;
};
}, [t]);
// 保存所有配置
const handleSaveAll = async () => {
// 验证所有倍率
for (const app of PRICING_APPS) {
const trimmed = appConfigs[app].multiplier.trim();
if (!trimmed) {
toast.error(
`${t(`apps.${app}`)}: ${t("settings.globalProxy.defaultCostMultiplierRequired")}`,
);
return;
}
if (!/^-?\d+(?:\.\d+)?$/.test(trimmed)) {
toast.error(
`${t(`apps.${app}`)}: ${t("settings.globalProxy.defaultCostMultiplierInvalid")}`,
);
return;
}
}
setIsSaving(true);
try {
await Promise.all(
PRICING_APPS.flatMap((app) => [
proxyApi.setDefaultCostMultiplier(
app,
appConfigs[app].multiplier.trim(),
),
proxyApi.setPricingModelSource(app, appConfigs[app].source),
]),
);
toast.success(t("settings.globalProxy.pricingSaved"));
setOriginalConfigs({ ...appConfigs });
} catch (error) {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Unknown error";
toast.error(
t("settings.globalProxy.pricingSaveFailed", { error: message }),
);
} finally {
setIsSaving(false);
}
};
const [isExpanded, setIsExpanded] = useState(false);
const handleDelete = (modelId: string) => {
deleteMutation.mutate(modelId, {
onSuccess: () => setDeleteConfirm(null),
onSuccess: () => {
setDeleteConfirm(null);
},
});
};
@@ -199,240 +55,149 @@ export function PricingConfigPanel() {
if (isLoading) {
return (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
<Card className="border rounded-lg">
<CardHeader
className="cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<ChevronRight className="h-4 w-4" />
<CardTitle className="text-base">
{t("usage.modelPricing")}
</CardTitle>
</div>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Alert variant="destructive">
<AlertDescription>
{t("usage.loadPricingError")}: {String(error)}
</AlertDescription>
</Alert>
<Card className="border rounded-lg">
<CardHeader
className="cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<CardTitle className="text-base">
{t("usage.modelPricing")}
</CardTitle>
</div>
</CardHeader>
{isExpanded && (
<CardContent>
<Alert variant="destructive">
<AlertDescription>
{t("usage.loadPricingError")}: {String(error)}
</AlertDescription>
</Alert>
</CardContent>
)}
</Card>
);
}
return (
<div className="space-y-6">
{/* 全局计费默认配置 - 紧凑表格布局 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium">
{t("settings.globalProxy.pricingDefaultsTitle")}
</h4>
<p className="text-xs text-muted-foreground">
{t("settings.globalProxy.pricingDefaultsDescription")}
</p>
</div>
<Button
onClick={handleSaveAll}
disabled={isConfigLoading || isSaving || !isDirty}
size="sm"
>
{isSaving ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
{t("common.saving")}
</>
) : (
t("common.save")
)}
</Button>
</div>
{isConfigLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="rounded-md border border-border/50 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 bg-muted/30">
<th className="px-3 py-2 text-left font-medium text-muted-foreground w-24">
{t("settings.globalProxy.pricingAppLabel")}
</th>
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
{t("settings.globalProxy.defaultCostMultiplierLabel")}
</th>
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
{t("settings.globalProxy.pricingModelSourceLabel")}
</th>
</tr>
</thead>
<tbody>
{PRICING_APPS.map((app, idx) => (
<tr
key={app}
className={
idx < PRICING_APPS.length - 1
? "border-b border-border/30"
: ""
}
>
<td className="px-3 py-1.5 font-medium">
{t(`apps.${app}`)}
</td>
<td className="px-3 py-1.5">
<Input
type="number"
step="0.01"
inputMode="decimal"
value={appConfigs[app].multiplier}
onChange={(e) =>
setAppConfigs((prev) => ({
...prev,
[app]: { ...prev[app], multiplier: e.target.value },
}))
}
disabled={isSaving}
placeholder="1"
className="h-7 w-24"
/>
</td>
<td className="px-3 py-1.5">
<Select
value={appConfigs[app].source}
onValueChange={(value) =>
setAppConfigs((prev) => ({
...prev,
[app]: {
...prev[app],
source: value as PricingModelSource,
},
}))
}
disabled={isSaving}
>
<SelectTrigger className="h-7 w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="response">
{t(
"settings.globalProxy.pricingModelSourceResponse",
)}
</SelectItem>
<SelectItem value="request">
{t(
"settings.globalProxy.pricingModelSourceRequest",
)}
</SelectItem>
</SelectContent>
</Select>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-medium text-muted-foreground">
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
</h4>
<Button
onClick={(e) => {
e.stopPropagation();
handleAddNew();
}}
size="sm"
>
<Plus className="mr-1 h-4 w-4" />
{t("common.add")}
</Button>
</div>
{/* 分隔线 */}
<div className="border-t border-border/50" />
{/* 模型定价配置 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-muted-foreground">
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
</h4>
<Button
onClick={(e) => {
e.stopPropagation();
handleAddNew();
}}
size="sm"
>
<Plus className="mr-1 h-4 w-4" />
{t("common.add")}
</Button>
</div>
<div className="space-y-4">
{!pricing || pricing.length === 0 ? (
<Alert>
<AlertDescription>{t("usage.noPricingData")}</AlertDescription>
</Alert>
) : (
<div className="rounded-md bg-card/60 shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("usage.model")}</TableHead>
<TableHead>{t("usage.displayName")}</TableHead>
<TableHead className="text-right">
{t("usage.inputCost")}
</TableHead>
<TableHead className="text-right">
{t("usage.outputCost")}
</TableHead>
<TableHead className="text-right">
{t("usage.cacheReadCost")}
</TableHead>
<TableHead className="text-right">
{t("usage.cacheWriteCost")}
</TableHead>
<TableHead className="text-right">
{t("common.actions")}
</TableHead>
{!pricing || pricing.length === 0 ? (
<Alert>
<AlertDescription>{t("usage.noPricingData")}</AlertDescription>
</Alert>
) : (
<div className="rounded-md bg-card/60 shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("usage.model")}</TableHead>
<TableHead>{t("usage.displayName")}</TableHead>
<TableHead className="text-right">
{t("usage.inputCost")}
</TableHead>
<TableHead className="text-right">
{t("usage.outputCost")}
</TableHead>
<TableHead className="text-right">
{t("usage.cacheReadCost")}
</TableHead>
<TableHead className="text-right">
{t("usage.cacheWriteCost")}
</TableHead>
<TableHead className="text-right">
{t("common.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pricing.map((model) => (
<TableRow key={model.modelId}>
<TableCell className="font-mono text-sm">
{model.modelId}
</TableCell>
<TableCell>{model.displayName}</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.inputCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.outputCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.cacheReadCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.cacheCreationCostPerMillion}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => {
setIsAddingNew(false);
setEditingModel(model);
}}
title={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteConfirm(model.modelId)}
title={t("common.delete")}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{pricing.map((model) => (
<TableRow key={model.modelId}>
<TableCell className="font-mono text-sm">
{model.modelId}
</TableCell>
<TableCell>{model.displayName}</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.inputCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.outputCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.cacheReadCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.cacheCreationCostPerMillion}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => {
setIsAddingNew(false);
setEditingModel(model);
}}
title={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteConfirm(model.modelId)}
title={t("common.delete")}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
))}
</TableBody>
</Table>
</div>
)}
</div>
{editingModel && (
+1 -31
View File
@@ -184,9 +184,6 @@ export function RequestDetailPanel({
<div>
<dt className="text-muted-foreground">
{t("usage.inputCost", "输入成本")}
<span className="ml-1 text-xs">
({t("usage.baseCost", "基础")})
</span>
</dt>
<dd className="font-mono">
${parseFloat(request.inputCostUsd).toFixed(6)}
@@ -195,9 +192,6 @@ export function RequestDetailPanel({
<div>
<dt className="text-muted-foreground">
{t("usage.outputCost", "输出成本")}
<span className="ml-1 text-xs">
({t("usage.baseCost", "基础")})
</span>
</dt>
<dd className="font-mono">
${parseFloat(request.outputCostUsd).toFixed(6)}
@@ -206,9 +200,6 @@ export function RequestDetailPanel({
<div>
<dt className="text-muted-foreground">
{t("usage.cacheReadCost", "缓存读取成本")}
<span className="ml-1 text-xs">
({t("usage.baseCost", "基础")})
</span>
</dt>
<dd className="font-mono">
${parseFloat(request.cacheReadCostUsd).toFixed(6)}
@@ -217,35 +208,14 @@ export function RequestDetailPanel({
<div>
<dt className="text-muted-foreground">
{t("usage.cacheCreationCost", "缓存写入成本")}
<span className="ml-1 text-xs">
({t("usage.baseCost", "基础")})
</span>
</dt>
<dd className="font-mono">
${parseFloat(request.cacheCreationCostUsd).toFixed(6)}
</dd>
</div>
{/* 显示成本倍率(如果不等于1) */}
{request.costMultiplier &&
parseFloat(request.costMultiplier) !== 1 && (
<div className="col-span-2 border-t pt-3">
<dt className="text-muted-foreground">
{t("usage.costMultiplier", "成本倍率")}
</dt>
<dd className="font-mono">×{request.costMultiplier}</dd>
</div>
)}
<div
className={`col-span-2 ${request.costMultiplier && parseFloat(request.costMultiplier) !== 1 ? "" : "border-t"} pt-3`}
>
<div className="col-span-2 border-t pt-3">
<dt className="text-muted-foreground">
{t("usage.totalCost", "总成本")}
{request.costMultiplier &&
parseFloat(request.costMultiplier) !== 1 && (
<span className="ml-1 text-xs">
({t("usage.withMultiplier", "含倍率")})
</span>
)}
</dt>
<dd className="text-lg font-semibold text-primary">
${parseFloat(request.totalCostUsd).toFixed(6)}
+7 -33
View File
@@ -250,7 +250,7 @@ export function RequestLogTable() {
<TableHead className="whitespace-nowrap">
{t("usage.provider")}
</TableHead>
<TableHead className="min-w-[200px] whitespace-nowrap">
<TableHead className="min-w-[280px] whitespace-nowrap">
{t("usage.billingModel")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
@@ -265,9 +265,6 @@ export function RequestLogTable() {
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
{t("usage.cacheCreationTokens")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("usage.multiplier")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("usage.totalCost")}
</TableHead>
@@ -283,7 +280,7 @@ export function RequestLogTable() {
{logs.length === 0 ? (
<TableRow>
<TableCell
colSpan={11}
colSpan={10}
className="text-center text-muted-foreground"
>
{t("usage.noData")}
@@ -300,25 +297,11 @@ export function RequestLogTable() {
<TableCell>
{log.providerName || t("usage.unknownProvider")}
</TableCell>
<TableCell className="font-mono text-xs max-w-[200px]">
<div
className="truncate"
title={
log.requestModel && log.requestModel !== log.model
? `${t("usage.requestModel")}: ${log.requestModel}\n${t("usage.responseModel")}: ${log.model}`
: log.model
}
>
{log.model}
</div>
{log.requestModel && log.requestModel !== log.model && (
<div
className="truncate text-muted-foreground text-[10px]"
title={log.requestModel}
>
{log.requestModel}
</div>
)}
<TableCell
className="font-mono text-sm max-w-[280px] truncate"
title={log.model}
>
{log.model}
</TableCell>
<TableCell className="text-right">
{log.inputTokens.toLocaleString()}
@@ -332,15 +315,6 @@ export function RequestLogTable() {
<TableCell className="text-right">
{log.cacheCreationTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{parseFloat(log.costMultiplier) !== 1 ? (
<span className="text-orange-600">
×{log.costMultiplier}
</span>
) : (
<span className="text-muted-foreground">×1</span>
)}
</TableCell>
<TableCell className="text-right">
${parseFloat(log.totalCostUsd).toFixed(6)}
</TableCell>
+8 -52
View File
@@ -5,14 +5,13 @@ import { homeDir, join } from "@tauri-apps/api/path";
import { settingsApi, type AppId } from "@/lib/api";
import type { SettingsFormState } from "./useSettingsForm";
type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini" | "opencode";
type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini";
export interface ResolvedDirectories {
appConfig: string;
claude: string;
codex: string;
gemini: string;
opencode: string;
}
const sanitizeDir = (value?: string | null): string | undefined => {
@@ -40,13 +39,7 @@ const computeDefaultConfigDir = async (
try {
const home = await homeDir();
const folder =
app === "claude"
? ".claude"
: app === "codex"
? ".codex"
: app === "gemini"
? ".gemini"
: ".config/opencode";
app === "claude" ? ".claude" : app === "codex" ? ".codex" : ".gemini";
return await join(home, folder);
} catch (error) {
console.error(
@@ -77,7 +70,6 @@ export interface UseDirectorySettingsResult {
claudeDir?: string,
codexDir?: string,
geminiDir?: string,
opencodeDir?: string,
) => void;
}
@@ -104,7 +96,6 @@ export function useDirectorySettings({
claude: "",
codex: "",
gemini: "",
opencode: "",
});
const [isLoading, setIsLoading] = useState(true);
@@ -113,7 +104,6 @@ export function useDirectorySettings({
claude: "",
codex: "",
gemini: "",
opencode: "",
});
const initialAppConfigDirRef = useRef<string | undefined>(undefined);
@@ -129,23 +119,19 @@ export function useDirectorySettings({
claudeDir,
codexDir,
geminiDir,
opencodeDir,
defaultAppConfig,
defaultClaudeDir,
defaultCodexDir,
defaultGeminiDir,
defaultOpencodeDir,
] = await Promise.all([
settingsApi.getAppConfigDirOverride(),
settingsApi.getConfigDir("claude"),
settingsApi.getConfigDir("codex"),
settingsApi.getConfigDir("gemini"),
settingsApi.getConfigDir("opencode"),
computeDefaultAppConfigDir(),
computeDefaultConfigDir("claude"),
computeDefaultConfigDir("codex"),
computeDefaultConfigDir("gemini"),
computeDefaultConfigDir("opencode"),
]);
if (!active) return;
@@ -157,7 +143,6 @@ export function useDirectorySettings({
claude: defaultClaudeDir ?? "",
codex: defaultCodexDir ?? "",
gemini: defaultGeminiDir ?? "",
opencode: defaultOpencodeDir ?? "",
};
setAppConfigDir(normalizedOverride);
@@ -168,7 +153,6 @@ export function useDirectorySettings({
claude: claudeDir || defaultsRef.current.claude,
codex: codexDir || defaultsRef.current.codex,
gemini: geminiDir || defaultsRef.current.gemini,
opencode: opencodeDir || defaultsRef.current.opencode,
});
} catch (error) {
console.error(
@@ -199,9 +183,7 @@ export function useDirectorySettings({
? { claudeConfigDir: sanitized }
: key === "codex"
? { codexConfigDir: sanitized }
: key === "gemini"
? { geminiConfigDir: sanitized }
: { opencodeConfigDir: sanitized },
: { geminiConfigDir: sanitized },
);
}
@@ -223,13 +205,7 @@ export function useDirectorySettings({
const updateDirectory = useCallback(
(app: AppId, value?: string) => {
updateDirectoryState(
app === "claude"
? "claude"
: app === "codex"
? "codex"
: app === "gemini"
? "gemini"
: "opencode",
app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini",
value,
);
},
@@ -239,21 +215,13 @@ export function useDirectorySettings({
const browseDirectory = useCallback(
async (app: AppId) => {
const key: DirectoryKey =
app === "claude"
? "claude"
: app === "codex"
? "codex"
: app === "gemini"
? "gemini"
: "opencode";
app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini";
const currentValue =
key === "claude"
? (settings?.claudeConfigDir ?? resolvedDirs.claude)
: key === "codex"
? (settings?.codexConfigDir ?? resolvedDirs.codex)
: key === "gemini"
? (settings?.geminiConfigDir ?? resolvedDirs.gemini)
: (settings?.opencodeConfigDir ?? resolvedDirs.opencode);
: (settings?.geminiConfigDir ?? resolvedDirs.gemini);
try {
const picked = await settingsApi.selectConfigDirectory(currentValue);
@@ -295,13 +263,7 @@ export function useDirectorySettings({
const resetDirectory = useCallback(
async (app: AppId) => {
const key: DirectoryKey =
app === "claude"
? "claude"
: app === "codex"
? "codex"
: app === "gemini"
? "gemini"
: "opencode";
app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini";
if (!defaultsRef.current[key]) {
const fallback = await computeDefaultConfigDir(app);
if (fallback) {
@@ -330,12 +292,7 @@ export function useDirectorySettings({
}, [updateDirectoryState]);
const resetAllDirectories = useCallback(
(
claudeDir?: string,
codexDir?: string,
geminiDir?: string,
opencodeDir?: string,
) => {
(claudeDir?: string, codexDir?: string, geminiDir?: string) => {
setAppConfigDir(initialAppConfigDirRef.current);
setResolvedDirs({
appConfig:
@@ -343,7 +300,6 @@ export function useDirectorySettings({
claude: claudeDir ?? defaultsRef.current.claude,
codex: codexDir ?? defaultsRef.current.codex,
gemini: geminiDir ?? defaultsRef.current.gemini,
opencode: opencodeDir ?? defaultsRef.current.opencode,
});
},
[],
+2 -18
View File
@@ -109,7 +109,6 @@ export function useSettings(): UseSettingsResult {
sanitizeDir(data?.claudeConfigDir),
sanitizeDir(data?.codexConfigDir),
sanitizeDir(data?.geminiConfigDir),
sanitizeDir(data?.opencodeConfigDir),
);
setRequiresRestart(false);
}, [
@@ -132,16 +131,12 @@ export function useSettings(): UseSettingsResult {
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
const sanitizedOpencodeDir = sanitizeDir(
mergedSettings.opencodeConfigDir,
);
const payload: Settings = {
...mergedSettings,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
opencodeConfigDir: sanitizedOpencodeDir,
language: mergedSettings.language,
};
@@ -243,21 +238,16 @@ export function useSettings(): UseSettingsResult {
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
const sanitizedOpencodeDir = sanitizeDir(
mergedSettings.opencodeConfigDir,
);
const previousAppDir = initialAppConfigDir;
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir);
const payload: Settings = {
...mergedSettings,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
opencodeConfigDir: sanitizedOpencodeDir,
language: mergedSettings.language,
};
@@ -354,17 +344,11 @@ export function useSettings(): UseSettingsResult {
console.warn("[useSettings] Failed to refresh tray menu", error);
}
// 如果 Claude/Codex/Gemini/OpenCode 的目录覆盖发生变化,则立即将"当前使用的供应商"写回对应应用的 live 配置
// 如果 Claude/Codex/Gemini 的目录覆盖发生变化,则立即将当前使用的供应商写回对应应用的 live 配置
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;
const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir;
if (
claudeDirChanged ||
codexDirChanged ||
geminiDirChanged ||
opencodeDirChanged
) {
if (claudeDirChanged || codexDirChanged || geminiDirChanged) {
const syncResult = await syncCurrentProvidersLiveSafe();
if (!syncResult.ok) {
console.warn(
-6
View File
@@ -83,12 +83,9 @@ export function useSettingsForm(): UseSettingsFormResult {
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
enableClaudePluginIntegration:
data.enableClaudePluginIntegration ?? false,
silentStartup: data.silentStartup ?? false,
skipClaudeOnboarding: data.skipClaudeOnboarding ?? false,
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
codexConfigDir: sanitizeDir(data.codexConfigDir),
geminiConfigDir: sanitizeDir(data.geminiConfigDir),
opencodeConfigDir: sanitizeDir(data.opencodeConfigDir),
language: normalizedLanguage,
};
@@ -141,12 +138,9 @@ export function useSettingsForm(): UseSettingsFormResult {
minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true,
enableClaudePluginIntegration:
serverData.enableClaudePluginIntegration ?? false,
silentStartup: serverData.silentStartup ?? false,
skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false,
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
geminiConfigDir: sanitizeDir(serverData.geminiConfigDir),
opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir),
language: normalizedLanguage,
};
+8 -74
View File
@@ -265,8 +265,6 @@
"windowBehaviorHint": "Configure window minimize and Claude plugin integration policies.",
"launchOnStartup": "Launch on Startup",
"launchOnStartupDescription": "Automatically run CC Switch when system starts",
"silentStartup": "Silent Startup",
"silentStartupDescription": "Start in background mode without showing main window",
"autoLaunchFailed": "Failed to set auto-launch",
"minimizeToTray": "Minimize to tray on close",
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
@@ -289,33 +287,6 @@
"copy": "Copy Files",
"symlinkHint": "Symlinks save disk space and enable real-time sync. Note: May require admin privileges or Developer Mode on Windows"
},
"terminal": {
"title": "Preferred Terminal",
"description": "Choose which terminal app to use when clicking the terminal button",
"fallbackHint": "If the selected terminal is unavailable, the system default will be used",
"options": {
"macos": {
"terminal": "Terminal.app",
"iterm2": "iTerm2",
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty"
},
"windows": {
"cmd": "Command Prompt",
"powershell": "PowerShell",
"wt": "Windows Terminal"
},
"linux": {
"gnomeTerminal": "GNOME Terminal",
"konsole": "Konsole",
"xfce4Terminal": "Xfce4 Terminal",
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty"
}
}
},
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.",
"appConfigDir": "CC Switch Configuration Directory",
@@ -327,12 +298,9 @@
"codexConfigDirDescription": "Override Codex configuration directory.",
"geminiConfigDir": "Gemini Configuration Directory",
"geminiConfigDirDescription": "Override Gemini configuration directory (.env).",
"opencodeConfigDir": "OpenCode Configuration Directory",
"opencodeConfigDirDescription": "Override OpenCode configuration directory (opencode.json).",
"browsePlaceholderClaude": "e.g., /home/<your-username>/.claude",
"browsePlaceholderCodex": "e.g., /home/<your-username>/.codex",
"browsePlaceholderGemini": "e.g., /home/<your-username>/.gemini",
"browsePlaceholderOpencode": "e.g., /home/<your-username>/.config/opencode",
"browseDirectory": "Browse Directory",
"resetDefault": "Reset to default directory (takes effect after saving)",
"checkForUpdates": "Check for Updates",
@@ -376,21 +344,7 @@
"saved": "Proxy settings saved",
"saveFailed": "Save failed: {{error}}",
"testSuccess": "Connected! Latency {{latency}}ms",
"testFailed": "Connection failed: {{error}}",
"pricingDefaultsTitle": "Pricing Defaults",
"pricingDefaultsDescription": "Set the default multiplier and pricing model source per app.",
"pricingAppLabel": "App",
"defaultCostMultiplierLabel": "Default Multiplier",
"defaultCostMultiplierHint": "Multiplier for cost calculation, decimals supported.",
"pricingModelSourceLabel": "Pricing Model Source",
"pricingModelSourceRequest": "Request model",
"pricingModelSourceResponse": "Response model",
"pricingSave": "Save Pricing Defaults",
"pricingSaved": "Pricing defaults saved",
"pricingSaveFailed": "Failed to save pricing defaults: {{error}}",
"pricingLoadFailed": "Failed to load pricing defaults: {{error}}",
"defaultCostMultiplierRequired": "Default multiplier is required",
"defaultCostMultiplierInvalid": "Invalid multiplier format"
"testFailed": "Connection failed: {{error}}"
}
},
"apps": {
@@ -527,17 +481,12 @@
"proxyConfigDesc": "Configure separate network proxy for this provider. Uses system proxy or global settings when disabled.",
"proxyUsername": "Username (optional)",
"proxyPassword": "Password (optional)",
"pricingConfig": "Pricing Config",
"useCustomPricing": "Use separate config",
"pricingConfigDesc": "Configure separate pricing parameters for this provider. Uses global defaults when disabled.",
"costMultiplier": "Cost Multiplier",
"costMultiplierPlaceholder": "Leave empty to use global default (1)",
"costMultiplierHint": "Actual cost = Base cost × Multiplier, supports decimals like 1.5",
"pricingModelSourceLabel": "Pricing Mode",
"pricingModelSourceInherit": "Inherit global default",
"pricingModelSourceRequest": "Request model",
"pricingModelSourceResponse": "Response model",
"pricingModelSourceHint": "Choose whether to match pricing by request model or response model"
"formatTransform": "Format Transform",
"enableFormatTransform": "Enable Transform",
"formatTransformDesc": "Transform requests and responses between different API formats. Useful for providers using OpenAI-compatible interfaces.",
"sourceFormat": "Source Format (Client)",
"targetFormat": "Target Format (Upstream)",
"transformStreaming": "Transform Streaming Responses"
},
"codexConfig": {
"authJson": "auth.json (JSON) *",
@@ -640,9 +589,6 @@
"cacheCreationTokens": "Cache Creation",
"timingInfo": "Duration/TTFT",
"status": "Status",
"multiplier": "Multiplier",
"requestModel": "Request Model",
"responseModel": "Response Model",
"noData": "No data",
"unknownProvider": "Unknown Provider",
"stream": "Stream",
@@ -685,19 +631,7 @@
"input": "Input",
"output": "Output",
"cacheWrite": "Creation",
"cacheRead": "Hit",
"baseCost": "Base",
"costMultiplier": "Cost Multiplier",
"withMultiplier": "with multiplier",
"requestDetail": "Request Detail",
"requestNotFound": "Request not found",
"basicInfo": "Basic Info",
"tokenUsage": "Token Usage",
"cacheCreationCost": "Cache Creation Cost",
"costBreakdown": "Cost Breakdown",
"performance": "Performance",
"latency": "Latency",
"errorMessage": "Error Message"
"cacheRead": "Hit"
},
"usageScript": {
"title": "Configure Usage Query",
+8 -74
View File
@@ -265,8 +265,6 @@
"windowBehaviorHint": "最小化動作や Claude プラグイン連携を設定します。",
"launchOnStartup": "起動時に自動実行",
"launchOnStartupDescription": "システム起動時に CC Switch を自動起動します",
"silentStartup": "サイレント起動",
"silentStartupDescription": "起動時にメインウィンドウを表示せず、トレイのみで起動",
"autoLaunchFailed": "自動起動の設定に失敗しました",
"minimizeToTray": "閉じるときトレイへ最小化",
"minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。",
@@ -289,33 +287,6 @@
"copy": "ファイルコピー",
"symlinkHint": "シンボリックリンクはディスク容量を節約し、リアルタイム同期を有効にします。注意:Windowsでは管理者権限または開発者モードが必要な場合があります"
},
"terminal": {
"title": "優先ターミナル",
"description": "ターミナルボタンをクリックした時に使用するターミナルアプリを選択",
"fallbackHint": "選択したターミナルが利用できない場合、システムのデフォルトが使用されます",
"options": {
"macos": {
"terminal": "Terminal.app",
"iterm2": "iTerm2",
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty"
},
"windows": {
"cmd": "コマンドプロンプト",
"powershell": "PowerShell",
"wt": "Windows Terminal"
},
"linux": {
"gnomeTerminal": "GNOME Terminal",
"konsole": "Konsole",
"xfce4Terminal": "Xfce4 Terminal",
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty"
}
}
},
"configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
"appConfigDir": "CC Switch 設定ディレクトリ",
@@ -327,12 +298,9 @@
"codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。",
"geminiConfigDir": "Gemini 設定ディレクトリ",
"geminiConfigDirDescription": "Gemini の設定ディレクトリ(.env)を上書きします。",
"opencodeConfigDir": "OpenCode 設定ディレクトリ",
"opencodeConfigDirDescription": "OpenCode の設定ディレクトリ(opencode.json)を上書きします。",
"browsePlaceholderClaude": "例: /home/<your-username>/.claude",
"browsePlaceholderCodex": "例: /home/<your-username>/.codex",
"browsePlaceholderGemini": "例: /home/<your-username>/.gemini",
"browsePlaceholderOpencode": "例: /home/<your-username>/.config/opencode",
"browseDirectory": "ディレクトリを選択",
"resetDefault": "デフォルトに戻す(保存後に反映)",
"checkForUpdates": "アップデートを確認",
@@ -376,21 +344,7 @@
"saved": "プロキシ設定を保存しました",
"saveFailed": "保存に失敗しました: {{error}}",
"testSuccess": "接続成功!遅延 {{latency}}ms",
"testFailed": "接続に失敗しました: {{error}}",
"pricingDefaultsTitle": "課金のデフォルト設定",
"pricingDefaultsDescription": "アプリごとのデフォルト倍率と課金モードを設定します。",
"pricingAppLabel": "アプリ",
"defaultCostMultiplierLabel": "デフォルト倍率",
"defaultCostMultiplierHint": "コスト計算用の倍率(小数対応)。",
"pricingModelSourceLabel": "課金モード",
"pricingModelSourceRequest": "リクエストモデル",
"pricingModelSourceResponse": "レスポンスモデル",
"pricingSave": "課金設定を保存",
"pricingSaved": "課金設定を保存しました",
"pricingSaveFailed": "課金設定の保存に失敗しました: {{error}}",
"pricingLoadFailed": "課金設定の読み込みに失敗しました: {{error}}",
"defaultCostMultiplierRequired": "デフォルト倍率は必須です",
"defaultCostMultiplierInvalid": "デフォルト倍率の形式が正しくありません"
"testFailed": "接続に失敗しました: {{error}}"
}
},
"apps": {
@@ -527,17 +481,12 @@
"proxyConfigDesc": "このプロバイダーに個別のネットワークプロキシを設定します。無効の場合はシステムプロキシまたはグローバル設定を使用します。",
"proxyUsername": "ユーザー名(任意)",
"proxyPassword": "パスワード(任意)",
"pricingConfig": "課金設定",
"useCustomPricing": "個別設定を使用",
"pricingConfigDesc": "このプロバイダーに個別の課金パラメータを設定します。無効の場合はグローバル設定を使用します。",
"costMultiplier": "コスト倍率",
"costMultiplierPlaceholder": "空白の場合はグローバル設定を使用(1",
"costMultiplierHint": "実際のコスト = 基本コスト × 倍率、1.5 などの小数をサポート",
"pricingModelSourceLabel": "課金モード",
"pricingModelSourceInherit": "グローバル設定を継承",
"pricingModelSourceRequest": "リクエストモデル",
"pricingModelSourceResponse": "レスポンスモデル",
"pricingModelSourceHint": "リクエストモデルまたはレスポンスモデルで価格を照合するかを選択"
"formatTransform": "フォーマット変換",
"enableFormatTransform": "変換を有効化",
"formatTransformDesc": "リクエストとレスポンスを異なる API フォーマット間で変換します。OpenAI 互換インターフェースを使用するプロバイダーに適しています。",
"sourceFormat": "ソースフォーマット(クライアント)",
"targetFormat": "ターゲットフォーマット(上流",
"transformStreaming": "ストリーミングレスポンスを変換"
},
"codexConfig": {
"authJson": "auth.json (JSON) *",
@@ -640,9 +589,6 @@
"cacheCreationTokens": "キャッシュ作成",
"timingInfo": "応答時間/TTFT",
"status": "ステータス",
"multiplier": "倍率",
"requestModel": "リクエストモデル",
"responseModel": "レスポンスモデル",
"noData": "データなし",
"unknownProvider": "不明なプロバイダー",
"stream": "ストリーム",
@@ -685,19 +631,7 @@
"input": "Input",
"output": "Output",
"cacheWrite": "作成",
"cacheRead": "ヒット",
"baseCost": "基本",
"costMultiplier": "コスト倍率",
"withMultiplier": "倍率込み",
"requestDetail": "リクエスト詳細",
"requestNotFound": "リクエストが見つかりません",
"basicInfo": "基本情報",
"tokenUsage": "Token 使用量",
"cacheCreationCost": "キャッシュ作成コスト",
"costBreakdown": "コスト明細",
"performance": "パフォーマンス",
"latency": "レイテンシー",
"errorMessage": "エラーメッセージ"
"cacheRead": "ヒット"
},
"usageScript": {
"title": "利用状況を設定",
+8 -74
View File
@@ -265,8 +265,6 @@
"windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。",
"launchOnStartup": "开机自启",
"launchOnStartupDescription": "随系统启动自动运行 CC Switch",
"silentStartup": "静默启动",
"silentStartupDescription": "程序启动时不显示主窗口,仅在系统托盘运行",
"autoLaunchFailed": "设置开机自启失败",
"minimizeToTray": "关闭时最小化到托盘",
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
@@ -289,33 +287,6 @@
"copy": "文件复制",
"symlinkHint": "软连接节省磁盘空间并支持实时同步。注意:Windows 可能需要管理员权限或开启开发者模式"
},
"terminal": {
"title": "首选终端",
"description": "选择点击终端按钮时使用的终端应用",
"fallbackHint": "如果选择的终端不可用,将自动使用系统默认终端",
"options": {
"macos": {
"terminal": "Terminal.app",
"iterm2": "iTerm2",
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty"
},
"windows": {
"cmd": "命令提示符",
"powershell": "PowerShell",
"wt": "Windows Terminal"
},
"linux": {
"gnomeTerminal": "GNOME Terminal",
"konsole": "Konsole",
"xfce4Terminal": "Xfce4 Terminal",
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty"
}
}
},
"configDirectoryOverride": "配置目录覆盖(高级)",
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。",
"appConfigDir": "CC Switch 配置目录",
@@ -327,12 +298,9 @@
"codexConfigDirDescription": "覆盖 Codex 配置目录。",
"geminiConfigDir": "Gemini 配置目录",
"geminiConfigDirDescription": "覆盖 Gemini 配置目录 (.env)。",
"opencodeConfigDir": "OpenCode 配置目录",
"opencodeConfigDirDescription": "覆盖 OpenCode 配置目录 (opencode.json)。",
"browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude",
"browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex",
"browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini",
"browsePlaceholderOpencode": "例如:/home/<你的用户名>/.config/opencode",
"browseDirectory": "浏览目录",
"resetDefault": "恢复默认目录(需保存后生效)",
"checkForUpdates": "检查更新",
@@ -376,21 +344,7 @@
"saved": "代理设置已保存",
"saveFailed": "保存失败:{{error}}",
"testSuccess": "连接成功!延迟 {{latency}}ms",
"testFailed": "连接失败:{{error}}",
"pricingDefaultsTitle": "计费默认配置",
"pricingDefaultsDescription": "设置各应用的默认倍率与计费模式来源。",
"pricingAppLabel": "应用",
"defaultCostMultiplierLabel": "默认倍率",
"defaultCostMultiplierHint": "用于成本计算的倍率,支持小数。",
"pricingModelSourceLabel": "计费模式",
"pricingModelSourceRequest": "请求模型",
"pricingModelSourceResponse": "返回模型",
"pricingSave": "保存计费配置",
"pricingSaved": "计费配置已保存",
"pricingSaveFailed": "保存计费配置失败:{{error}}",
"pricingLoadFailed": "加载计费配置失败:{{error}}",
"defaultCostMultiplierRequired": "默认倍率不能为空",
"defaultCostMultiplierInvalid": "默认倍率格式不正确"
"testFailed": "连接失败:{{error}}"
}
},
"apps": {
@@ -527,17 +481,12 @@
"proxyConfigDesc": "为此供应商配置单独的网络代理,不启用时使用系统代理或全局设置。",
"proxyUsername": "用户名(可选)",
"proxyPassword": "密码(可选)",
"pricingConfig": "计费配置",
"useCustomPricing": "使用单独配置",
"pricingConfigDesc": "为此供应商配置单独的计费参数,不启用时使用全局默认配置。",
"costMultiplier": "成本倍率",
"costMultiplierPlaceholder": "留空使用全局默认(1",
"costMultiplierHint": "实际成本 = 基础成本 × 倍率,支持小数如 1.5",
"pricingModelSourceLabel": "计费模式",
"pricingModelSourceInherit": "继承全局默认",
"pricingModelSourceRequest": "请求模型",
"pricingModelSourceResponse": "返回模型",
"pricingModelSourceHint": "选择按请求模型还是返回模型进行定价匹配"
"formatTransform": "格式转换",
"enableFormatTransform": "启用转换",
"formatTransformDesc": "将请求和响应在不同 API 格式之间转换。适用于使用 OpenAI 兼容接口的供应商。",
"sourceFormat": "源格式(客户端)",
"targetFormat": "目标格式(上游",
"transformStreaming": "转换流式响应"
},
"codexConfig": {
"authJson": "auth.json (JSON) *",
@@ -640,9 +589,6 @@
"cacheCreationTokens": "缓存创建",
"timingInfo": "用时/首字",
"status": "状态",
"multiplier": "倍率",
"requestModel": "请求模型",
"responseModel": "返回模型",
"noData": "暂无数据",
"unknownProvider": "未知供应商",
"stream": "流",
@@ -685,19 +631,7 @@
"input": "Input",
"output": "Output",
"cacheWrite": "创建",
"cacheRead": "命中",
"baseCost": "基础",
"costMultiplier": "成本倍率",
"withMultiplier": "含倍率",
"requestDetail": "请求详情",
"requestNotFound": "请求未找到",
"basicInfo": "基本信息",
"tokenUsage": "Token 使用量",
"cacheCreationCost": "缓存写入成本",
"costBreakdown": "成本明细",
"performance": "性能信息",
"latency": "延迟",
"errorMessage": "错误信息"
"cacheRead": "命中"
},
"usageScript": {
"title": "配置用量查询",
-25
View File
@@ -92,29 +92,4 @@ export const proxyApi = {
async updateProxyConfigForApp(config: AppProxyConfig): Promise<void> {
return invoke("update_proxy_config_for_app", { config });
},
// ========== 计费默认配置 API ==========
// 获取默认成本倍率
async getDefaultCostMultiplier(appType: string): Promise<string> {
return invoke("get_default_cost_multiplier", { appType });
},
// 设置默认成本倍率
async setDefaultCostMultiplier(
appType: string,
value: string,
): Promise<void> {
return invoke("set_default_cost_multiplier", { appType, value });
},
// 获取计费模式来源
async getPricingModelSource(appType: string): Promise<string> {
return invoke("get_pricing_model_source", { appType });
},
// 设置计费模式来源
async setPricingModelSource(appType: string, value: string): Promise<void> {
return invoke("set_pricing_model_source", { appType, value });
},
};
+14 -13
View File
@@ -119,6 +119,18 @@ export interface ProviderProxyConfig {
proxyPassword?: string;
}
// 格式转换配置(用于 OpenRouter 等需要 API 格式转换的供应商)
export interface FormatTransformConfig {
// 是否启用格式转换
enabled: boolean;
// 源格式:anthropic, openai, gemini
sourceFormat?: "anthropic" | "openai" | "gemini";
// 目标格式:anthropic, openai, gemini
targetFormat?: "anthropic" | "openai" | "gemini";
// 是否转换流式响应(默认 true)
transformStreaming?: boolean;
}
// 供应商元数据(字段名与后端一致,保持 snake_case
export interface ProviderMeta {
// 自定义端点:以 URL 为键,值为端点信息
@@ -135,10 +147,8 @@ export interface ProviderMeta {
testConfig?: ProviderTestConfig;
// 供应商单独的代理配置
proxyConfig?: ProviderProxyConfig;
// 供应商成本倍率
costMultiplier?: string;
// 供应商计费模式来源
pricingModelSource?: string;
// 格式转换配置(用于 OpenRouter 等需要 API 格式转换的供应商)
formatTransform?: FormatTransformConfig;
}
// Skill 同步方式
@@ -166,8 +176,6 @@ export interface Settings {
skipClaudeOnboarding?: boolean;
// 是否开机自启
launchOnStartup?: boolean;
// 静默启动(程序启动时不显示主窗口)
silentStartup?: boolean;
// 首选语言(可选,默认中文)
language?: "en" | "zh" | "ja";
@@ -195,13 +203,6 @@ export interface Settings {
// ===== Skill 同步设置 =====
// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
skillSyncMethod?: SkillSyncMethod;
// ===== 终端设置 =====
// 首选终端应用(可选,默认使用系统默认终端)
// macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
// Windows: "cmd" | "powershell" | "wt"
// Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
preferredTerminal?: string;
}
// MCP 服务器连接参数(宽松:允许扩展字段)
-2
View File
@@ -13,8 +13,6 @@ export interface RequestLog {
providerName?: string;
appType: string;
model: string;
requestModel?: string;
costMultiplier: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
@@ -1,83 +0,0 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const mutateAsyncMock = vi.fn();
const testMutateAsyncMock = vi.fn();
const scanMutateAsyncMock = vi.fn();
vi.mock("@/hooks/useGlobalProxy", () => ({
useGlobalProxyUrl: () => ({ data: "http://127.0.0.1:7890", isLoading: false }),
useSetGlobalProxyUrl: () => ({
mutateAsync: mutateAsyncMock,
isPending: false,
}),
useTestProxy: () => ({
mutateAsync: testMutateAsyncMock,
isPending: false,
}),
useScanProxies: () => ({
mutateAsync: scanMutateAsyncMock,
isPending: false,
}),
}));
describe("GlobalProxySettings", () => {
beforeEach(() => {
mutateAsyncMock.mockReset();
testMutateAsyncMock.mockReset();
scanMutateAsyncMock.mockReset();
});
it("renders proxy URL input with saved value", async () => {
render(<GlobalProxySettings />);
const urlInput = screen.getByPlaceholderText(
"http://127.0.0.1:7890 / socks5://127.0.0.1:1080",
);
// URL 对象会在末尾添加斜杠
await waitFor(() =>
expect(urlInput).toHaveValue("http://127.0.0.1:7890/"),
);
});
it("saves proxy URL when save button is clicked", async () => {
render(<GlobalProxySettings />);
const urlInput = screen.getByPlaceholderText(
"http://127.0.0.1:7890 / socks5://127.0.0.1:1080",
);
fireEvent.change(urlInput, { target: { value: "http://localhost:8080" } });
const saveButton = screen.getByRole("button", { name: "common.save" });
fireEvent.click(saveButton);
await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled());
// 没有用户名时,URL 不经过 URL 对象解析,所以没有尾部斜杠
expect(mutateAsyncMock).toHaveBeenCalledWith("http://localhost:8080");
});
it("clears proxy URL when clear button is clicked", async () => {
render(<GlobalProxySettings />);
const urlInput = screen.getByPlaceholderText(
"http://127.0.0.1:7890 / socks5://127.0.0.1:1080",
);
// Wait for initial value to load
await waitFor(() =>
expect(urlInput).toHaveValue("http://127.0.0.1:7890/"),
);
// Click clear button
const clearButton = screen.getByTitle("settings.globalProxy.clear");
fireEvent.click(clearButton);
expect(urlInput).toHaveValue("");
});
});
+5 -16
View File
@@ -64,12 +64,9 @@ describe("useDirectorySettings", () => {
);
getAppConfigDirOverrideMock.mockResolvedValue(null);
getConfigDirMock.mockImplementation(async (app: string) => {
if (app === "claude") return "/remote/claude";
if (app === "codex") return "/remote/codex";
if (app === "gemini") return "/remote/gemini";
return "/remote/opencode";
});
getConfigDirMock.mockImplementation(async (app: string) =>
app === "claude" ? "/remote/claude" : "/remote/codex",
);
selectConfigDirectoryMock.mockReset();
});
@@ -87,8 +84,7 @@ describe("useDirectorySettings", () => {
appConfig: "/override/app",
claude: "/remote/claude",
codex: "/remote/codex",
gemini: "/remote/gemini",
opencode: "/remote/opencode",
gemini: "/remote/codex", // Gemini 使用 codex 作为默认
});
});
@@ -218,17 +214,10 @@ describe("useDirectorySettings", () => {
await waitFor(() => expect(result.current.isLoading).toBe(false));
act(() => {
result.current.resetAllDirectories(
"/server/claude",
"/server/codex",
"/server/gemini",
"/server/opencode",
);
result.current.resetAllDirectories("/server/claude", "/server/codex");
});
expect(result.current.resolvedDirs.claude).toBe("/server/claude");
expect(result.current.resolvedDirs.codex).toBe("/server/codex");
expect(result.current.resolvedDirs.gemini).toBe("/server/gemini");
expect(result.current.resolvedDirs.opencode).toBe("/server/opencode");
});
});
-1
View File
@@ -381,7 +381,6 @@ describe("useSettings hook", () => {
"/server/claude",
undefined,
undefined, // geminiConfigDir
undefined, // opencodeConfigDir
);
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
});
-26
View File
@@ -1,26 +0,0 @@
const storage = new Map<string, string>();
if (
typeof globalThis.localStorage === "undefined" ||
typeof globalThis.localStorage?.getItem !== "function"
) {
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, String(value));
},
removeItem: (key: string) => {
storage.delete(key);
},
clear: () => {
storage.clear();
},
key: (index: number) => Array.from(storage.keys())[index] ?? null,
get length() {
return storage.size;
},
},
configurable: true,
});
}
+1 -1
View File
@@ -11,7 +11,7 @@ export default defineConfig({
},
test: {
environment: "jsdom",
setupFiles: ["./tests/setupGlobals.ts", "./tests/setupTests.ts"],
setupFiles: ["./tests/setupTests.ts"],
globals: true,
coverage: {
reporter: ["text", "lcov"],