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() { fn main() {
tauri_build::build(); 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());
}
} }
-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", 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 { impl FromStr for AppType {
+9 -2
View File
@@ -2,14 +2,21 @@
use std::path::PathBuf; use std::path::PathBuf;
use crate::config::{ use crate::config::{
atomic_write, delete_file, get_home_dir, sanitize_provider_name, write_json_file, atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
write_text_file,
}; };
use crate::error::AppError; use crate::error::AppError;
use serde_json::Value; use serde_json::Value;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Codex 配置目录路径 /// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf { pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() { if let Some(custom) = crate::settings::get_codex_override_dir() {
+33 -197
View File
@@ -581,19 +581,18 @@ fn write_claude_config(
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}")) std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
} }
/// macOS: 根据用户首选终端启动 /// macOS: 使用 Terminal.app 启动
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> { fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("terminal");
let temp_dir = std::env::temp_dir(); let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id())); let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy(); 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!( let script_content = format!(
r#"#!/bin/bash r#"#!/bin/bash
trap 'rm -f "{config_path}" "{script_file}"' EXIT 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)) std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?; .map_err(|e| format!("设置脚本权限失败: {e}"))?;
// Try the preferred terminal first, fall back to Terminal.app if it fails // Simple AppleScript - just execute the script file
// 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;
let applescript = format!( let applescript = format!(
r#"tell application "Terminal" r#"tell application "Terminal"
activate activate
@@ -655,9 +627,12 @@ end tell"#,
.map_err(|e| format!("执行 osascript 失败: {e}"))?; .map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() { 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); let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!( return Err(format!(
"Terminal.app 执行失败 (exit code: {:?}): {}", "AppleScript 执行失败 (exit code: {:?}): {}",
output.status.code(), output.status.code(),
stderr stderr
)); ));
@@ -666,86 +641,13 @@ end tell"#,
Ok(()) Ok(())
} }
/// macOS: iTerm2 /// Linux: 尝试使用常见终端启动
#[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: 根据用户首选终端启动
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> { fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::process::Command; use std::process::Command;
let preferred = crate::settings::get_preferred_terminal(); let terminals = [
// Default terminal list with their arguments
let default_terminals = [
("gnome-terminal", vec!["--"]), ("gnome-terminal", vec!["--"]),
("konsole", vec!["-e"]), ("konsole", vec!["-e"]),
("xfce4-terminal", vec!["-e"]), ("xfce4-terminal", vec!["-e"]),
@@ -753,10 +655,9 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
("lxterminal", vec!["-e"]), ("lxterminal", vec!["-e"]),
("alacritty", vec!["-e"]), ("alacritty", vec!["-e"]),
("kitty", 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 temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id())); let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy(); 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)) std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?; .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("未找到可用的终端"); let mut last_error = String::from("未找到可用的终端");
for (terminal, args) in terminals_to_try { for (terminal, args) in terminals {
// Check if terminal exists in common paths // Check if terminal exists
let terminal_exists = std::path::Path::new(&format!("/usr/bin/{}", 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!("/bin/{}", terminal)).exists()
|| std::path::Path::new(&format!("/usr/local/bin/{}", terminal)).exists() {
|| which_command(terminal);
if terminal_exists {
let result = Command::new(terminal) let result = Command::new(terminal)
.args(&args) .args(&args)
.arg("bash") .arg("bash")
.arg(script_file.to_string_lossy().as_ref()) .arg(script_file.to_string_lossy().as_ref())
.spawn(); .output();
match result { 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) => { Err(e) => {
last_error = format!("执行 {} 失败: {}", terminal, e); last_error = format!("执行 {} 失败: {}", terminal, e);
} }
@@ -833,25 +711,13 @@ exec bash --norc --noprofile
Err(last_error) Err(last_error)
} }
/// Check if a command exists using `which` /// Windows: 创建临时批处理文件启动
#[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: 根据用户首选终端启动
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn launch_windows_terminal( fn launch_windows_terminal(
temp_dir: &std::path::Path, temp_dir: &std::path::Path,
config_file: &std::path::Path, config_file: &std::path::Path,
) -> Result<(), String> { ) -> Result<(), String> {
let preferred = crate::settings::get_preferred_terminal(); use std::process::Command;
let terminal = preferred.as_deref().unwrap_or("cmd");
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id())); 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('&', "^&"); 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 config_path_for_batch, config_path_for_batch, config_path_for_batch
); );
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?; 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);
// 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") let output = Command::new("cmd")
.args(&full_args) .args(["/C", "start", "cmd", "/K", &bat_file.to_string_lossy()])
.creation_flags(CREATE_NO_WINDOW) .creation_flags(CREATE_NO_WINDOW)
.output() .output()
.map_err(|e| format!("启动 {} 失败: {e}", terminal_name))?; .map_err(|e| format!("执行 cmd 失败: {e}"))?;
if !output.status.success() { 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); let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!( return Err(format!(
"{} 启动失败 (exit code: {:?}): {}", "启动 Windows 终端失败 (exit code: {:?}): {}",
terminal_name,
output.status.code(), output.status.code(),
stderr stderr
)); ));
-115
View File
@@ -2,7 +2,6 @@
//! //!
//! 提供前端调用的 API 接口 //! 提供前端调用的 API 接口
use crate::error::AppError;
use crate::proxy::types::*; use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats}; use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState; use crate::store::AppState;
@@ -120,120 +119,6 @@ pub async fn update_proxy_config_for_app(
.map_err(|e| e.to_string()) .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] #[tauri::command]
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> { 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; use crate::error::AppError;
/// 获取用户主目录,带回退和日志 /// 获取用户主目录,带回退和日志
/// fn get_home_dir() -> PathBuf {
/// 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);
}
}
dirs::home_dir().unwrap_or_else(|| { dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录"); log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".") 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() { if let Some(custom) = crate::app_store::get_app_config_dir_override() {
return custom; 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::error::AppError;
use crate::proxy::types::*; use crate::proxy::types::*;
use rust_decimal::Decimal;
use super::super::{lock_conn, Database}; use super::super::{lock_conn, Database};
@@ -76,117 +75,6 @@ impl Database {
Ok(()) 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( pub async fn get_proxy_config_for_app(
&self, &self,
@@ -289,90 +177,17 @@ impl Database {
Ok(()) 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 表的三行数据 /// 初始化 proxy_config 表的三行数据
///
/// 使用与 schema.rs seed 相同的 per-app 默认值
async fn init_proxy_config_rows(&self) -> Result<(), AppError> { async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
// 使用与 schema.rs seed 相同的 per-app 默认值 for app_type in &["claude", "codex", "gemini"] {
// claude: 更激进的重试和超时配置 conn.execute(
conn.execute( "INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
"INSERT OR IGNORE INTO proxy_config ( [app_type],
app_type, max_retries, )
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, .map_err(|e| AppError::Database(e.to_string()))?;
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()))?;
Ok(()) Ok(())
} }
@@ -847,58 +662,3 @@ impl Database {
Ok(()) 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 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 5; pub(crate) const SCHEMA_VERSION: i32 = 4;
/// 安全地序列化 JSON,避免 unwrap panic /// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> { 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_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_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, 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')) created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", []).map_err(|e| AppError::Database(e.to_string()))?; )", []).map_err(|e| AppError::Database(e.to_string()))?;
@@ -172,7 +170,6 @@ impl Database {
// 10. Proxy Request Logs 表 // 10. Proxy Request Logs 表
conn.execute("CREATE TABLE IF NOT EXISTS 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_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, 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, 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', 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::migrate_v3_to_v4(conn)?;
Self::set_user_version(conn, 4)?; 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!( return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -529,7 +521,6 @@ impl Database {
// proxy_request_logs 表 // proxy_request_logs 表
conn.execute("CREATE TABLE IF NOT EXISTS 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_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, 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, 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', 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_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_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, 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')) created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", [])?; )", [])?;
@@ -890,30 +879,6 @@ impl Database {
Ok(()) 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, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
+7 -67
View File
@@ -151,7 +151,7 @@ fn normalize_default(default: &Option<String>) -> Option<String> {
} }
#[test] #[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"); let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables"); Database::create_tables_on_conn(&conn).expect("create tables");
@@ -169,7 +169,7 @@ fn schema_migration_sets_user_version_when_missing() {
} }
#[test] #[test]
fn schema_migration_rejects_future_version() { fn migration_rejects_future_version() {
let conn = Connection::open_in_memory().expect("open memory db"); let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables"); Database::create_tables_on_conn(&conn).expect("create tables");
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version"); Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
@@ -183,7 +183,7 @@ fn schema_migration_rejects_future_version() {
} }
#[test] #[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"); let conn = Connection::open_in_memory().expect("open memory db");
// 创建旧版 providers 表,缺少新增列 // 创建旧版 providers 表,缺少新增列
@@ -224,7 +224,7 @@ fn schema_migration_adds_missing_columns_for_providers() {
} }
#[test] #[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"); let conn = Connection::open_in_memory().expect("open memory db");
conn.execute_batch(LEGACY_SCHEMA_SQL) conn.execute_batch(LEGACY_SCHEMA_SQL)
.expect("seed old schema"); .expect("seed old schema");
@@ -268,67 +268,7 @@ fn schema_migration_aligns_column_defaults_and_types() {
} }
#[test] #[test]
fn schema_create_tables_include_pricing_model_columns() { fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
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() {
let conn = Connection::open_in_memory().expect("open memory db"); let conn = Connection::open_in_memory().expect("open memory db");
// 模拟测试版 v2user_version=2,但 proxy_config 仍是单例结构(无 app_type // 模拟测试版 v2user_version=2,但 proxy_config 仍是单例结构(无 app_type
@@ -493,7 +433,7 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
} }
#[test] #[test]
fn schema_dry_run_does_not_write_to_disk() { fn dry_run_does_not_write_to_disk() {
// Create minimal valid config for migration // Create minimal valid config for migration
let mut apps = HashMap::new(); let mut apps = HashMap::new();
apps.insert("claude".to_string(), ProviderManager::default()); apps.insert("claude".to_string(), ProviderManager::default());
@@ -567,7 +507,7 @@ fn dry_run_validates_schema_compatibility() {
} }
#[test] #[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 db = Database::memory().expect("create memory db");
let conn = db.conn.lock().expect("lock conn"); 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 crate::error::AppError;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Gemini 配置目录路径(支持设置覆盖) /// 获取 Gemini 配置目录路径(支持设置覆盖)
pub fn get_gemini_dir() -> PathBuf { pub fn get_gemini_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_gemini_override_dir() { if let Some(custom) = crate::settings::get_gemini_override_dir() {
-22
View File
@@ -745,24 +745,6 @@ pub fn run() {
restore_proxy_state_on_startup(&state).await; 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(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
@@ -895,10 +877,6 @@ pub fn run() {
commands::update_global_proxy_config, commands::update_global_proxy_config,
commands::get_proxy_config_for_app, commands::get_proxy_config_for_app,
commands::update_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_proxy_running,
commands::is_live_takeover_active, commands::is_live_takeover_active,
commands::switch_proxy_provider, commands::switch_proxy_provider,
+20 -267
View File
@@ -191,6 +191,23 @@ pub struct ProviderProxyConfig {
pub proxy_password: Option<String>, 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)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta { pub struct ProviderMeta {
@@ -215,9 +232,6 @@ pub struct ProviderMeta {
/// 成本倍数(用于计算实际成本) /// 成本倍数(用于计算实际成本)
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")] #[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
pub cost_multiplier: Option<String>, pub cost_multiplier: Option<String>,
/// 计费模式来源(response/request
#[serde(rename = "pricingModelSource", skip_serializing_if = "Option::is_none")]
pub pricing_model_source: Option<String>,
/// 每日消费限额(USD /// 每日消费限额(USD
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")] #[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
pub limit_daily_usd: Option<String>, pub limit_daily_usd: Option<String>,
@@ -230,6 +244,9 @@ pub struct ProviderMeta {
/// 供应商单独的代理配置 /// 供应商单独的代理配置
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")] #[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
pub proxy_config: Option<ProviderProxyConfig>, pub proxy_config: Option<ProviderProxyConfig>,
/// 格式转换配置(用于 OpenRouter 等需要 API 格式转换的供应商)
#[serde(rename = "formatTransform", skip_serializing_if = "Option::is_none")]
pub format_transform: Option<FormatTransformConfig>,
} }
impl ProviderManager { impl ProviderManager {
@@ -617,267 +634,3 @@ pub struct OpenCodeModelLimit {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<u64>, 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, provider_router::ProviderRouter,
providers::{get_adapter, ProviderAdapter, ProviderType}, providers::{get_adapter, ProviderAdapter, ProviderType},
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature}, thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
transform::{get_transformer, TransformConfig},
types::{ProxyStatus, RectifierConfig}, types::{ProxyStatus, RectifierConfig},
ProxyError, ProxyError,
}; };
@@ -558,26 +559,63 @@ impl RequestForwarder {
// 使用适配器提取 base_url // 使用适配器提取 base_url
let base_url = adapter.extract_base_url(provider)?; 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" { let transformer = if needs_transform {
"/v1/chat/completions" let t = get_transformer(
} else { transform_config.source_format,
endpoint 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 // 使用适配器构建 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); 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 { let request_body = if let Some(ref t) = transformer {
adapter.transform_request(mapped_body, provider)? t.transform_request(mapped_body)?
} else { } else {
mapped_body mapped_body
}; };
+55 -24
View File
@@ -13,16 +13,18 @@ use super::{
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG, CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
}, },
handler_context::RequestContext, handler_context::RequestContext,
providers::{get_adapter, streaming::create_anthropic_sse_stream, transform},
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector}, response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
server::ProxyState, server::ProxyState,
transform::{get_transformer, TransformConfig},
types::*, types::*,
usage::parser::TokenUsage, usage::parser::TokenUsage,
ProxyError, ProxyError,
}; };
use crate::app_config::AppType; use crate::app_config::AppType;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use rust_decimal::Decimal;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::str::FromStr;
// ============================================================================ // ============================================================================
// 健康检查和状态查询(简单端点) // 健康检查和状态查询(简单端点)
@@ -92,13 +94,20 @@ pub async fn handle_messages(
ctx.provider = result.provider; ctx.provider = result.provider;
let response = result.response; let response = result.response;
// 检查是否需要格式转换(OpenRouter 等中转服务 // 检查是否需要格式转换(通过 Provider 配置
let adapter = get_adapter(&AppType::Claude); let transform_config = TransformConfig::from_provider(&ctx.provider);
let needs_transform = adapter.needs_transform(&ctx.provider);
// Claude 特有:格式转换处理 // Claude 特有:格式转换处理
if needs_transform { if transform_config.needs_transform() {
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await; return handle_claude_transform(
response,
&ctx,
&state,
&body,
is_stream,
&transform_config,
)
.await;
} }
// 通用响应处理(透传模式) // 通用响应处理(透传模式)
@@ -114,13 +123,26 @@ async fn handle_claude_transform(
state: &ProxyState, state: &ProxyState,
_original_body: &Value, _original_body: &Value,
is_stream: bool, is_stream: bool,
transform_config: &TransformConfig,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let status = response.status(); 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) // 流式响应转换 (OpenAI SSE → Anthropic SSE)
let stream = response.bytes_stream(); let stream = response.bytes_stream();
let sse_stream = create_anthropic_sse_stream(stream); let sse_stream = response_transformer.transform_stream(Box::pin(stream));
// 创建使用量收集器 // 创建使用量收集器
let usage_collector = { let usage_collector = {
@@ -143,7 +165,6 @@ async fn handle_claude_transform(
&provider_id, &provider_id,
"claude", "claude",
&model, &model,
&model,
usage, usage,
latency_ms, latency_ms,
first_token_ms, first_token_ms,
@@ -201,10 +222,12 @@ async fn handle_claude_transform(
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}")) ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
})?; })?;
let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| { let anthropic_response = response_transformer
log::error!("[Claude] 转换响应失败: {e}"); .transform_response(openai_response)
e .map_err(|e| {
})?; log::error!("[Claude] 转换响应失败: {e}");
e
})?;
// 记录使用量 // 记录使用量
if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) { if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {
@@ -214,7 +237,6 @@ async fn handle_claude_transform(
.unwrap_or("unknown"); .unwrap_or("unknown");
let latency_ms = ctx.latency_ms(); let latency_ms = ctx.latency_ms();
let request_model = ctx.request_model.clone();
tokio::spawn({ tokio::spawn({
let state = state.clone(); let state = state.clone();
let provider_id = ctx.provider.id.clone(); let provider_id = ctx.provider.id.clone();
@@ -225,7 +247,6 @@ async fn handle_claude_transform(
&provider_id, &provider_id,
"claude", "claude",
&model, &model,
&request_model,
usage, usage,
latency_ms, latency_ms,
None, None,
@@ -442,7 +463,6 @@ async fn log_usage(
provider_id: &str, provider_id: &str,
app_type: &str, app_type: &str,
model: &str, model: &str,
request_model: &str,
usage: TokenUsage, usage: TokenUsage,
latency_ms: u64, latency_ms: u64,
first_token_ms: Option<u64>, first_token_ms: Option<u64>,
@@ -453,12 +473,25 @@ async fn log_usage(
let logger = UsageLogger::new(&state.db); let logger = UsageLogger::new(&state.db);
let (multiplier, pricing_model_source) = // 获取 provider 的 cost_multiplier
logger.resolve_pricing_config(provider_id, app_type).await; let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
let pricing_model = if pricing_model_source == "request" { Ok(Some(p)) => {
request_model if let Some(meta) = p.meta {
} else { if let Some(cm) = meta.cost_multiplier {
model 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(); let request_id = uuid::Uuid::new_v4().to_string();
@@ -468,8 +501,6 @@ async fn log_usage(
provider_id.to_string(), provider_id.to_string(),
app_type.to_string(), app_type.to_string(),
model.to_string(), model.to_string(),
request_model.to_string(),
pricing_model.to_string(),
usage, usage,
multiplier, multiplier,
latency_ms, latency_ms,
+1
View File
@@ -22,6 +22,7 @@ pub mod response_processor;
pub(crate) mod server; pub(crate) mod server;
pub mod session; pub mod session;
pub mod thinking_rectifier; pub mod thinking_rectifier;
pub mod transform;
pub(crate) mod types; pub(crate) mod types;
pub mod usage; pub mod usage;
-46
View File
@@ -6,7 +6,6 @@ use super::auth::AuthInfo;
use crate::provider::Provider; use crate::provider::Provider;
use crate::proxy::error::ProxyError; use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder; use reqwest::RequestBuilder;
use serde_json::Value;
/// 供应商适配器 Trait /// 供应商适配器 Trait
/// ///
@@ -83,49 +82,4 @@ pub trait ProviderAdapter: Send + Sync {
/// # Returns /// # Returns
/// 添加了认证头的 RequestBuilder /// 添加了认证头的 RequestBuilder
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> 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 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 认证模式 /// 检测是否为仅 Bearer 认证模式
fn is_bearer_only_mode(&self, provider: &Provider) -> bool { fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
// 检查 settings_config 中的 auth_mode // 检查 settings_config 中的 auth_mode
@@ -252,27 +233,6 @@ impl ProviderAdapter for ClaudeAdapter {
_ => request, _ => 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)] #[cfg(test)]
@@ -454,41 +414,4 @@ mod tests {
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar"); let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
assert_eq!(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 base_trimmed = base_url.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_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}"); let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
// 去除重复的 /v1/v1 // 去除重复的 /v1/v1
@@ -231,6 +245,33 @@ mod tests {
assert_eq!(url, "https://www.packyapi.com/v1/responses"); 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] #[test]
fn test_is_official_client_vscode() { fn test_is_official_client_vscode() {
-2
View File
@@ -17,8 +17,6 @@ mod claude;
mod codex; mod codex;
mod gemini; mod gemini;
pub mod models; pub mod models;
pub mod streaming;
pub mod transform;
use crate::app_config::AppType; use crate::app_config::AppType;
use crate::provider::Provider; 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 bytes::Bytes;
use futures::stream::{Stream, StreamExt}; use futures::stream::{Stream, StreamExt};
use reqwest::header::HeaderMap; use reqwest::header::HeaderMap;
use rust_decimal::Decimal;
use serde_json::Value; use serde_json::Value;
use std::{ use std::{
str::FromStr,
sync::{ sync::{
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
Arc, Arc,
@@ -126,15 +128,7 @@ pub async fn handle_non_streaming(
ctx.request_model.clone() ctx.request_model.clone()
}; };
spawn_log_usage( spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false);
state,
ctx,
usage,
&model,
&ctx.request_model,
status.as_u16(),
false,
);
} else { } else {
let model = json_value let model = json_value
.get("model") .get("model")
@@ -146,7 +140,6 @@ pub async fn handle_non_streaming(
ctx, ctx,
TokenUsage::default(), TokenUsage::default(),
&model, &model,
&ctx.request_model,
status.as_u16(), status.as_u16(),
false, false,
); );
@@ -166,7 +159,6 @@ pub async fn handle_non_streaming(
ctx, ctx,
TokenUsage::default(), TokenUsage::default(),
&ctx.request_model, &ctx.request_model,
&ctx.request_model,
status.as_u16(), status.as_u16(),
false, false,
); );
@@ -301,7 +293,6 @@ fn create_usage_collector(
let state = state.clone(); let state = state.clone();
let provider_id = provider_id.clone(); let provider_id = provider_id.clone();
let session_id = session_id.clone(); let session_id = session_id.clone();
let request_model = request_model.clone();
tokio::spawn(async move { tokio::spawn(async move {
log_usage_internal( log_usage_internal(
@@ -309,7 +300,6 @@ fn create_usage_collector(
&provider_id, &provider_id,
app_type_str, app_type_str,
&model, &model,
&request_model,
usage, usage,
latency_ms, latency_ms,
first_token_ms, first_token_ms,
@@ -325,7 +315,6 @@ fn create_usage_collector(
let state = state.clone(); let state = state.clone();
let provider_id = provider_id.clone(); let provider_id = provider_id.clone();
let session_id = session_id.clone(); let session_id = session_id.clone();
let request_model = request_model.clone();
tokio::spawn(async move { tokio::spawn(async move {
log_usage_internal( log_usage_internal(
@@ -333,7 +322,6 @@ fn create_usage_collector(
&provider_id, &provider_id,
app_type_str, app_type_str,
&model, &model,
&request_model,
TokenUsage::default(), TokenUsage::default(),
latency_ms, latency_ms,
first_token_ms, first_token_ms,
@@ -354,7 +342,6 @@ fn spawn_log_usage(
ctx: &RequestContext, ctx: &RequestContext,
usage: TokenUsage, usage: TokenUsage,
model: &str, model: &str,
request_model: &str,
status_code: u16, status_code: u16,
is_streaming: bool, is_streaming: bool,
) { ) {
@@ -362,7 +349,6 @@ fn spawn_log_usage(
let provider_id = ctx.provider.id.clone(); let provider_id = ctx.provider.id.clone();
let app_type_str = ctx.app_type_str.to_string(); let app_type_str = ctx.app_type_str.to_string();
let model = model.to_string(); let model = model.to_string();
let request_model = request_model.to_string();
let latency_ms = ctx.latency_ms(); let latency_ms = ctx.latency_ms();
let session_id = ctx.session_id.clone(); let session_id = ctx.session_id.clone();
@@ -372,7 +358,6 @@ fn spawn_log_usage(
&provider_id, &provider_id,
&app_type_str, &app_type_str,
&model, &model,
&request_model,
usage, usage,
latency_ms, latency_ms,
None, None,
@@ -391,7 +376,6 @@ async fn log_usage_internal(
provider_id: &str, provider_id: &str,
app_type: &str, app_type: &str,
model: &str, model: &str,
request_model: &str,
usage: TokenUsage, usage: TokenUsage,
latency_ms: u64, latency_ms: u64,
first_token_ms: Option<u64>, first_token_ms: Option<u64>,
@@ -402,12 +386,26 @@ async fn log_usage_internal(
use super::usage::logger::UsageLogger; use super::usage::logger::UsageLogger;
let logger = UsageLogger::new(&state.db); let logger = UsageLogger::new(&state.db);
let (multiplier, pricing_model_source) =
logger.resolve_pricing_config(provider_id, app_type).await; // 获取 provider 的 cost_multiplier
let pricing_model = if pricing_model_source == "request" { let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
request_model Ok(Some(p)) => {
} else { if let Some(meta) = p.meta {
model 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(); let request_id = uuid::Uuid::new_v4().to_string();
@@ -426,8 +424,6 @@ async fn log_usage_internal(
provider_id.to_string(), provider_id.to_string(),
app_type.to_string(), app_type.to_string(),
model.to_string(), model.to_string(),
request_model.to_string(),
pricing_model.to_string(),
usage, usage,
multiplier, multiplier,
latency_ms, latency_ms,
@@ -560,185 +556,3 @@ fn format_headers(headers: &HeaderMap) -> String {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", ") .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 futures::stream::{Stream, StreamExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::collections::HashMap;
/// OpenAI 流式响应数据结构 /// OpenAI 流式响应数据结构
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -73,7 +74,8 @@ pub fn create_anthropic_sse_stream(
let mut content_index = 0; let mut content_index = 0;
let mut has_sent_message_start = false; let mut has_sent_message_start = false;
let mut current_block_type: Option<String> = None; 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); tokio::pin!(stream);
@@ -94,17 +96,17 @@ pub fn create_anthropic_sse_stream(
for l in line.lines() { for l in line.lines() {
if let Some(data) = l.strip_prefix("data: ") { if let Some(data) = l.strip_prefix("data: ") {
if data.trim() == "[DONE]" { if data.trim() == "[DONE]" {
log::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]"); log::debug!("[Transform] <<< OpenAI SSE: [DONE]");
let event = json!({"type": "message_stop"}); let event = json!({"type": "message_stop"});
let sse_data = format!("event: message_stop\ndata: {}\n\n", let sse_data = format!("event: message_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default()); serde_json::to_string(&event).unwrap_or_default());
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop"); log::debug!("[Transform] >>> Anthropic SSE: message_stop");
yield Ok(Bytes::from(sse_data)); yield Ok(Bytes::from(sse_data));
continue; continue;
} }
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) { if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
log::debug!("[Claude/OpenRouter] <<< SSE chunk received"); log::debug!("[Transform] <<< SSE chunk received");
if message_id.is_none() { if message_id.is_none() {
message_id = Some(chunk.id.clone()); 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 { if let Some(tool_calls) = &choice.delta.tool_calls {
for tool_call in tool_calls { for tool_call in tool_calls {
let tc_index = tool_call.index;
// 检查是否是新的工具调用(有 id 表示开始新的工具调用)
if let Some(id) = &tool_call.id { if let Some(id) = &tool_call.id {
// 关闭当前的 content block(如果有)
if current_block_type.is_some() { if current_block_type.is_some() {
let event = json!({ let event = json!({
"type": "content_block_stop", "type": "content_block_stop",
@@ -222,30 +228,44 @@ pub fn create_anthropic_sse_stream(
content_index += 1; 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 { if let Some(function) = &tool_call.function {
// 如果有 name,发送 content_block_start
if let Some(name) = &function.name { if let Some(name) = &function.name {
let event = json!({ let event = json!({
"type": "content_block_start", "type": "content_block_start",
"index": content_index, "index": tool_content_index,
"content_block": { "content_block": {
"type": "tool_use", "type": "tool_use",
"id": tool_call_id.clone().unwrap_or_default(), "id": tool_id,
"name": name "name": name
} }
}); });
let sse_data = format!("event: content_block_start\ndata: {}\n\n", let sse_data = format!("event: content_block_start\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default()); serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data)); yield Ok(Bytes::from(sse_data));
current_block_type = Some("tool_use".to_string());
} }
// 如果有 arguments,发送 content_block_delta
if let Some(args) = &function.arguments { if let Some(args) = &function.arguments {
let event = json!({ let event = json!({
"type": "content_block_delta", "type": "content_block_delta",
"index": content_index, "index": tool_content_index,
"delta": { "delta": {
"type": "input_json_delta", "type": "input_json_delta",
"partial_json": args "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) × 输入价格 /// - input_cost: (input_tokens - cache_read_tokens) × 输入价格
/// - cache_read_cost: cache_read_tokens × 缓存读取价格 /// - cache_read_cost: cache_read_tokens × 缓存读取价格
/// - 这样避免缓存部分被重复计费 /// - 这样避免缓存部分被重复计费
/// - total_cost: 各项成本之和 × 倍率(倍率只作用于最终总价)
pub fn calculate( pub fn calculate(
usage: &TokenUsage, usage: &TokenUsage,
pricing: &ModelPricing, pricing: &ModelPricing,
@@ -51,20 +50,21 @@ impl CostCalculator {
// 计算实际需要按输入价格计费的 token 数(减去缓存命中部分) // 计算实际需要按输入价格计费的 token 数(减去缓存命中部分)
let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens); 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
let input_cost = / million
Decimal::from(billable_input_tokens) * pricing.input_cost_per_million / million; * cost_multiplier;
let output_cost = let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million
Decimal::from(usage.output_tokens) * pricing.output_cost_per_million / million; / million
* cost_multiplier;
let cache_read_cost = 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) let cache_creation_cost = Decimal::from(usage.cache_creation_tokens)
* pricing.cache_creation_cost_per_million * pricing.cache_creation_cost_per_million
/ million; / million
* cost_multiplier;
// 总成本 = 各项基础成本之和 × 倍率 let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost;
let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost;
let total_cost = base_total * cost_multiplier;
CostBreakdown { CostBreakdown {
input_cost, input_cost,
@@ -151,9 +151,8 @@ mod tests {
let cost = CostCalculator::calculate(&usage, &pricing, multiplier); let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
// input_cost: 基础价格(不含倍率)= 1000 * 3.0 / 1M = 0.003 // input: 1000 * 3.0 / 1M * 1.5 = 0.0045
assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap()); assert_eq!(cost.input_cost, Decimal::from_str("0.0045").unwrap());
// total_cost: 基础价格 × 倍率 = 0.003 * 1.5 = 0.0045
assert_eq!(cost.total_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::error::AppError;
use crate::services::usage_stats::find_model_pricing_row; use crate::services::usage_stats::find_model_pricing_row;
use rust_decimal::Decimal; use rust_decimal::Decimal;
use std::{str::FromStr, time::SystemTime}; use std::time::SystemTime;
/// 请求日志 /// 请求日志
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -15,7 +15,6 @@ pub struct RequestLog {
pub provider_id: String, pub provider_id: String,
pub app_type: String, pub app_type: String,
pub model: String, pub model: String,
pub request_model: String,
pub usage: TokenUsage, pub usage: TokenUsage,
pub cost: Option<CostBreakdown>, pub cost: Option<CostBreakdown>,
pub latency_ms: u64, pub latency_ms: u64,
@@ -74,18 +73,17 @@ impl<'a> UsageLogger<'a> {
conn.execute( conn.execute(
"INSERT INTO proxy_request_logs ( "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_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, 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, latency_ms, first_token_ms, status_code, error_message, session_id,
provider_type, is_streaming, cost_multiplier, created_at 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![ rusqlite::params![
log.request_id, log.request_id,
log.provider_id, log.provider_id,
log.app_type, log.app_type,
log.model, log.model,
log.request_model,
log.usage.input_tokens, log.usage.input_tokens,
log.usage.output_tokens, log.usage.output_tokens,
log.usage.cache_read_tokens, log.usage.cache_read_tokens,
@@ -125,13 +123,11 @@ impl<'a> UsageLogger<'a> {
error_message: String, error_message: String,
latency_ms: u64, latency_ms: u64,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let request_model = model.clone();
let log = RequestLog { let log = RequestLog {
request_id, request_id,
provider_id, provider_id,
app_type, app_type,
model, model,
request_model,
usage: TokenUsage::default(), usage: TokenUsage::default(),
cost: None, cost: None,
latency_ms, latency_ms,
@@ -164,13 +160,11 @@ impl<'a> UsageLogger<'a> {
session_id: Option<String>, session_id: Option<String>,
provider_type: Option<String>, provider_type: Option<String>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let request_model = model.clone();
let log = RequestLog { let log = RequestLog {
request_id, request_id,
provider_id, provider_id,
app_type, app_type,
model, model,
request_model,
usage: TokenUsage::default(), usage: TokenUsage::default(),
cost: None, cost: None,
latency_ms, 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)] #[allow(clippy::too_many_arguments)]
pub fn log_with_calculation( pub fn log_with_calculation(
@@ -290,8 +202,6 @@ impl<'a> UsageLogger<'a> {
provider_id: String, provider_id: String,
app_type: String, app_type: String,
model: String, model: String,
request_model: String,
pricing_model: String,
usage: TokenUsage, usage: TokenUsage,
cost_multiplier: Decimal, cost_multiplier: Decimal,
latency_ms: u64, latency_ms: u64,
@@ -301,10 +211,10 @@ impl<'a> UsageLogger<'a> {
provider_type: Option<String>, provider_type: Option<String>,
is_streaming: bool, is_streaming: bool,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let pricing = self.get_model_pricing(&pricing_model)?; let pricing = self.get_model_pricing(&model)?;
if pricing.is_none() { 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); let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
@@ -314,7 +224,6 @@ impl<'a> UsageLogger<'a> {
provider_id, provider_id,
app_type, app_type,
model, model,
request_model,
usage, usage,
cost, cost,
latency_ms, latency_ms,
@@ -365,8 +274,6 @@ mod tests {
"provider-1".to_string(), "provider-1".to_string(),
"claude".to_string(), "claude".to_string(),
"test-model".to_string(), "test-model".to_string(),
"req-model".to_string(),
"test-model".to_string(),
usage, usage,
Decimal::from(1), Decimal::from(1),
100, 100,
@@ -379,15 +286,14 @@ mod tests {
// 验证记录已插入 // 验证记录已插入
let conn = crate::database::lock_conn!(db.conn); let conn = crate::database::lock_conn!(db.conn);
let (count, request_model): (i64, String) = conn let count: i64 = conn
.query_row( .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(); .unwrap();
assert_eq!(count, 1); assert_eq!(count, 1);
assert_eq!(request_model, "req-model");
Ok(()) Ok(())
} }
+13 -47
View File
@@ -182,67 +182,33 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
Ok(()) 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 /// Sync current provider to live configuration
/// ///
/// 使用有效的当前供应商 ID(验证过存在性)。 /// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。 /// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。 /// 这确保了配置导入后无效 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> { pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// Sync providers based on mode for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
for app_type in AppType::all() { // Use validated effective current provider
if app_type.is_additive_mode() { let current_id =
// Additive mode: sync ALL providers match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
sync_all_providers_to_live(state, &app_type)?; Some(id) => id,
} else { None => continue,
// 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,
};
let providers = state.db.get_all_providers(app_type.as_str())?; let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) { if let Some(provider) = providers.get(&current_id) {
write_live_snapshot(&app_type, provider)?; write_live_snapshot(&app_type, provider)?;
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
} }
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
} }
// MCP sync // MCP sync
McpService::sync_all_enabled(state)?; McpService::sync_all_enabled(state)?;
// Skill sync // 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) { if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}"); log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
// Continue syncing other apps, don't abort // Continue syncing other apps, don't abort
+43 -59
View File
@@ -94,9 +94,6 @@ pub struct RequestLogDetail {
pub provider_name: Option<String>, pub provider_name: Option<String>,
pub app_type: String, pub app_type: String,
pub model: 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 input_tokens: u32,
pub output_tokens: u32, pub output_tokens: u32,
pub cache_read_tokens: u32, pub cache_read_tokens: u32,
@@ -443,7 +440,6 @@ impl Database {
let sql = format!( let sql = format!(
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model, "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_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.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, l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
@@ -464,26 +460,22 @@ impl Database {
provider_name: row.get(2)?, provider_name: row.get(2)?,
app_type: row.get(3)?, app_type: row.get(3)?,
model: row.get(4)?, model: row.get(4)?,
request_model: row.get(5)?, input_tokens: row.get::<_, i64>(5)? as u32,
cost_multiplier: row output_tokens: row.get::<_, i64>(6)? as u32,
.get::<_, Option<String>>(6)? cache_read_tokens: row.get::<_, i64>(7)? as u32,
.unwrap_or_else(|| "1".to_string()), cache_creation_tokens: row.get::<_, i64>(8)? as u32,
input_tokens: row.get::<_, i64>(7)? as u32, input_cost_usd: row.get(9)?,
output_tokens: row.get::<_, i64>(8)? as u32, output_cost_usd: row.get(10)?,
cache_read_tokens: row.get::<_, i64>(9)? as u32, cache_read_cost_usd: row.get(11)?,
cache_creation_tokens: row.get::<_, i64>(10)? as u32, cache_creation_cost_usd: row.get(12)?,
input_cost_usd: row.get(11)?, total_cost_usd: row.get(13)?,
output_cost_usd: row.get(12)?, is_streaming: row.get::<_, i64>(14)? != 0,
cache_read_cost_usd: row.get(13)?, latency_ms: row.get::<_, i64>(15)? as u64,
cache_creation_cost_usd: row.get(14)?, first_token_ms: row.get::<_, Option<i64>>(16)?.map(|v| v as u64),
total_cost_usd: row.get(15)?, duration_ms: row.get::<_, Option<i64>>(17)?.map(|v| v as u64),
is_streaming: row.get::<_, i64>(16)? != 0, status_code: row.get::<_, i64>(18)? as u16,
latency_ms: row.get::<_, i64>(17)? as u64, error_message: row.get(19)?,
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64), created_at: row.get(20)?,
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)?,
}) })
})?; })?;
@@ -519,7 +511,6 @@ impl Database {
let result = conn.query_row( let result = conn.query_row(
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model, "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_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, 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, is_streaming, latency_ms, first_token_ms, duration_ms,
@@ -535,24 +526,22 @@ impl Database {
provider_name: row.get(2)?, provider_name: row.get(2)?,
app_type: row.get(3)?, app_type: row.get(3)?,
model: row.get(4)?, model: row.get(4)?,
request_model: row.get(5)?, input_tokens: row.get::<_, i64>(5)? as u32,
cost_multiplier: row.get::<_, Option<String>>(6)?.unwrap_or_else(|| "1".to_string()), output_tokens: row.get::<_, i64>(6)? as u32,
input_tokens: row.get::<_, i64>(7)? as u32, cache_read_tokens: row.get::<_, i64>(7)? as u32,
output_tokens: row.get::<_, i64>(8)? as u32, cache_creation_tokens: row.get::<_, i64>(8)? as u32,
cache_read_tokens: row.get::<_, i64>(9)? as u32, input_cost_usd: row.get(9)?,
cache_creation_tokens: row.get::<_, i64>(10)? as u32, output_cost_usd: row.get(10)?,
input_cost_usd: row.get(11)?, cache_read_cost_usd: row.get(11)?,
output_cost_usd: row.get(12)?, cache_creation_cost_usd: row.get(12)?,
cache_read_cost_usd: row.get(13)?, total_cost_usd: row.get(13)?,
cache_creation_cost_usd: row.get(14)?, is_streaming: row.get::<_, i64>(14)? != 0,
total_cost_usd: row.get(15)?, latency_ms: row.get::<_, i64>(15)? as u64,
is_streaming: row.get::<_, i64>(16)? != 0, first_token_ms: row.get::<_, Option<i64>>(16)?.map(|v| v as u64),
latency_ms: row.get::<_, i64>(17)? as u64, duration_ms: row.get::<_, Option<i64>>(17)?.map(|v| v as u64),
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64), status_code: row.get::<_, i64>(18)? as u16,
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64), error_message: row.get(19)?,
status_code: row.get::<_, i64>(20)? as u16, created_at: row.get(20)?,
error_message: row.get(21)?,
created_at: row.get(22)?,
}) })
}, },
); );
@@ -702,26 +691,21 @@ impl Database {
)?; )?;
let million = rust_decimal::Decimal::from(1_000_000u64); let million = rust_decimal::Decimal::from(1_000_000u64);
let input_cost = rust_decimal::Decimal::from(log.input_tokens as u64) * pricing.input
// 与 CostCalculator::calculate 保持一致的计算逻辑: / million
// 1. input_cost 需要扣除 cache_read_tokens(避免缓存部分被重复计费) * multiplier;
// 2. 各项成本是基础成本(不含倍率) let output_cost = rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output
// 3. 倍率只作用于最终总价 / million
let billable_input_tokens = * multiplier;
(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 cache_read_cost = rust_decimal::Decimal::from(log.cache_read_tokens as u64) let cache_read_cost = rust_decimal::Decimal::from(log.cache_read_tokens as u64)
* pricing.cache_read * pricing.cache_read
/ million; / million
* multiplier;
let cache_creation_cost = rust_decimal::Decimal::from(log.cache_creation_tokens as u64) let cache_creation_cost = rust_decimal::Decimal::from(log.cache_creation_tokens as u64)
* pricing.cache_creation * pricing.cache_creation
/ million; / million
// 总成本 = 基础成本之和 × 倍率 * multiplier;
let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost; let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost;
let total_cost = base_total * multiplier;
log.input_cost_usd = format!("{input_cost:.6}"); log.input_cost_usd = format!("{input_cost:.6}");
log.output_cost_usd = format!("{output_cost:.6}"); log.output_cost_usd = format!("{output_cost:.6}");
+1 -32
View File
@@ -79,9 +79,6 @@ pub struct AppSettings {
/// 是否开机自启 /// 是否开机自启
#[serde(default)] #[serde(default)]
pub launch_on_startup: bool, pub launch_on_startup: bool,
/// 静默启动(程序启动时不显示主窗口,仅托盘运行)
#[serde(default)]
pub silent_startup: bool,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>, pub language: Option<String>,
@@ -117,14 +114,6 @@ pub struct AppSettings {
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy /// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
#[serde(default)] #[serde(default)]
pub skill_sync_method: SyncMethod, 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 { fn default_show_in_tray() -> bool {
@@ -143,7 +132,6 @@ impl Default for AppSettings {
enable_claude_plugin_integration: false, enable_claude_plugin_integration: false,
skip_claude_onboarding: false, skip_claude_onboarding: false,
launch_on_startup: false, launch_on_startup: false,
silent_startup: false,
language: None, language: None,
visible_apps: None, visible_apps: None,
claude_config_dir: None, claude_config_dir: None,
@@ -155,7 +143,6 @@ impl Default for AppSettings {
current_provider_gemini: None, current_provider_gemini: None,
current_provider_opencode: None, current_provider_opencode: None,
skill_sync_method: SyncMethod::default(), skill_sync_method: SyncMethod::default(),
preferred_terminal: None,
} }
} }
} }
@@ -163,11 +150,7 @@ impl Default for AppSettings {
impl AppSettings { impl AppSettings {
fn settings_path() -> Option<PathBuf> { fn settings_path() -> Option<PathBuf> {
// settings.json 保留用于旧版本迁移和无数据库场景 // settings.json 保留用于旧版本迁移和无数据库场景
Some( dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json"))
crate::config::get_home_dir()
.join(".cc-switch")
.join("settings.json"),
)
} }
fn normalize_paths(&mut self) { fn normalize_paths(&mut self) {
@@ -419,17 +402,3 @@ pub fn get_skill_sync_method() -> SyncMethod {
}) })
.skill_sync_method .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"); let state = create_test_state().expect("create test state");
// Try to export to an invalid path (nonexistent parent or invalid name on Windows) // Try to export to an invalid path (parent directory doesn't exist)
let invalid_parent = if cfg!(windows) { let invalid_path = PathBuf::from("/nonexistent/directory/export.sql");
std::env::temp_dir().join("cc-switch-test-invalid<>dir")
} else {
PathBuf::from("/nonexistent/directory")
};
let invalid_path = invalid_parent.join("export.sql");
let err = state let err = state
.db .db
.export_sql(&invalid_path) .export_sql(&invalid_path)
.expect_err("export to invalid path should fail"); .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 // The error can be either IoContext or Io depending on where it fails
match err { match err {
@@ -994,8 +988,8 @@ fn export_sql_returns_error_for_invalid_path() {
} }
AppError::Io { path, .. } => { AppError::Io { path, .. } => {
assert!( assert!(
path.starts_with(invalid_prefix.as_ref()), path.starts_with("/nonexistent"),
"expected error for {invalid_parent:?}, got: {path:?}" "expected error for /nonexistent path, got: {path:?}"
); );
} }
other => panic!("expected IoContext or Io error, got {other:?}"), 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, ChevronRight,
FlaskConical, FlaskConical,
Globe, Globe,
Coins,
Eye, Eye,
EyeOff, EyeOff,
X, X,
ArrowLeftRight,
} from "lucide-react"; } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -22,23 +22,19 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ProviderTestConfig, ProviderProxyConfig } from "@/types"; import type {
ProviderTestConfig,
export type PricingModelSourceOption = "inherit" | "request" | "response"; ProviderProxyConfig,
FormatTransformConfig,
interface ProviderPricingConfig { } from "@/types";
enabled: boolean;
costMultiplier?: string;
pricingModelSource: PricingModelSourceOption;
}
interface ProviderAdvancedConfigProps { interface ProviderAdvancedConfigProps {
testConfig: ProviderTestConfig; testConfig: ProviderTestConfig;
proxyConfig: ProviderProxyConfig; proxyConfig: ProviderProxyConfig;
pricingConfig: ProviderPricingConfig; formatTransform?: FormatTransformConfig;
onTestConfigChange: (config: ProviderTestConfig) => void; onTestConfigChange: (config: ProviderTestConfig) => void;
onProxyConfigChange: (config: ProviderProxyConfig) => void; onProxyConfigChange: (config: ProviderProxyConfig) => void;
onPricingConfigChange: (config: ProviderPricingConfig) => void; onFormatTransformChange?: (config: FormatTransformConfig) => void;
} }
/** 从 ProviderProxyConfig 构建完整 URL */ /** 从 ProviderProxyConfig 构建完整 URL */
@@ -89,18 +85,18 @@ function parseProxyUrl(url: string): Partial<ProviderProxyConfig> {
export function ProviderAdvancedConfig({ export function ProviderAdvancedConfig({
testConfig, testConfig,
proxyConfig, proxyConfig,
pricingConfig, formatTransform,
onTestConfigChange, onTestConfigChange,
onProxyConfigChange, onProxyConfigChange,
onPricingConfigChange, onFormatTransformChange,
}: ProviderAdvancedConfigProps) { }: ProviderAdvancedConfigProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled); const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState( const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
proxyConfig.enabled, proxyConfig.enabled,
); );
const [isPricingConfigOpen, setIsPricingConfigOpen] = useState( const [isFormatTransformOpen, setIsFormatTransformOpen] = useState(
pricingConfig.enabled, formatTransform?.enabled ?? false,
); );
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
@@ -120,10 +116,10 @@ export function ProviderAdvancedConfig({
setIsProxyConfigOpen(proxyConfig.enabled); setIsProxyConfigOpen(proxyConfig.enabled);
}, [proxyConfig.enabled]); }, [proxyConfig.enabled]);
// 同步外部 pricingConfig.enabled 变化到展开状态 // 同步外部 formatTransform.enabled 变化到展开状态
useEffect(() => { useEffect(() => {
setIsPricingConfigOpen(pricingConfig.enabled); setIsFormatTransformOpen(formatTransform?.enabled ?? false);
}, [pricingConfig.enabled]); }, [formatTransform?.enabled]);
// 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据) // 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据)
useEffect(() => { useEffect(() => {
@@ -479,142 +475,134 @@ export function ProviderAdvancedConfig({
</div> </div>
</div> </div>
{/* 计费配置 */} {/* 格式转换配置 */}
<div className="rounded-lg border border-border/50 bg-muted/20"> {onFormatTransformChange && (
<button <div className="rounded-lg border border-border/50 bg-muted/20">
type="button" <button
className="flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors" type="button"
onClick={() => setIsPricingConfigOpen(!isPricingConfigOpen)} 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"> >
<Coins className="h-4 w-4 text-muted-foreground" /> <div className="flex items-center gap-3">
<span className="font-medium"> <ArrowLeftRight className="h-4 w-4 text-muted-foreground" />
{t("providerAdvanced.pricingConfig", { <span className="font-medium">
defaultValue: "计费配置", {t("providerAdvanced.formatTransform")}
})} </span>
</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);
}}
/>
</div> </div>
{isPricingConfigOpen ? ( <div className="flex items-center gap-3">
<ChevronDown className="h-4 w-4 text-muted-foreground" /> <div
) : ( className="flex items-center gap-2"
<ChevronRight className="h-4 w-4 text-muted-foreground" /> onClick={(e) => e.stopPropagation()}
)} >
</div> <Label
</button> htmlFor="format-transform-enabled"
<div className="text-sm text-muted-foreground"
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}
> >
<SelectTrigger id="pricing-model-source"> {t("providerAdvanced.enableFormatTransform")}
<SelectValue /> </Label>
</SelectTrigger> <Switch
<SelectContent> id="format-transform-enabled"
<SelectItem value="inherit"> checked={formatTransform?.enabled ?? false}
{t("providerAdvanced.pricingModelSourceInherit", { onCheckedChange={(checked) => {
defaultValue: "继承全局默认", onFormatTransformChange({
})} ...(formatTransform ?? { enabled: false }),
</SelectItem> enabled: checked,
<SelectItem value="request"> });
{t("providerAdvanced.pricingModelSourceRequest", { if (checked) setIsFormatTransformOpen(true);
defaultValue: "请求模型", }}
})} />
</SelectItem> </div>
<SelectItem value="response"> {isFormatTransformOpen ? (
{t("providerAdvanced.pricingModelSourceResponse", { <ChevronDown className="h-4 w-4 text-muted-foreground" />
defaultValue: "返回模型", ) : (
})} <ChevronRight className="h-4 w-4 text-muted-foreground" />
</SelectItem> )}
</SelectContent> </div>
</Select> </button>
<p className="text-xs text-muted-foreground"> <div
{t("providerAdvanced.pricingModelSourceHint", { className={cn(
defaultValue: "选择按请求模型还是返回模型进行定价匹配", "overflow-hidden transition-all duration-200",
})} isFormatTransformOpen
</p> ? "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>
</div> </div>
</div> )}
</div> </div>
); );
} }
@@ -13,6 +13,7 @@ import type {
ProviderMeta, ProviderMeta,
ProviderTestConfig, ProviderTestConfig,
ProviderProxyConfig, ProviderProxyConfig,
FormatTransformConfig,
} from "@/types"; } from "@/types";
import { import {
providerPresets, providerPresets,
@@ -46,10 +47,7 @@ import { BasicFormFields } from "./BasicFormFields";
import { ClaudeFormFields } from "./ClaudeFormFields"; import { ClaudeFormFields } from "./ClaudeFormFields";
import { CodexFormFields } from "./CodexFormFields"; import { CodexFormFields } from "./CodexFormFields";
import { GeminiFormFields } from "./GeminiFormFields"; import { GeminiFormFields } from "./GeminiFormFields";
import { import { ProviderAdvancedConfig } from "./ProviderAdvancedConfig";
ProviderAdvancedConfig,
type PricingModelSourceOption,
} from "./ProviderAdvancedConfig";
import { import {
useProviderCategory, useProviderCategory,
useApiKeyState, useApiKeyState,
@@ -124,9 +122,6 @@ interface ProviderFormProps {
showButtons?: boolean; showButtons?: boolean;
} }
const normalizePricingSource = (value?: string): PricingModelSourceOption =>
value === "request" || value === "response" ? value : "inherit";
export function ProviderForm({ export function ProviderForm({
appId, appId,
providerId, providerId,
@@ -174,19 +169,9 @@ export function ProviderForm({
const [proxyConfig, setProxyConfig] = useState<ProviderProxyConfig>( const [proxyConfig, setProxyConfig] = useState<ProviderProxyConfig>(
() => initialData?.meta?.proxyConfig ?? { enabled: false }, () => initialData?.meta?.proxyConfig ?? { enabled: false },
); );
const [pricingConfig, setPricingConfig] = useState<{ const [formatTransform, setFormatTransform] = useState<FormatTransformConfig>(
enabled: boolean; () => initialData?.meta?.formatTransform ?? { enabled: false },
costMultiplier?: string; );
pricingModelSource: PricingModelSourceOption;
}>(() => ({
enabled:
initialData?.meta?.costMultiplier !== undefined ||
initialData?.meta?.pricingModelSource !== undefined,
costMultiplier: initialData?.meta?.costMultiplier,
pricingModelSource: normalizePricingSource(
initialData?.meta?.pricingModelSource,
),
}));
// 使用 category hook // 使用 category hook
const { category } = useProviderCategory({ const { category } = useProviderCategory({
@@ -207,15 +192,6 @@ export function ProviderForm({
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true); setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false }); setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
setProxyConfig(initialData?.meta?.proxyConfig ?? { 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]); }, [appId, initialData]);
const defaultValues: ProviderFormData = useMemo( const defaultValues: ProviderFormData = useMemo(
@@ -968,13 +944,7 @@ export function ProviderForm({
// 添加高级配置 // 添加高级配置
testConfig: testConfig.enabled ? testConfig : undefined, testConfig: testConfig.enabled ? testConfig : undefined,
proxyConfig: proxyConfig.enabled ? proxyConfig : undefined, proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,
costMultiplier: pricingConfig.enabled formatTransform: formatTransform.enabled ? formatTransform : undefined,
? pricingConfig.costMultiplier
: undefined,
pricingModelSource:
pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit"
? pricingConfig.pricingModelSource
: undefined,
}; };
onSubmit(payload); onSubmit(payload);
@@ -1499,10 +1469,10 @@ export function ProviderForm({
<ProviderAdvancedConfig <ProviderAdvancedConfig
testConfig={testConfig} testConfig={testConfig}
proxyConfig={proxyConfig} proxyConfig={proxyConfig}
pricingConfig={pricingConfig} formatTransform={formatTransform}
onTestConfigChange={setTestConfig} onTestConfigChange={setTestConfig}
onProxyConfigChange={setProxyConfig} onProxyConfigChange={setProxyConfig}
onPricingConfigChange={setPricingConfig} onFormatTransformChange={setFormatTransform}
/> />
{showButtons && ( {showButtons && (
@@ -15,7 +15,6 @@ interface DirectorySettingsProps {
claudeDir?: string; claudeDir?: string;
codexDir?: string; codexDir?: string;
geminiDir?: string; geminiDir?: string;
opencodeDir?: string;
onDirectoryChange: (app: AppId, value?: string) => void; onDirectoryChange: (app: AppId, value?: string) => void;
onBrowseDirectory: (app: AppId) => Promise<void>; onBrowseDirectory: (app: AppId) => Promise<void>;
onResetDirectory: (app: AppId) => Promise<void>; onResetDirectory: (app: AppId) => Promise<void>;
@@ -30,7 +29,6 @@ export function DirectorySettings({
claudeDir, claudeDir,
codexDir, codexDir,
geminiDir, geminiDir,
opencodeDir,
onDirectoryChange, onDirectoryChange,
onBrowseDirectory, onBrowseDirectory,
onResetDirectory, onResetDirectory,
@@ -119,17 +117,6 @@ export function DirectorySettings({
onBrowse={() => onBrowseDirectory("gemini")} onBrowse={() => onBrowseDirectory("gemini")}
onReset={() => onResetDirectory("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> </section>
</> </>
); );
-8
View File
@@ -36,7 +36,6 @@ import { ThemeSettings } from "@/components/settings/ThemeSettings";
import { WindowSettings } from "@/components/settings/WindowSettings"; import { WindowSettings } from "@/components/settings/WindowSettings";
import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings"; import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings";
import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings"; import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings";
import { TerminalSettings } from "@/components/settings/TerminalSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings"; import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection"; import { ImportExportSection } from "@/components/settings/ImportExportSection";
import { AboutSection } from "@/components/settings/AboutSection"; import { AboutSection } from "@/components/settings/AboutSection";
@@ -257,12 +256,6 @@ export function SettingsPage({
handleAutoSave({ skillSyncMethod: method }) handleAutoSave({ skillSyncMethod: method })
} }
/> />
<TerminalSettings
value={settings.preferredTerminal}
onChange={(terminal) =>
handleAutoSave({ preferredTerminal: terminal })
}
/>
</motion.div> </motion.div>
) : null} ) : null}
</TabsContent> </TabsContent>
@@ -307,7 +300,6 @@ export function SettingsPage({
claudeDir={settings.claudeConfigDir} claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir} codexDir={settings.codexConfigDir}
geminiDir={settings.geminiConfigDir} geminiDir={settings.geminiConfigDir}
opencodeDir={settings.opencodeConfigDir}
onDirectoryChange={updateDirectory} onDirectoryChange={updateDirectory}
onBrowseDirectory={browseDirectory} onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory} 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 { useTranslation } from "react-i18next";
import type { SettingsFormState } from "@/hooks/useSettings"; 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"; import { ToggleRow } from "@/components/ui/toggle-row";
interface WindowSettingsProps { interface WindowSettingsProps {
@@ -27,14 +27,6 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
onCheckedChange={(value) => onChange({ launchOnStartup: value })} 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 <ToggleRow
icon={<MonitorUp className="h-4 w-4 text-purple-500" />} icon={<MonitorUp className="h-4 w-4 text-purple-500" />}
title={t("settings.enableClaudePluginIntegration")} 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 { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Table, Table,
TableBody, TableBody,
@@ -18,31 +19,10 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } 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 { useModelPricing, useDeleteModelPricing } from "@/lib/query/usage";
import { PricingEditModal } from "./PricingEditModal"; import { PricingEditModal } from "./PricingEditModal";
import type { ModelPricing } from "@/types/usage"; import type { ModelPricing } from "@/types/usage";
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react"; import { Plus, Pencil, Trash2, ChevronDown, ChevronRight } 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>;
export function PricingConfigPanel() { export function PricingConfigPanel() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -51,137 +31,13 @@ export function PricingConfigPanel() {
const [editingModel, setEditingModel] = useState<ModelPricing | null>(null); const [editingModel, setEditingModel] = useState<ModelPricing | null>(null);
const [isAddingNew, setIsAddingNew] = useState(false); const [isAddingNew, setIsAddingNew] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
// 三个应用的配置状态
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 handleDelete = (modelId: string) => { const handleDelete = (modelId: string) => {
deleteMutation.mutate(modelId, { deleteMutation.mutate(modelId, {
onSuccess: () => setDeleteConfirm(null), onSuccess: () => {
setDeleteConfirm(null);
},
}); });
}; };
@@ -199,240 +55,149 @@ export function PricingConfigPanel() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center p-4"> <Card className="border rounded-lg">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> <CardHeader
</div> 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) { if (error) {
return ( return (
<Alert variant="destructive"> <Card className="border rounded-lg">
<AlertDescription> <CardHeader
{t("usage.loadPricingError")}: {String(error)} className="cursor-pointer"
</AlertDescription> onClick={() => setIsExpanded(!isExpanded)}
</Alert> >
<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 ( return (
<div className="space-y-6"> <div className="space-y-4">
{/* 全局计费默认配置 - 紧凑表格布局 */} <div className="flex items-center justify-between mb-4">
<div className="space-y-2"> <h4 className="text-sm font-medium text-muted-foreground">
<div className="flex items-center justify-between"> {t("usage.modelPricingDesc")} {t("usage.perMillion")}
<div> </h4>
<h4 className="text-sm font-medium"> <Button
{t("settings.globalProxy.pricingDefaultsTitle")} onClick={(e) => {
</h4> e.stopPropagation();
<p className="text-xs text-muted-foreground"> handleAddNew();
{t("settings.globalProxy.pricingDefaultsDescription")} }}
</p> size="sm"
</div> >
<Button <Plus className="mr-1 h-4 w-4" />
onClick={handleSaveAll} {t("common.add")}
disabled={isConfigLoading || isSaving || !isDirty} </Button>
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> </div>
{/* 分隔线 */}
<div className="border-t border-border/50" />
{/* 模型定价配置 */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> {!pricing || pricing.length === 0 ? (
<h4 className="text-sm font-medium text-muted-foreground"> <Alert>
{t("usage.modelPricingDesc")} {t("usage.perMillion")} <AlertDescription>{t("usage.noPricingData")}</AlertDescription>
</h4> </Alert>
<Button ) : (
onClick={(e) => { <div className="rounded-md bg-card/60 shadow-sm">
e.stopPropagation(); <Table>
handleAddNew(); <TableHeader>
}} <TableRow>
size="sm" <TableHead>{t("usage.model")}</TableHead>
> <TableHead>{t("usage.displayName")}</TableHead>
<Plus className="mr-1 h-4 w-4" /> <TableHead className="text-right">
{t("common.add")} {t("usage.inputCost")}
</Button> </TableHead>
</div> <TableHead className="text-right">
{t("usage.outputCost")}
<div className="space-y-4"> </TableHead>
{!pricing || pricing.length === 0 ? ( <TableHead className="text-right">
<Alert> {t("usage.cacheReadCost")}
<AlertDescription>{t("usage.noPricingData")}</AlertDescription> </TableHead>
</Alert> <TableHead className="text-right">
) : ( {t("usage.cacheWriteCost")}
<div className="rounded-md bg-card/60 shadow-sm"> </TableHead>
<Table> <TableHead className="text-right">
<TableHeader> {t("common.actions")}
<TableRow> </TableHead>
<TableHead>{t("usage.model")}</TableHead> </TableRow>
<TableHead>{t("usage.displayName")}</TableHead> </TableHeader>
<TableHead className="text-right"> <TableBody>
{t("usage.inputCost")} {pricing.map((model) => (
</TableHead> <TableRow key={model.modelId}>
<TableHead className="text-right"> <TableCell className="font-mono text-sm">
{t("usage.outputCost")} {model.modelId}
</TableHead> </TableCell>
<TableHead className="text-right"> <TableCell>{model.displayName}</TableCell>
{t("usage.cacheReadCost")} <TableCell className="text-right font-mono text-sm">
</TableHead> ${model.inputCostPerMillion}
<TableHead className="text-right"> </TableCell>
{t("usage.cacheWriteCost")} <TableCell className="text-right font-mono text-sm">
</TableHead> ${model.outputCostPerMillion}
<TableHead className="text-right"> </TableCell>
{t("common.actions")} <TableCell className="text-right font-mono text-sm">
</TableHead> ${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> </TableRow>
</TableHeader> ))}
<TableBody> </TableBody>
{pricing.map((model) => ( </Table>
<TableRow key={model.modelId}> </div>
<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>
</div> </div>
{editingModel && ( {editingModel && (
+1 -31
View File
@@ -184,9 +184,6 @@ export function RequestDetailPanel({
<div> <div>
<dt className="text-muted-foreground"> <dt className="text-muted-foreground">
{t("usage.inputCost", "输入成本")} {t("usage.inputCost", "输入成本")}
<span className="ml-1 text-xs">
({t("usage.baseCost", "基础")})
</span>
</dt> </dt>
<dd className="font-mono"> <dd className="font-mono">
${parseFloat(request.inputCostUsd).toFixed(6)} ${parseFloat(request.inputCostUsd).toFixed(6)}
@@ -195,9 +192,6 @@ export function RequestDetailPanel({
<div> <div>
<dt className="text-muted-foreground"> <dt className="text-muted-foreground">
{t("usage.outputCost", "输出成本")} {t("usage.outputCost", "输出成本")}
<span className="ml-1 text-xs">
({t("usage.baseCost", "基础")})
</span>
</dt> </dt>
<dd className="font-mono"> <dd className="font-mono">
${parseFloat(request.outputCostUsd).toFixed(6)} ${parseFloat(request.outputCostUsd).toFixed(6)}
@@ -206,9 +200,6 @@ export function RequestDetailPanel({
<div> <div>
<dt className="text-muted-foreground"> <dt className="text-muted-foreground">
{t("usage.cacheReadCost", "缓存读取成本")} {t("usage.cacheReadCost", "缓存读取成本")}
<span className="ml-1 text-xs">
({t("usage.baseCost", "基础")})
</span>
</dt> </dt>
<dd className="font-mono"> <dd className="font-mono">
${parseFloat(request.cacheReadCostUsd).toFixed(6)} ${parseFloat(request.cacheReadCostUsd).toFixed(6)}
@@ -217,35 +208,14 @@ export function RequestDetailPanel({
<div> <div>
<dt className="text-muted-foreground"> <dt className="text-muted-foreground">
{t("usage.cacheCreationCost", "缓存写入成本")} {t("usage.cacheCreationCost", "缓存写入成本")}
<span className="ml-1 text-xs">
({t("usage.baseCost", "基础")})
</span>
</dt> </dt>
<dd className="font-mono"> <dd className="font-mono">
${parseFloat(request.cacheCreationCostUsd).toFixed(6)} ${parseFloat(request.cacheCreationCostUsd).toFixed(6)}
</dd> </dd>
</div> </div>
{/* 显示成本倍率(如果不等于1) */} <div className="col-span-2 border-t pt-3">
{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`}
>
<dt className="text-muted-foreground"> <dt className="text-muted-foreground">
{t("usage.totalCost", "总成本")} {t("usage.totalCost", "总成本")}
{request.costMultiplier &&
parseFloat(request.costMultiplier) !== 1 && (
<span className="ml-1 text-xs">
({t("usage.withMultiplier", "含倍率")})
</span>
)}
</dt> </dt>
<dd className="text-lg font-semibold text-primary"> <dd className="text-lg font-semibold text-primary">
${parseFloat(request.totalCostUsd).toFixed(6)} ${parseFloat(request.totalCostUsd).toFixed(6)}
+7 -33
View File
@@ -250,7 +250,7 @@ export function RequestLogTable() {
<TableHead className="whitespace-nowrap"> <TableHead className="whitespace-nowrap">
{t("usage.provider")} {t("usage.provider")}
</TableHead> </TableHead>
<TableHead className="min-w-[200px] whitespace-nowrap"> <TableHead className="min-w-[280px] whitespace-nowrap">
{t("usage.billingModel")} {t("usage.billingModel")}
</TableHead> </TableHead>
<TableHead className="text-right whitespace-nowrap"> <TableHead className="text-right whitespace-nowrap">
@@ -265,9 +265,6 @@ export function RequestLogTable() {
<TableHead className="text-right min-w-[90px] whitespace-nowrap"> <TableHead className="text-right min-w-[90px] whitespace-nowrap">
{t("usage.cacheCreationTokens")} {t("usage.cacheCreationTokens")}
</TableHead> </TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("usage.multiplier")}
</TableHead>
<TableHead className="text-right whitespace-nowrap"> <TableHead className="text-right whitespace-nowrap">
{t("usage.totalCost")} {t("usage.totalCost")}
</TableHead> </TableHead>
@@ -283,7 +280,7 @@ export function RequestLogTable() {
{logs.length === 0 ? ( {logs.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={11} colSpan={10}
className="text-center text-muted-foreground" className="text-center text-muted-foreground"
> >
{t("usage.noData")} {t("usage.noData")}
@@ -300,25 +297,11 @@ export function RequestLogTable() {
<TableCell> <TableCell>
{log.providerName || t("usage.unknownProvider")} {log.providerName || t("usage.unknownProvider")}
</TableCell> </TableCell>
<TableCell className="font-mono text-xs max-w-[200px]"> <TableCell
<div className="font-mono text-sm max-w-[280px] truncate"
className="truncate" title={log.model}
title={ >
log.requestModel && log.requestModel !== log.model {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> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{log.inputTokens.toLocaleString()} {log.inputTokens.toLocaleString()}
@@ -332,15 +315,6 @@ export function RequestLogTable() {
<TableCell className="text-right"> <TableCell className="text-right">
{log.cacheCreationTokens.toLocaleString()} {log.cacheCreationTokens.toLocaleString()}
</TableCell> </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"> <TableCell className="text-right">
${parseFloat(log.totalCostUsd).toFixed(6)} ${parseFloat(log.totalCostUsd).toFixed(6)}
</TableCell> </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 { settingsApi, type AppId } from "@/lib/api";
import type { SettingsFormState } from "./useSettingsForm"; import type { SettingsFormState } from "./useSettingsForm";
type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini" | "opencode"; type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini";
export interface ResolvedDirectories { export interface ResolvedDirectories {
appConfig: string; appConfig: string;
claude: string; claude: string;
codex: string; codex: string;
gemini: string; gemini: string;
opencode: string;
} }
const sanitizeDir = (value?: string | null): string | undefined => { const sanitizeDir = (value?: string | null): string | undefined => {
@@ -40,13 +39,7 @@ const computeDefaultConfigDir = async (
try { try {
const home = await homeDir(); const home = await homeDir();
const folder = const folder =
app === "claude" app === "claude" ? ".claude" : app === "codex" ? ".codex" : ".gemini";
? ".claude"
: app === "codex"
? ".codex"
: app === "gemini"
? ".gemini"
: ".config/opencode";
return await join(home, folder); return await join(home, folder);
} catch (error) { } catch (error) {
console.error( console.error(
@@ -77,7 +70,6 @@ export interface UseDirectorySettingsResult {
claudeDir?: string, claudeDir?: string,
codexDir?: string, codexDir?: string,
geminiDir?: string, geminiDir?: string,
opencodeDir?: string,
) => void; ) => void;
} }
@@ -104,7 +96,6 @@ export function useDirectorySettings({
claude: "", claude: "",
codex: "", codex: "",
gemini: "", gemini: "",
opencode: "",
}); });
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -113,7 +104,6 @@ export function useDirectorySettings({
claude: "", claude: "",
codex: "", codex: "",
gemini: "", gemini: "",
opencode: "",
}); });
const initialAppConfigDirRef = useRef<string | undefined>(undefined); const initialAppConfigDirRef = useRef<string | undefined>(undefined);
@@ -129,23 +119,19 @@ export function useDirectorySettings({
claudeDir, claudeDir,
codexDir, codexDir,
geminiDir, geminiDir,
opencodeDir,
defaultAppConfig, defaultAppConfig,
defaultClaudeDir, defaultClaudeDir,
defaultCodexDir, defaultCodexDir,
defaultGeminiDir, defaultGeminiDir,
defaultOpencodeDir,
] = await Promise.all([ ] = await Promise.all([
settingsApi.getAppConfigDirOverride(), settingsApi.getAppConfigDirOverride(),
settingsApi.getConfigDir("claude"), settingsApi.getConfigDir("claude"),
settingsApi.getConfigDir("codex"), settingsApi.getConfigDir("codex"),
settingsApi.getConfigDir("gemini"), settingsApi.getConfigDir("gemini"),
settingsApi.getConfigDir("opencode"),
computeDefaultAppConfigDir(), computeDefaultAppConfigDir(),
computeDefaultConfigDir("claude"), computeDefaultConfigDir("claude"),
computeDefaultConfigDir("codex"), computeDefaultConfigDir("codex"),
computeDefaultConfigDir("gemini"), computeDefaultConfigDir("gemini"),
computeDefaultConfigDir("opencode"),
]); ]);
if (!active) return; if (!active) return;
@@ -157,7 +143,6 @@ export function useDirectorySettings({
claude: defaultClaudeDir ?? "", claude: defaultClaudeDir ?? "",
codex: defaultCodexDir ?? "", codex: defaultCodexDir ?? "",
gemini: defaultGeminiDir ?? "", gemini: defaultGeminiDir ?? "",
opencode: defaultOpencodeDir ?? "",
}; };
setAppConfigDir(normalizedOverride); setAppConfigDir(normalizedOverride);
@@ -168,7 +153,6 @@ export function useDirectorySettings({
claude: claudeDir || defaultsRef.current.claude, claude: claudeDir || defaultsRef.current.claude,
codex: codexDir || defaultsRef.current.codex, codex: codexDir || defaultsRef.current.codex,
gemini: geminiDir || defaultsRef.current.gemini, gemini: geminiDir || defaultsRef.current.gemini,
opencode: opencodeDir || defaultsRef.current.opencode,
}); });
} catch (error) { } catch (error) {
console.error( console.error(
@@ -199,9 +183,7 @@ export function useDirectorySettings({
? { claudeConfigDir: sanitized } ? { claudeConfigDir: sanitized }
: key === "codex" : key === "codex"
? { codexConfigDir: sanitized } ? { codexConfigDir: sanitized }
: key === "gemini" : { geminiConfigDir: sanitized },
? { geminiConfigDir: sanitized }
: { opencodeConfigDir: sanitized },
); );
} }
@@ -223,13 +205,7 @@ export function useDirectorySettings({
const updateDirectory = useCallback( const updateDirectory = useCallback(
(app: AppId, value?: string) => { (app: AppId, value?: string) => {
updateDirectoryState( updateDirectoryState(
app === "claude" app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini",
? "claude"
: app === "codex"
? "codex"
: app === "gemini"
? "gemini"
: "opencode",
value, value,
); );
}, },
@@ -239,21 +215,13 @@ export function useDirectorySettings({
const browseDirectory = useCallback( const browseDirectory = useCallback(
async (app: AppId) => { async (app: AppId) => {
const key: DirectoryKey = const key: DirectoryKey =
app === "claude" app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini";
? "claude"
: app === "codex"
? "codex"
: app === "gemini"
? "gemini"
: "opencode";
const currentValue = const currentValue =
key === "claude" key === "claude"
? (settings?.claudeConfigDir ?? resolvedDirs.claude) ? (settings?.claudeConfigDir ?? resolvedDirs.claude)
: key === "codex" : key === "codex"
? (settings?.codexConfigDir ?? resolvedDirs.codex) ? (settings?.codexConfigDir ?? resolvedDirs.codex)
: key === "gemini" : (settings?.geminiConfigDir ?? resolvedDirs.gemini);
? (settings?.geminiConfigDir ?? resolvedDirs.gemini)
: (settings?.opencodeConfigDir ?? resolvedDirs.opencode);
try { try {
const picked = await settingsApi.selectConfigDirectory(currentValue); const picked = await settingsApi.selectConfigDirectory(currentValue);
@@ -295,13 +263,7 @@ export function useDirectorySettings({
const resetDirectory = useCallback( const resetDirectory = useCallback(
async (app: AppId) => { async (app: AppId) => {
const key: DirectoryKey = const key: DirectoryKey =
app === "claude" app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini";
? "claude"
: app === "codex"
? "codex"
: app === "gemini"
? "gemini"
: "opencode";
if (!defaultsRef.current[key]) { if (!defaultsRef.current[key]) {
const fallback = await computeDefaultConfigDir(app); const fallback = await computeDefaultConfigDir(app);
if (fallback) { if (fallback) {
@@ -330,12 +292,7 @@ export function useDirectorySettings({
}, [updateDirectoryState]); }, [updateDirectoryState]);
const resetAllDirectories = useCallback( const resetAllDirectories = useCallback(
( (claudeDir?: string, codexDir?: string, geminiDir?: string) => {
claudeDir?: string,
codexDir?: string,
geminiDir?: string,
opencodeDir?: string,
) => {
setAppConfigDir(initialAppConfigDirRef.current); setAppConfigDir(initialAppConfigDirRef.current);
setResolvedDirs({ setResolvedDirs({
appConfig: appConfig:
@@ -343,7 +300,6 @@ export function useDirectorySettings({
claude: claudeDir ?? defaultsRef.current.claude, claude: claudeDir ?? defaultsRef.current.claude,
codex: codexDir ?? defaultsRef.current.codex, codex: codexDir ?? defaultsRef.current.codex,
gemini: geminiDir ?? defaultsRef.current.gemini, 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?.claudeConfigDir),
sanitizeDir(data?.codexConfigDir), sanitizeDir(data?.codexConfigDir),
sanitizeDir(data?.geminiConfigDir), sanitizeDir(data?.geminiConfigDir),
sanitizeDir(data?.opencodeConfigDir),
); );
setRequiresRestart(false); setRequiresRestart(false);
}, [ }, [
@@ -132,16 +131,12 @@ export function useSettings(): UseSettingsResult {
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir); const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir); const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir); const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
const sanitizedOpencodeDir = sanitizeDir(
mergedSettings.opencodeConfigDir,
);
const payload: Settings = { const payload: Settings = {
...mergedSettings, ...mergedSettings,
claudeConfigDir: sanitizedClaudeDir, claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir, codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir, geminiConfigDir: sanitizedGeminiDir,
opencodeConfigDir: sanitizedOpencodeDir,
language: mergedSettings.language, language: mergedSettings.language,
}; };
@@ -243,21 +238,16 @@ export function useSettings(): UseSettingsResult {
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir); const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir); const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir); const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
const sanitizedOpencodeDir = sanitizeDir(
mergedSettings.opencodeConfigDir,
);
const previousAppDir = initialAppConfigDir; const previousAppDir = initialAppConfigDir;
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir); const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
const previousCodexDir = sanitizeDir(data?.codexConfigDir); const previousCodexDir = sanitizeDir(data?.codexConfigDir);
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir); const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir);
const payload: Settings = { const payload: Settings = {
...mergedSettings, ...mergedSettings,
claudeConfigDir: sanitizedClaudeDir, claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir, codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir, geminiConfigDir: sanitizedGeminiDir,
opencodeConfigDir: sanitizedOpencodeDir,
language: mergedSettings.language, language: mergedSettings.language,
}; };
@@ -354,17 +344,11 @@ export function useSettings(): UseSettingsResult {
console.warn("[useSettings] Failed to refresh tray menu", error); console.warn("[useSettings] Failed to refresh tray menu", error);
} }
// 如果 Claude/Codex/Gemini/OpenCode 的目录覆盖发生变化,则立即将"当前使用的供应商"写回对应应用的 live 配置 // 如果 Claude/Codex/Gemini 的目录覆盖发生变化,则立即将当前使用的供应商写回对应应用的 live 配置
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir; const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
const codexDirChanged = sanitizedCodexDir !== previousCodexDir; const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir; const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;
const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir; if (claudeDirChanged || codexDirChanged || geminiDirChanged) {
if (
claudeDirChanged ||
codexDirChanged ||
geminiDirChanged ||
opencodeDirChanged
) {
const syncResult = await syncCurrentProvidersLiveSafe(); const syncResult = await syncCurrentProvidersLiveSafe();
if (!syncResult.ok) { if (!syncResult.ok) {
console.warn( console.warn(
-6
View File
@@ -83,12 +83,9 @@ export function useSettingsForm(): UseSettingsFormResult {
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true, minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
enableClaudePluginIntegration: enableClaudePluginIntegration:
data.enableClaudePluginIntegration ?? false, data.enableClaudePluginIntegration ?? false,
silentStartup: data.silentStartup ?? false,
skipClaudeOnboarding: data.skipClaudeOnboarding ?? false, skipClaudeOnboarding: data.skipClaudeOnboarding ?? false,
claudeConfigDir: sanitizeDir(data.claudeConfigDir), claudeConfigDir: sanitizeDir(data.claudeConfigDir),
codexConfigDir: sanitizeDir(data.codexConfigDir), codexConfigDir: sanitizeDir(data.codexConfigDir),
geminiConfigDir: sanitizeDir(data.geminiConfigDir),
opencodeConfigDir: sanitizeDir(data.opencodeConfigDir),
language: normalizedLanguage, language: normalizedLanguage,
}; };
@@ -141,12 +138,9 @@ export function useSettingsForm(): UseSettingsFormResult {
minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true, minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true,
enableClaudePluginIntegration: enableClaudePluginIntegration:
serverData.enableClaudePluginIntegration ?? false, serverData.enableClaudePluginIntegration ?? false,
silentStartup: serverData.silentStartup ?? false,
skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false, skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false,
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir), claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
codexConfigDir: sanitizeDir(serverData.codexConfigDir), codexConfigDir: sanitizeDir(serverData.codexConfigDir),
geminiConfigDir: sanitizeDir(serverData.geminiConfigDir),
opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir),
language: normalizedLanguage, language: normalizedLanguage,
}; };
+8 -74
View File
@@ -265,8 +265,6 @@
"windowBehaviorHint": "Configure window minimize and Claude plugin integration policies.", "windowBehaviorHint": "Configure window minimize and Claude plugin integration policies.",
"launchOnStartup": "Launch on Startup", "launchOnStartup": "Launch on Startup",
"launchOnStartupDescription": "Automatically run CC Switch when system starts", "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", "autoLaunchFailed": "Failed to set auto-launch",
"minimizeToTray": "Minimize to tray on close", "minimizeToTray": "Minimize to tray on close",
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.", "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", "copy": "Copy Files",
"symlinkHint": "Symlinks save disk space and enable real-time sync. Note: May require admin privileges or Developer Mode on Windows" "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)", "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.", "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", "appConfigDir": "CC Switch Configuration Directory",
@@ -327,12 +298,9 @@
"codexConfigDirDescription": "Override Codex configuration directory.", "codexConfigDirDescription": "Override Codex configuration directory.",
"geminiConfigDir": "Gemini Configuration Directory", "geminiConfigDir": "Gemini Configuration Directory",
"geminiConfigDirDescription": "Override Gemini configuration directory (.env).", "geminiConfigDirDescription": "Override Gemini configuration directory (.env).",
"opencodeConfigDir": "OpenCode Configuration Directory",
"opencodeConfigDirDescription": "Override OpenCode configuration directory (opencode.json).",
"browsePlaceholderClaude": "e.g., /home/<your-username>/.claude", "browsePlaceholderClaude": "e.g., /home/<your-username>/.claude",
"browsePlaceholderCodex": "e.g., /home/<your-username>/.codex", "browsePlaceholderCodex": "e.g., /home/<your-username>/.codex",
"browsePlaceholderGemini": "e.g., /home/<your-username>/.gemini", "browsePlaceholderGemini": "e.g., /home/<your-username>/.gemini",
"browsePlaceholderOpencode": "e.g., /home/<your-username>/.config/opencode",
"browseDirectory": "Browse Directory", "browseDirectory": "Browse Directory",
"resetDefault": "Reset to default directory (takes effect after saving)", "resetDefault": "Reset to default directory (takes effect after saving)",
"checkForUpdates": "Check for Updates", "checkForUpdates": "Check for Updates",
@@ -376,21 +344,7 @@
"saved": "Proxy settings saved", "saved": "Proxy settings saved",
"saveFailed": "Save failed: {{error}}", "saveFailed": "Save failed: {{error}}",
"testSuccess": "Connected! Latency {{latency}}ms", "testSuccess": "Connected! Latency {{latency}}ms",
"testFailed": "Connection failed: {{error}}", "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"
} }
}, },
"apps": { "apps": {
@@ -527,17 +481,12 @@
"proxyConfigDesc": "Configure separate network proxy for this provider. Uses system proxy or global settings when disabled.", "proxyConfigDesc": "Configure separate network proxy for this provider. Uses system proxy or global settings when disabled.",
"proxyUsername": "Username (optional)", "proxyUsername": "Username (optional)",
"proxyPassword": "Password (optional)", "proxyPassword": "Password (optional)",
"pricingConfig": "Pricing Config", "formatTransform": "Format Transform",
"useCustomPricing": "Use separate config", "enableFormatTransform": "Enable Transform",
"pricingConfigDesc": "Configure separate pricing parameters for this provider. Uses global defaults when disabled.", "formatTransformDesc": "Transform requests and responses between different API formats. Useful for providers using OpenAI-compatible interfaces.",
"costMultiplier": "Cost Multiplier", "sourceFormat": "Source Format (Client)",
"costMultiplierPlaceholder": "Leave empty to use global default (1)", "targetFormat": "Target Format (Upstream)",
"costMultiplierHint": "Actual cost = Base cost × Multiplier, supports decimals like 1.5", "transformStreaming": "Transform Streaming Responses"
"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"
}, },
"codexConfig": { "codexConfig": {
"authJson": "auth.json (JSON) *", "authJson": "auth.json (JSON) *",
@@ -640,9 +589,6 @@
"cacheCreationTokens": "Cache Creation", "cacheCreationTokens": "Cache Creation",
"timingInfo": "Duration/TTFT", "timingInfo": "Duration/TTFT",
"status": "Status", "status": "Status",
"multiplier": "Multiplier",
"requestModel": "Request Model",
"responseModel": "Response Model",
"noData": "No data", "noData": "No data",
"unknownProvider": "Unknown Provider", "unknownProvider": "Unknown Provider",
"stream": "Stream", "stream": "Stream",
@@ -685,19 +631,7 @@
"input": "Input", "input": "Input",
"output": "Output", "output": "Output",
"cacheWrite": "Creation", "cacheWrite": "Creation",
"cacheRead": "Hit", "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"
}, },
"usageScript": { "usageScript": {
"title": "Configure Usage Query", "title": "Configure Usage Query",
+8 -74
View File
@@ -265,8 +265,6 @@
"windowBehaviorHint": "最小化動作や Claude プラグイン連携を設定します。", "windowBehaviorHint": "最小化動作や Claude プラグイン連携を設定します。",
"launchOnStartup": "起動時に自動実行", "launchOnStartup": "起動時に自動実行",
"launchOnStartupDescription": "システム起動時に CC Switch を自動起動します", "launchOnStartupDescription": "システム起動時に CC Switch を自動起動します",
"silentStartup": "サイレント起動",
"silentStartupDescription": "起動時にメインウィンドウを表示せず、トレイのみで起動",
"autoLaunchFailed": "自動起動の設定に失敗しました", "autoLaunchFailed": "自動起動の設定に失敗しました",
"minimizeToTray": "閉じるときトレイへ最小化", "minimizeToTray": "閉じるときトレイへ最小化",
"minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。", "minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。",
@@ -289,33 +287,6 @@
"copy": "ファイルコピー", "copy": "ファイルコピー",
"symlinkHint": "シンボリックリンクはディスク容量を節約し、リアルタイム同期を有効にします。注意:Windowsでは管理者権限または開発者モードが必要な場合があります" "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": "設定ディレクトリの上書き(詳細)", "configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。", "configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
"appConfigDir": "CC Switch 設定ディレクトリ", "appConfigDir": "CC Switch 設定ディレクトリ",
@@ -327,12 +298,9 @@
"codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。", "codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。",
"geminiConfigDir": "Gemini 設定ディレクトリ", "geminiConfigDir": "Gemini 設定ディレクトリ",
"geminiConfigDirDescription": "Gemini の設定ディレクトリ(.env)を上書きします。", "geminiConfigDirDescription": "Gemini の設定ディレクトリ(.env)を上書きします。",
"opencodeConfigDir": "OpenCode 設定ディレクトリ",
"opencodeConfigDirDescription": "OpenCode の設定ディレクトリ(opencode.json)を上書きします。",
"browsePlaceholderClaude": "例: /home/<your-username>/.claude", "browsePlaceholderClaude": "例: /home/<your-username>/.claude",
"browsePlaceholderCodex": "例: /home/<your-username>/.codex", "browsePlaceholderCodex": "例: /home/<your-username>/.codex",
"browsePlaceholderGemini": "例: /home/<your-username>/.gemini", "browsePlaceholderGemini": "例: /home/<your-username>/.gemini",
"browsePlaceholderOpencode": "例: /home/<your-username>/.config/opencode",
"browseDirectory": "ディレクトリを選択", "browseDirectory": "ディレクトリを選択",
"resetDefault": "デフォルトに戻す(保存後に反映)", "resetDefault": "デフォルトに戻す(保存後に反映)",
"checkForUpdates": "アップデートを確認", "checkForUpdates": "アップデートを確認",
@@ -376,21 +344,7 @@
"saved": "プロキシ設定を保存しました", "saved": "プロキシ設定を保存しました",
"saveFailed": "保存に失敗しました: {{error}}", "saveFailed": "保存に失敗しました: {{error}}",
"testSuccess": "接続成功!遅延 {{latency}}ms", "testSuccess": "接続成功!遅延 {{latency}}ms",
"testFailed": "接続に失敗しました: {{error}}", "testFailed": "接続に失敗しました: {{error}}"
"pricingDefaultsTitle": "課金のデフォルト設定",
"pricingDefaultsDescription": "アプリごとのデフォルト倍率と課金モードを設定します。",
"pricingAppLabel": "アプリ",
"defaultCostMultiplierLabel": "デフォルト倍率",
"defaultCostMultiplierHint": "コスト計算用の倍率(小数対応)。",
"pricingModelSourceLabel": "課金モード",
"pricingModelSourceRequest": "リクエストモデル",
"pricingModelSourceResponse": "レスポンスモデル",
"pricingSave": "課金設定を保存",
"pricingSaved": "課金設定を保存しました",
"pricingSaveFailed": "課金設定の保存に失敗しました: {{error}}",
"pricingLoadFailed": "課金設定の読み込みに失敗しました: {{error}}",
"defaultCostMultiplierRequired": "デフォルト倍率は必須です",
"defaultCostMultiplierInvalid": "デフォルト倍率の形式が正しくありません"
} }
}, },
"apps": { "apps": {
@@ -527,17 +481,12 @@
"proxyConfigDesc": "このプロバイダーに個別のネットワークプロキシを設定します。無効の場合はシステムプロキシまたはグローバル設定を使用します。", "proxyConfigDesc": "このプロバイダーに個別のネットワークプロキシを設定します。無効の場合はシステムプロキシまたはグローバル設定を使用します。",
"proxyUsername": "ユーザー名(任意)", "proxyUsername": "ユーザー名(任意)",
"proxyPassword": "パスワード(任意)", "proxyPassword": "パスワード(任意)",
"pricingConfig": "課金設定", "formatTransform": "フォーマット変換",
"useCustomPricing": "個別設定を使用", "enableFormatTransform": "変換を有効化",
"pricingConfigDesc": "このプロバイダーに個別の課金パラメータを設定します。無効の場合はグローバル設定を使用します。", "formatTransformDesc": "リクエストとレスポンスを異なる API フォーマット間で変換します。OpenAI 互換インターフェースを使用するプロバイダーに適しています。",
"costMultiplier": "コスト倍率", "sourceFormat": "ソースフォーマット(クライアント)",
"costMultiplierPlaceholder": "空白の場合はグローバル設定を使用(1", "targetFormat": "ターゲットフォーマット(上流",
"costMultiplierHint": "実際のコスト = 基本コスト × 倍率、1.5 などの小数をサポート", "transformStreaming": "ストリーミングレスポンスを変換"
"pricingModelSourceLabel": "課金モード",
"pricingModelSourceInherit": "グローバル設定を継承",
"pricingModelSourceRequest": "リクエストモデル",
"pricingModelSourceResponse": "レスポンスモデル",
"pricingModelSourceHint": "リクエストモデルまたはレスポンスモデルで価格を照合するかを選択"
}, },
"codexConfig": { "codexConfig": {
"authJson": "auth.json (JSON) *", "authJson": "auth.json (JSON) *",
@@ -640,9 +589,6 @@
"cacheCreationTokens": "キャッシュ作成", "cacheCreationTokens": "キャッシュ作成",
"timingInfo": "応答時間/TTFT", "timingInfo": "応答時間/TTFT",
"status": "ステータス", "status": "ステータス",
"multiplier": "倍率",
"requestModel": "リクエストモデル",
"responseModel": "レスポンスモデル",
"noData": "データなし", "noData": "データなし",
"unknownProvider": "不明なプロバイダー", "unknownProvider": "不明なプロバイダー",
"stream": "ストリーム", "stream": "ストリーム",
@@ -685,19 +631,7 @@
"input": "Input", "input": "Input",
"output": "Output", "output": "Output",
"cacheWrite": "作成", "cacheWrite": "作成",
"cacheRead": "ヒット", "cacheRead": "ヒット"
"baseCost": "基本",
"costMultiplier": "コスト倍率",
"withMultiplier": "倍率込み",
"requestDetail": "リクエスト詳細",
"requestNotFound": "リクエストが見つかりません",
"basicInfo": "基本情報",
"tokenUsage": "Token 使用量",
"cacheCreationCost": "キャッシュ作成コスト",
"costBreakdown": "コスト明細",
"performance": "パフォーマンス",
"latency": "レイテンシー",
"errorMessage": "エラーメッセージ"
}, },
"usageScript": { "usageScript": {
"title": "利用状況を設定", "title": "利用状況を設定",
+8 -74
View File
@@ -265,8 +265,6 @@
"windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。", "windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。",
"launchOnStartup": "开机自启", "launchOnStartup": "开机自启",
"launchOnStartupDescription": "随系统启动自动运行 CC Switch", "launchOnStartupDescription": "随系统启动自动运行 CC Switch",
"silentStartup": "静默启动",
"silentStartupDescription": "程序启动时不显示主窗口,仅在系统托盘运行",
"autoLaunchFailed": "设置开机自启失败", "autoLaunchFailed": "设置开机自启失败",
"minimizeToTray": "关闭时最小化到托盘", "minimizeToTray": "关闭时最小化到托盘",
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。", "minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
@@ -289,33 +287,6 @@
"copy": "文件复制", "copy": "文件复制",
"symlinkHint": "软连接节省磁盘空间并支持实时同步。注意:Windows 可能需要管理员权限或开启开发者模式" "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": "配置目录覆盖(高级)", "configDirectoryOverride": "配置目录覆盖(高级)",
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。", "configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。",
"appConfigDir": "CC Switch 配置目录", "appConfigDir": "CC Switch 配置目录",
@@ -327,12 +298,9 @@
"codexConfigDirDescription": "覆盖 Codex 配置目录。", "codexConfigDirDescription": "覆盖 Codex 配置目录。",
"geminiConfigDir": "Gemini 配置目录", "geminiConfigDir": "Gemini 配置目录",
"geminiConfigDirDescription": "覆盖 Gemini 配置目录 (.env)。", "geminiConfigDirDescription": "覆盖 Gemini 配置目录 (.env)。",
"opencodeConfigDir": "OpenCode 配置目录",
"opencodeConfigDirDescription": "覆盖 OpenCode 配置目录 (opencode.json)。",
"browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude", "browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude",
"browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex", "browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex",
"browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini", "browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini",
"browsePlaceholderOpencode": "例如:/home/<你的用户名>/.config/opencode",
"browseDirectory": "浏览目录", "browseDirectory": "浏览目录",
"resetDefault": "恢复默认目录(需保存后生效)", "resetDefault": "恢复默认目录(需保存后生效)",
"checkForUpdates": "检查更新", "checkForUpdates": "检查更新",
@@ -376,21 +344,7 @@
"saved": "代理设置已保存", "saved": "代理设置已保存",
"saveFailed": "保存失败:{{error}}", "saveFailed": "保存失败:{{error}}",
"testSuccess": "连接成功!延迟 {{latency}}ms", "testSuccess": "连接成功!延迟 {{latency}}ms",
"testFailed": "连接失败:{{error}}", "testFailed": "连接失败:{{error}}"
"pricingDefaultsTitle": "计费默认配置",
"pricingDefaultsDescription": "设置各应用的默认倍率与计费模式来源。",
"pricingAppLabel": "应用",
"defaultCostMultiplierLabel": "默认倍率",
"defaultCostMultiplierHint": "用于成本计算的倍率,支持小数。",
"pricingModelSourceLabel": "计费模式",
"pricingModelSourceRequest": "请求模型",
"pricingModelSourceResponse": "返回模型",
"pricingSave": "保存计费配置",
"pricingSaved": "计费配置已保存",
"pricingSaveFailed": "保存计费配置失败:{{error}}",
"pricingLoadFailed": "加载计费配置失败:{{error}}",
"defaultCostMultiplierRequired": "默认倍率不能为空",
"defaultCostMultiplierInvalid": "默认倍率格式不正确"
} }
}, },
"apps": { "apps": {
@@ -527,17 +481,12 @@
"proxyConfigDesc": "为此供应商配置单独的网络代理,不启用时使用系统代理或全局设置。", "proxyConfigDesc": "为此供应商配置单独的网络代理,不启用时使用系统代理或全局设置。",
"proxyUsername": "用户名(可选)", "proxyUsername": "用户名(可选)",
"proxyPassword": "密码(可选)", "proxyPassword": "密码(可选)",
"pricingConfig": "计费配置", "formatTransform": "格式转换",
"useCustomPricing": "使用单独配置", "enableFormatTransform": "启用转换",
"pricingConfigDesc": "为此供应商配置单独的计费参数,不启用时使用全局默认配置。", "formatTransformDesc": "将请求和响应在不同 API 格式之间转换。适用于使用 OpenAI 兼容接口的供应商。",
"costMultiplier": "成本倍率", "sourceFormat": "源格式(客户端)",
"costMultiplierPlaceholder": "留空使用全局默认(1", "targetFormat": "目标格式(上游",
"costMultiplierHint": "实际成本 = 基础成本 × 倍率,支持小数如 1.5", "transformStreaming": "转换流式响应"
"pricingModelSourceLabel": "计费模式",
"pricingModelSourceInherit": "继承全局默认",
"pricingModelSourceRequest": "请求模型",
"pricingModelSourceResponse": "返回模型",
"pricingModelSourceHint": "选择按请求模型还是返回模型进行定价匹配"
}, },
"codexConfig": { "codexConfig": {
"authJson": "auth.json (JSON) *", "authJson": "auth.json (JSON) *",
@@ -640,9 +589,6 @@
"cacheCreationTokens": "缓存创建", "cacheCreationTokens": "缓存创建",
"timingInfo": "用时/首字", "timingInfo": "用时/首字",
"status": "状态", "status": "状态",
"multiplier": "倍率",
"requestModel": "请求模型",
"responseModel": "返回模型",
"noData": "暂无数据", "noData": "暂无数据",
"unknownProvider": "未知供应商", "unknownProvider": "未知供应商",
"stream": "流", "stream": "流",
@@ -685,19 +631,7 @@
"input": "Input", "input": "Input",
"output": "Output", "output": "Output",
"cacheWrite": "创建", "cacheWrite": "创建",
"cacheRead": "命中", "cacheRead": "命中"
"baseCost": "基础",
"costMultiplier": "成本倍率",
"withMultiplier": "含倍率",
"requestDetail": "请求详情",
"requestNotFound": "请求未找到",
"basicInfo": "基本信息",
"tokenUsage": "Token 使用量",
"cacheCreationCost": "缓存写入成本",
"costBreakdown": "成本明细",
"performance": "性能信息",
"latency": "延迟",
"errorMessage": "错误信息"
}, },
"usageScript": { "usageScript": {
"title": "配置用量查询", "title": "配置用量查询",
-25
View File
@@ -92,29 +92,4 @@ export const proxyApi = {
async updateProxyConfigForApp(config: AppProxyConfig): Promise<void> { async updateProxyConfigForApp(config: AppProxyConfig): Promise<void> {
return invoke("update_proxy_config_for_app", { config }); 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; 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 // 供应商元数据(字段名与后端一致,保持 snake_case
export interface ProviderMeta { export interface ProviderMeta {
// 自定义端点:以 URL 为键,值为端点信息 // 自定义端点:以 URL 为键,值为端点信息
@@ -135,10 +147,8 @@ export interface ProviderMeta {
testConfig?: ProviderTestConfig; testConfig?: ProviderTestConfig;
// 供应商单独的代理配置 // 供应商单独的代理配置
proxyConfig?: ProviderProxyConfig; proxyConfig?: ProviderProxyConfig;
// 供应商成本倍率 // 格式转换配置(用于 OpenRouter 等需要 API 格式转换的供应商)
costMultiplier?: string; formatTransform?: FormatTransformConfig;
// 供应商计费模式来源
pricingModelSource?: string;
} }
// Skill 同步方式 // Skill 同步方式
@@ -166,8 +176,6 @@ export interface Settings {
skipClaudeOnboarding?: boolean; skipClaudeOnboarding?: boolean;
// 是否开机自启 // 是否开机自启
launchOnStartup?: boolean; launchOnStartup?: boolean;
// 静默启动(程序启动时不显示主窗口)
silentStartup?: boolean;
// 首选语言(可选,默认中文) // 首选语言(可选,默认中文)
language?: "en" | "zh" | "ja"; language?: "en" | "zh" | "ja";
@@ -195,13 +203,6 @@ export interface Settings {
// ===== Skill 同步设置 ===== // ===== Skill 同步设置 =====
// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy // Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
skillSyncMethod?: SkillSyncMethod; 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 服务器连接参数(宽松:允许扩展字段) // MCP 服务器连接参数(宽松:允许扩展字段)
-2
View File
@@ -13,8 +13,6 @@ export interface RequestLog {
providerName?: string; providerName?: string;
appType: string; appType: string;
model: string; model: string;
requestModel?: string;
costMultiplier: string;
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
cacheReadTokens: 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); getAppConfigDirOverrideMock.mockResolvedValue(null);
getConfigDirMock.mockImplementation(async (app: string) => { getConfigDirMock.mockImplementation(async (app: string) =>
if (app === "claude") return "/remote/claude"; app === "claude" ? "/remote/claude" : "/remote/codex",
if (app === "codex") return "/remote/codex"; );
if (app === "gemini") return "/remote/gemini";
return "/remote/opencode";
});
selectConfigDirectoryMock.mockReset(); selectConfigDirectoryMock.mockReset();
}); });
@@ -87,8 +84,7 @@ describe("useDirectorySettings", () => {
appConfig: "/override/app", appConfig: "/override/app",
claude: "/remote/claude", claude: "/remote/claude",
codex: "/remote/codex", codex: "/remote/codex",
gemini: "/remote/gemini", gemini: "/remote/codex", // Gemini 使用 codex 作为默认
opencode: "/remote/opencode",
}); });
}); });
@@ -218,17 +214,10 @@ describe("useDirectorySettings", () => {
await waitFor(() => expect(result.current.isLoading).toBe(false)); await waitFor(() => expect(result.current.isLoading).toBe(false));
act(() => { act(() => {
result.current.resetAllDirectories( result.current.resetAllDirectories("/server/claude", "/server/codex");
"/server/claude",
"/server/codex",
"/server/gemini",
"/server/opencode",
);
}); });
expect(result.current.resolvedDirs.claude).toBe("/server/claude"); expect(result.current.resolvedDirs.claude).toBe("/server/claude");
expect(result.current.resolvedDirs.codex).toBe("/server/codex"); 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", "/server/claude",
undefined, undefined,
undefined, // geminiConfigDir undefined, // geminiConfigDir
undefined, // opencodeConfigDir
); );
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false); 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: { test: {
environment: "jsdom", environment: "jsdom",
setupFiles: ["./tests/setupGlobals.ts", "./tests/setupTests.ts"], setupFiles: ["./tests/setupTests.ts"],
globals: true, globals: true,
coverage: { coverage: {
reporter: ["text", "lcov"], reporter: ["text", "lcov"],