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