mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-31 11:01:36 +08:00
Merge branch 'refs/heads/main' into feat/proxy-url-refactor-full-url-mode
# Conflicts: # src-tauri/src/provider.rs # src-tauri/src/proxy/forwarder.rs # src-tauri/src/proxy/usage/parser.rs # src/components/providers/forms/ClaudeFormFields.tsx # src/components/providers/forms/ProviderForm.tsx # src/components/providers/forms/hooks/useApiKeyState.ts # src/i18n/locales/en.json # src/i18n/locales/ja.json # src/i18n/locales/zh.json # src/types.ts # src/utils/providerConfigUtils.ts
This commit is contained in:
Generated
+18
-1
@@ -701,7 +701,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.11.1"
|
||||
version = "3.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -714,6 +714,7 @@ dependencies = [
|
||||
"futures",
|
||||
"hyper",
|
||||
"indexmap 2.11.4",
|
||||
"json-five",
|
||||
"json5",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
@@ -2510,6 +2511,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "json-five"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "865f2d01a4549c1fd8c60640c03ae5249eb374cd8cde8b905628d4b1af95c87c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"unicode-general-category",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "json-patch"
|
||||
version = "3.0.1"
|
||||
@@ -6001,6 +6012,12 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-general-category"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.19"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.11.1"
|
||||
version = "3.12.0"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -13,6 +13,7 @@ rust-version = "1.85.0"
|
||||
[lib]
|
||||
name = "cc_switch_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
@@ -63,6 +64,7 @@ rust_decimal = "1.33"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
sha2 = "0.10"
|
||||
json5 = "0.4"
|
||||
json-five = "0.3.1"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
@@ -28,6 +28,39 @@ fn invalid_json_format_error(error: serde_json::Error) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_toml_format_error(error: toml_edit::TomlError) -> String {
|
||||
let lang = settings::get_settings()
|
||||
.language
|
||||
.unwrap_or_else(|| "zh".to_string());
|
||||
|
||||
match lang.as_str() {
|
||||
"en" => format!("Invalid TOML format: {error}"),
|
||||
"ja" => format!("TOML形式が無効です: {error}"),
|
||||
_ => format!("无效的 TOML 格式: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_common_config_snippet(app_type: &str, snippet: &str) -> Result<(), String> {
|
||||
if snippet.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match app_type {
|
||||
"claude" | "gemini" | "omo" | "omo-slim" => {
|
||||
serde_json::from_str::<serde_json::Value>(snippet)
|
||||
.map_err(invalid_json_format_error)?;
|
||||
}
|
||||
"codex" => {
|
||||
snippet
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(invalid_toml_format_error)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||
@@ -213,16 +246,12 @@ pub async fn set_common_config_snippet(
|
||||
snippet: String,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<(), String> {
|
||||
if !snippet.trim().is_empty() {
|
||||
match app_type.as_str() {
|
||||
"claude" | "gemini" | "omo" | "omo-slim" => {
|
||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||
.map_err(invalid_json_format_error)?;
|
||||
}
|
||||
"codex" => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let old_snippet = state
|
||||
.db
|
||||
.get_config_snippet(&app_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
validate_common_config_snippet(&app_type, &snippet)?;
|
||||
|
||||
let value = if snippet.trim().is_empty() {
|
||||
None
|
||||
@@ -230,11 +259,35 @@ pub async fn set_common_config_snippet(
|
||||
Some(snippet)
|
||||
};
|
||||
|
||||
if matches!(app_type.as_str(), "claude" | "codex" | "gemini") {
|
||||
if let Some(legacy_snippet) = old_snippet
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?;
|
||||
crate::services::provider::ProviderService::migrate_legacy_common_config_usage(
|
||||
state.inner(),
|
||||
app,
|
||||
legacy_snippet,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(&app_type, value)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if matches!(app_type.as_str(), "claude" | "codex" | "gemini") {
|
||||
let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?;
|
||||
crate::services::provider::ProviderService::sync_current_provider_for_app(
|
||||
state.inner(),
|
||||
app,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
if app_type == "omo"
|
||||
&& state
|
||||
.db
|
||||
@@ -264,6 +317,27 @@ pub async fn set_common_config_snippet(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_common_config_snippet;
|
||||
|
||||
#[test]
|
||||
fn validate_common_config_snippet_accepts_comment_only_codex_snippet() {
|
||||
validate_common_config_snippet("codex", "# comment only\n")
|
||||
.expect("comment-only codex snippet should be valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_common_config_snippet_rejects_invalid_codex_snippet() {
|
||||
let err = validate_common_config_snippet("codex", "[broken")
|
||||
.expect_err("invalid codex snippet should be rejected");
|
||||
assert!(
|
||||
err.contains("TOML") || err.contains("toml") || err.contains("格式"),
|
||||
"expected TOML validation error, got {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extract_common_config_snippet(
|
||||
appType: String,
|
||||
|
||||
@@ -26,6 +26,21 @@ pub fn get_openclaw_live_provider_ids() -> Result<Vec<String>, String> {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get a single OpenClaw provider fragment from live config.
|
||||
#[tauri::command]
|
||||
pub fn get_openclaw_live_provider(
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
openclaw_config::get_provider(&providerId).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Scan openclaw.json for known configuration hazards.
|
||||
#[tauri::command]
|
||||
pub fn scan_openclaw_config_health() -> Result<Vec<openclaw_config::OpenClawHealthWarning>, String>
|
||||
{
|
||||
openclaw_config::scan_openclaw_config_health().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agents Configuration Commands
|
||||
// ============================================================================
|
||||
@@ -41,7 +56,7 @@ pub fn get_openclaw_default_model() -> Result<Option<openclaw_config::OpenClawDe
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_default_model(
|
||||
model: openclaw_config::OpenClawDefaultModel,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
|
||||
openclaw_config::set_default_model(&model).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -56,7 +71,7 @@ pub fn get_openclaw_model_catalog(
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_model_catalog(
|
||||
catalog: HashMap<String, openclaw_config::OpenClawModelCatalogEntry>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
|
||||
openclaw_config::set_model_catalog(&catalog).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -71,7 +86,7 @@ pub fn get_openclaw_agents_defaults(
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_agents_defaults(
|
||||
defaults: openclaw_config::OpenClawAgentsDefaults,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
|
||||
openclaw_config::set_agents_defaults(&defaults).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -87,7 +102,9 @@ pub fn get_openclaw_env() -> Result<openclaw_config::OpenClawEnvConfig, String>
|
||||
|
||||
/// Set OpenClaw env config (env section of openclaw.json)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_env(env: openclaw_config::OpenClawEnvConfig) -> Result<(), String> {
|
||||
pub fn set_openclaw_env(
|
||||
env: openclaw_config::OpenClawEnvConfig,
|
||||
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
|
||||
openclaw_config::set_env_config(&env).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -103,6 +120,8 @@ pub fn get_openclaw_tools() -> Result<openclaw_config::OpenClawToolsConfig, Stri
|
||||
|
||||
/// Set OpenClaw tools config (tools section of openclaw.json)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_tools(tools: openclaw_config::OpenClawToolsConfig) -> Result<(), String> {
|
||||
pub fn set_openclaw_tools(
|
||||
tools: openclaw_config::OpenClawToolsConfig,
|
||||
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
|
||||
openclaw_config::set_tools_config(&tools).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -57,3 +57,20 @@ pub async fn launch_session_terminal(
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_session(
|
||||
providerId: String,
|
||||
sessionId: String,
|
||||
sourcePath: String,
|
||||
) -> Result<bool, String> {
|
||||
let provider_id = providerId.clone();
|
||||
let session_id = sessionId.clone();
|
||||
let source_path = sourcePath.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
session_manager::delete_session(&provider_id, &session_id, &source_path)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete session: {e}"))?
|
||||
}
|
||||
|
||||
@@ -15,6 +15,16 @@ use tempfile::NamedTempFile;
|
||||
|
||||
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
||||
|
||||
/// Tables that are local-only (not synced via WebDAV snapshots).
|
||||
/// Schema is still exported, but data rows are skipped.
|
||||
const LOCAL_ONLY_TABLES: &[&str] = &[
|
||||
"proxy_request_logs",
|
||||
"stream_check_logs",
|
||||
"provider_health",
|
||||
"proxy_live_backup",
|
||||
"usage_daily_rollups",
|
||||
];
|
||||
|
||||
/// A database backup entry for the UI
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -25,10 +35,16 @@ pub struct BackupEntry {
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
|
||||
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串,完整导出)
|
||||
pub fn export_sql_string(&self) -> Result<String, AppError> {
|
||||
let snapshot = self.snapshot_to_memory()?;
|
||||
Self::dump_sql(&snapshot)
|
||||
Self::dump_sql(&snapshot, &[])
|
||||
}
|
||||
|
||||
/// Export SQL for sync (WebDAV), skipping local-only tables' data
|
||||
pub fn export_sql_string_for_sync(&self) -> Result<String, AppError> {
|
||||
let snapshot = self.snapshot_to_memory()?;
|
||||
Self::dump_sql(&snapshot, LOCAL_ONLY_TABLES)
|
||||
}
|
||||
|
||||
/// 导出为 SQLite 兼容的 SQL 文本
|
||||
@@ -58,12 +74,32 @@ impl Database {
|
||||
|
||||
/// 从 SQL 字符串导入,返回生成的备份 ID(若无备份则为空字符串)
|
||||
pub fn import_sql_string(&self, sql_raw: &str) -> Result<String, AppError> {
|
||||
self.import_sql_string_inner(sql_raw, &[])
|
||||
}
|
||||
|
||||
/// Import SQL generated for sync, then restore local-only tables from the
|
||||
/// current device snapshot before replacing the main database.
|
||||
pub(crate) fn import_sql_string_for_sync(&self, sql_raw: &str) -> Result<String, AppError> {
|
||||
self.import_sql_string_inner(sql_raw, LOCAL_ONLY_TABLES)
|
||||
}
|
||||
|
||||
fn import_sql_string_inner(
|
||||
&self,
|
||||
sql_raw: &str,
|
||||
preserve_tables: &[&str],
|
||||
) -> Result<String, AppError> {
|
||||
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
||||
Self::validate_cc_switch_sql_export(sql_content)?;
|
||||
|
||||
// 导入前备份现有数据库
|
||||
let backup_path = self.backup_database_file()?;
|
||||
|
||||
let local_snapshot = if preserve_tables.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.snapshot_to_memory()?)
|
||||
};
|
||||
|
||||
// 在临时数据库执行导入,确保失败不会污染主库
|
||||
let temp_file = NamedTempFile::new().map_err(|e| AppError::IoContext {
|
||||
context: "创建临时数据库文件失败".to_string(),
|
||||
@@ -81,6 +117,9 @@ impl Database {
|
||||
Self::create_tables_on_conn(&temp_conn)?;
|
||||
Self::apply_schema_migrations_on_conn(&temp_conn)?;
|
||||
Self::validate_basic_state(&temp_conn)?;
|
||||
if let Some(local_snapshot) = local_snapshot.as_ref() {
|
||||
Self::restore_tables(local_snapshot, &temp_conn, preserve_tables)?;
|
||||
}
|
||||
|
||||
// 使用 Backup 将临时库原子写回主库
|
||||
{
|
||||
@@ -129,42 +168,121 @@ impl Database {
|
||||
))
|
||||
}
|
||||
|
||||
fn restore_tables(
|
||||
source_conn: &Connection,
|
||||
target_conn: &Connection,
|
||||
tables: &[&str],
|
||||
) -> Result<(), AppError> {
|
||||
for table in tables {
|
||||
if !Self::table_exists(source_conn, table)? || !Self::table_exists(target_conn, table)?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let columns = Self::get_table_columns(source_conn, table)?;
|
||||
if columns.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
target_conn
|
||||
.execute(&format!("DELETE FROM \"{table}\""), [])
|
||||
.map_err(|e| AppError::Database(format!("清空表 {table} 失败: {e}")))?;
|
||||
|
||||
let placeholders = (1..=columns.len())
|
||||
.map(|idx| format!("?{idx}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let cols = columns
|
||||
.iter()
|
||||
.map(|column| format!("\"{column}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let insert_sql = format!("INSERT INTO \"{table}\" ({cols}) VALUES ({placeholders})");
|
||||
|
||||
let mut stmt = source_conn
|
||||
.prepare(&format!("SELECT * FROM \"{table}\""))
|
||||
.map_err(|e| AppError::Database(format!("读取表 {table} 失败: {e}")))?;
|
||||
let mut rows = stmt
|
||||
.query([])
|
||||
.map_err(|e| AppError::Database(format!("查询表 {table} 数据失败: {e}")))?;
|
||||
|
||||
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||||
let mut values = Vec::with_capacity(columns.len());
|
||||
for idx in 0..columns.len() {
|
||||
values.push(
|
||||
row.get::<_, rusqlite::types::Value>(idx)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
|
||||
target_conn
|
||||
.execute(&insert_sql, rusqlite::params_from_iter(values.iter()))
|
||||
.map_err(|e| AppError::Database(format!("恢复表 {table} 数据失败: {e}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Periodic backup: create a new backup if the latest one is older than the configured interval
|
||||
pub(crate) fn periodic_backup_if_needed(&self) -> Result<(), AppError> {
|
||||
let interval_hours = crate::settings::effective_backup_interval_hours();
|
||||
if interval_hours == 0 {
|
||||
return Ok(()); // Auto-backup disabled
|
||||
}
|
||||
if interval_hours > 0 {
|
||||
let backup_dir = get_app_config_dir().join("backups");
|
||||
if !backup_dir.exists() {
|
||||
self.backup_database_file()?;
|
||||
} else {
|
||||
let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||||
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
|
||||
.max()
|
||||
});
|
||||
|
||||
let backup_dir = get_app_config_dir().join("backups");
|
||||
if !backup_dir.exists() {
|
||||
self.backup_database_file()?;
|
||||
return Ok(());
|
||||
}
|
||||
let interval_secs = u64::from(interval_hours) * 3600;
|
||||
let needs_backup = match latest {
|
||||
None => true,
|
||||
Some(last_modified) => {
|
||||
last_modified.elapsed().unwrap_or_default()
|
||||
> std::time::Duration::from_secs(interval_secs)
|
||||
}
|
||||
};
|
||||
|
||||
let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||||
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
|
||||
.max()
|
||||
});
|
||||
|
||||
let interval_secs = u64::from(interval_hours) * 3600;
|
||||
let needs_backup = match latest {
|
||||
None => true,
|
||||
Some(last_modified) => {
|
||||
last_modified.elapsed().unwrap_or_default()
|
||||
> std::time::Duration::from_secs(interval_secs)
|
||||
if needs_backup {
|
||||
log::info!(
|
||||
"Periodic backup: latest backup is older than {interval_hours} hours, creating new backup"
|
||||
);
|
||||
self.backup_database_file()?;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if needs_backup {
|
||||
log::info!(
|
||||
"Periodic backup: latest backup is older than {interval_hours} hours, creating new backup"
|
||||
);
|
||||
self.backup_database_file()?;
|
||||
}
|
||||
|
||||
// Periodic maintenance is always enabled, regardless of auto-backup settings.
|
||||
let mut reclaimed_rows = 0u64;
|
||||
match self.cleanup_old_stream_check_logs(7) {
|
||||
Ok(deleted) => {
|
||||
reclaimed_rows += deleted;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Periodic stream_check_logs cleanup failed: {e}");
|
||||
}
|
||||
}
|
||||
match self.rollup_and_prune(30) {
|
||||
Ok(deleted) => {
|
||||
reclaimed_rows += deleted;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Periodic rollup_and_prune failed: {e}");
|
||||
}
|
||||
}
|
||||
if reclaimed_rows > 0 {
|
||||
let conn = lock_conn!(self.conn);
|
||||
if let Err(e) = conn.execute_batch("PRAGMA incremental_vacuum;") {
|
||||
log::warn!("Periodic incremental vacuum failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -258,7 +376,7 @@ impl Database {
|
||||
}
|
||||
|
||||
/// 导出数据库为 SQL 文本
|
||||
fn dump_sql(conn: &Connection) -> Result<String, AppError> {
|
||||
fn dump_sql(conn: &Connection, skip_tables: &[&str]) -> Result<String, AppError> {
|
||||
let mut output = String::new();
|
||||
let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let user_version: i64 = conn
|
||||
@@ -306,6 +424,9 @@ impl Database {
|
||||
|
||||
// 导出数据
|
||||
for table in tables {
|
||||
if skip_tables.iter().any(|t| *t == table) {
|
||||
continue;
|
||||
}
|
||||
let columns = Self::get_table_columns(conn, &table)?;
|
||||
if columns.is_empty() {
|
||||
continue;
|
||||
@@ -557,3 +678,173 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::settings::{update_settings, AppSettings};
|
||||
use serial_test::serial;
|
||||
|
||||
#[test]
|
||||
fn sync_import_preserves_local_only_tables() -> Result<(), AppError> {
|
||||
let remote_db = Database::memory()?;
|
||||
{
|
||||
let conn = crate::database::lock_conn!(remote_db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
||||
VALUES ('remote-provider', 'claude', 'Remote Provider', '{}', '{}')",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
let remote_sql = remote_db.export_sql_string_for_sync()?;
|
||||
|
||||
let local_db = Database::memory()?;
|
||||
{
|
||||
let conn = crate::database::lock_conn!(local_db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
||||
VALUES ('local-provider', 'claude', 'Local Provider', '{}', '{}')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES ('req-1', 'local-provider', 'claude', 'claude-3', 100, 50, '0.01', 120, 200, 1000)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO usage_daily_rollups (
|
||||
date, app_type, provider_id, model, request_count, success_count,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
total_cost_usd, avg_latency_ms
|
||||
) VALUES ('2026-03-01', 'claude', 'local-provider', 'claude-3', 7, 7, 700, 350, 0, 0, '0.07', 120)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO stream_check_logs (
|
||||
provider_id, provider_name, app_type, status, success, message,
|
||||
response_time_ms, http_status, model_used, retry_count, tested_at
|
||||
) VALUES ('local-provider', 'Local Provider', 'claude', 'operational', 1, 'ok', 42, 200, 'claude-3', 0, 1000)",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
local_db.import_sql_string_for_sync(&remote_sql)?;
|
||||
|
||||
let remote_provider_exists: i64 = {
|
||||
let conn = crate::database::lock_conn!(local_db.conn);
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM providers WHERE id = 'remote-provider' AND app_type = 'claude'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?
|
||||
};
|
||||
assert_eq!(
|
||||
remote_provider_exists, 1,
|
||||
"remote config should be imported"
|
||||
);
|
||||
|
||||
let (request_logs, rollups, stream_logs): (i64, i64, i64) = {
|
||||
let conn = crate::database::lock_conn!(local_db.conn);
|
||||
let request_logs =
|
||||
conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let rollups =
|
||||
conn.query_row("SELECT COUNT(*) FROM usage_daily_rollups", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let stream_logs =
|
||||
conn.query_row("SELECT COUNT(*) FROM stream_check_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
(request_logs, rollups, stream_logs)
|
||||
};
|
||||
assert_eq!(request_logs, 1, "local request logs should be preserved");
|
||||
assert_eq!(rollups, 1, "local rollups should be preserved");
|
||||
assert_eq!(
|
||||
stream_logs, 1,
|
||||
"local stream check logs should be preserved"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn periodic_maintenance_runs_even_when_auto_backup_disabled() -> Result<(), AppError> {
|
||||
let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||||
let test_home =
|
||||
std::env::temp_dir().join("cc-switch-periodic-maintenance-backup-disabled-test");
|
||||
let _ = std::fs::remove_dir_all(&test_home);
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
let mut settings = AppSettings::default();
|
||||
settings.backup_interval_hours = Some(0);
|
||||
update_settings(settings).expect("disable auto backup");
|
||||
|
||||
let db = Database::memory()?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let old_ts = now - 40 * 86400;
|
||||
let old_stream_ts = now - 8 * 86400;
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES ('old-req', 'p1', 'claude', 'claude-3', 100, 50, '0.01', 100, 200, ?1)",
|
||||
[old_ts],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO stream_check_logs (
|
||||
provider_id, provider_name, app_type, status, success, message,
|
||||
response_time_ms, http_status, model_used, retry_count, tested_at
|
||||
) VALUES ('p1', 'Provider 1', 'claude', 'operational', 1, 'ok', 42, 200, 'claude-3', 0, ?1)",
|
||||
[old_stream_ts],
|
||||
)?;
|
||||
}
|
||||
|
||||
db.periodic_backup_if_needed()?;
|
||||
|
||||
let (remaining_request_logs, stream_logs, rollups): (i64, i64, i64) = {
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let remaining_request_logs =
|
||||
conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let stream_logs =
|
||||
conn.query_row("SELECT COUNT(*) FROM stream_check_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let rollups =
|
||||
conn.query_row("SELECT COUNT(*) FROM usage_daily_rollups", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
(remaining_request_logs, stream_logs, rollups)
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
remaining_request_logs, 0,
|
||||
"old request logs should still be pruned when auto backup is disabled"
|
||||
);
|
||||
assert_eq!(
|
||||
stream_logs, 0,
|
||||
"old stream check logs should still be pruned when auto backup is disabled"
|
||||
);
|
||||
assert_eq!(rollups, 1, "old request logs should be rolled up");
|
||||
|
||||
match old_test_home {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod settings;
|
||||
pub mod skills;
|
||||
pub mod stream_check;
|
||||
pub mod universal_providers;
|
||||
pub mod usage_rollup;
|
||||
|
||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||
// 导出 FailoverQueueItem 供外部使用
|
||||
|
||||
@@ -48,6 +48,23 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete stream check logs older than `retain_days` days.
|
||||
/// Returns the number of deleted rows.
|
||||
pub fn cleanup_old_stream_check_logs(&self, retain_days: i64) -> Result<u64, AppError> {
|
||||
let cutoff = chrono::Utc::now().timestamp() - retain_days * 86400;
|
||||
let conn = lock_conn!(self.conn);
|
||||
let deleted = conn
|
||||
.execute(
|
||||
"DELETE FROM stream_check_logs WHERE tested_at < ?1",
|
||||
[cutoff],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
if deleted > 0 {
|
||||
log::info!("Cleaned up {deleted} stream_check_logs older than {retain_days} days");
|
||||
}
|
||||
Ok(deleted as u64)
|
||||
}
|
||||
|
||||
/// 保存流式检查配置
|
||||
pub fn save_stream_check_config(&self, config: &StreamCheckConfig) -> Result<(), AppError> {
|
||||
let json = serde_json::to_string(config)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Usage rollup DAO
|
||||
//!
|
||||
//! Aggregates proxy_request_logs into daily rollups and prunes old detail rows.
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
|
||||
impl Database {
|
||||
/// Aggregate proxy_request_logs older than `retain_days` into usage_daily_rollups,
|
||||
/// then delete the aggregated detail rows.
|
||||
/// Returns the number of deleted detail rows.
|
||||
pub fn rollup_and_prune(&self, retain_days: i64) -> Result<u64, AppError> {
|
||||
let cutoff = chrono::Utc::now().timestamp() - retain_days * 86400;
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// Check if there are any rows to process
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM proxy_request_logs WHERE created_at < ?1",
|
||||
[cutoff],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
if count == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Use a savepoint for atomicity
|
||||
conn.execute("SAVEPOINT rollup_prune;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let result = Self::do_rollup_and_prune(&conn, cutoff);
|
||||
|
||||
match result {
|
||||
Ok(deleted) => {
|
||||
conn.execute("RELEASE rollup_prune;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
if deleted > 0 {
|
||||
log::info!(
|
||||
"Rolled up and pruned {deleted} proxy_request_logs (retain={retain_days}d)"
|
||||
);
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
Err(e) => {
|
||||
conn.execute("ROLLBACK TO rollup_prune;", []).ok();
|
||||
conn.execute("RELEASE rollup_prune;", []).ok();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn do_rollup_and_prune(conn: &rusqlite::Connection, cutoff: i64) -> Result<u64, AppError> {
|
||||
// Aggregate old logs, merging with any pre-existing rollup rows via LEFT JOIN.
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO usage_daily_rollups
|
||||
(date, app_type, provider_id, model,
|
||||
request_count, success_count,
|
||||
input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens,
|
||||
total_cost_usd, avg_latency_ms)
|
||||
SELECT
|
||||
d, a, p, m,
|
||||
COALESCE(old.request_count, 0) + new_req,
|
||||
COALESCE(old.success_count, 0) + new_succ,
|
||||
COALESCE(old.input_tokens, 0) + new_in,
|
||||
COALESCE(old.output_tokens, 0) + new_out,
|
||||
COALESCE(old.cache_read_tokens, 0) + new_cr,
|
||||
COALESCE(old.cache_creation_tokens, 0) + new_cc,
|
||||
CAST(COALESCE(CAST(old.total_cost_usd AS REAL), 0) + new_cost AS TEXT),
|
||||
CASE WHEN COALESCE(old.request_count, 0) + new_req > 0
|
||||
THEN (COALESCE(old.avg_latency_ms, 0) * COALESCE(old.request_count, 0)
|
||||
+ new_lat * new_req)
|
||||
/ (COALESCE(old.request_count, 0) + new_req)
|
||||
ELSE 0 END
|
||||
FROM (
|
||||
SELECT
|
||||
date(created_at, 'unixepoch', 'localtime') as d,
|
||||
app_type as a, provider_id as p, model as m,
|
||||
COUNT(*) as new_req,
|
||||
SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) as new_succ,
|
||||
COALESCE(SUM(input_tokens), 0) as new_in,
|
||||
COALESCE(SUM(output_tokens), 0) as new_out,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as new_cr,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as new_cc,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as new_cost,
|
||||
COALESCE(AVG(latency_ms), 0) as new_lat
|
||||
FROM proxy_request_logs WHERE created_at < ?1
|
||||
GROUP BY d, a, p, m
|
||||
) agg
|
||||
LEFT JOIN usage_daily_rollups old
|
||||
ON old.date = agg.d AND old.app_type = agg.a
|
||||
AND old.provider_id = agg.p AND old.model = agg.m",
|
||||
[cutoff],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Rollup aggregation failed: {e}")))?;
|
||||
|
||||
// Delete the aggregated detail rows
|
||||
let deleted = conn
|
||||
.execute(
|
||||
"DELETE FROM proxy_request_logs WHERE created_at < ?1",
|
||||
[cutoff],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Pruning old logs failed: {e}")))?;
|
||||
|
||||
Ok(deleted as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
|
||||
#[test]
|
||||
fn test_rollup_and_prune() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let old_ts = now - 40 * 86400; // 40 days ago
|
||||
let recent_ts = now - 5 * 86400; // 5 days ago
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
for i in 0..5 {
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES (?1, 'p1', 'claude', 'claude-3', 100, 50, '0.01', 100, 200, ?2)",
|
||||
rusqlite::params![format!("old-{i}"), old_ts + i as i64],
|
||||
)?;
|
||||
}
|
||||
for i in 0..3 {
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES (?1, 'p1', 'claude', 'claude-3', 200, 100, '0.02', 150, 200, ?2)",
|
||||
rusqlite::params![format!("recent-{i}"), recent_ts + i as i64],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
let deleted = db.rollup_and_prune(30)?;
|
||||
assert_eq!(deleted, 5);
|
||||
|
||||
// Verify rollup data
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT request_count FROM usage_daily_rollups WHERE app_type = 'claude'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(count, 5);
|
||||
|
||||
// Verify recent logs untouched
|
||||
let remaining: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
assert_eq!(remaining, 3);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_noop_when_no_old_data() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
assert_eq!(db.rollup_and_prune(30)?, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_merges_with_existing() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let old_ts = now - 40 * 86400;
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let date_str = chrono::DateTime::from_timestamp(old_ts, 0)
|
||||
.unwrap()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO usage_daily_rollups
|
||||
(date, app_type, provider_id, model, request_count, success_count,
|
||||
input_tokens, output_tokens, total_cost_usd, avg_latency_ms)
|
||||
VALUES (?1, 'claude', 'p1', 'claude-3', 10, 10, 1000, 500, '0.10', 100)",
|
||||
[&date_str],
|
||||
)?;
|
||||
for i in 0..3 {
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES (?1, 'p1', 'claude', 'claude-3', 100, 50, '0.01', 200, 200, ?2)",
|
||||
rusqlite::params![format!("merge-{i}"), old_ts + i as i64],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
let deleted = db.rollup_and_prune(30)?;
|
||||
assert_eq!(deleted, 3);
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let (count, input): (i64, i64) = conn.query_row(
|
||||
"SELECT request_count, input_tokens FROM usage_daily_rollups
|
||||
WHERE app_type = 'claude' AND provider_id = 'p1'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
assert_eq!(count, 13, "10 existing + 3 new");
|
||||
assert_eq!(input, 1300, "1000 existing + 300 new");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 5;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 6;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
@@ -89,6 +89,7 @@ impl Database {
|
||||
/// 数据库文件位于 `~/.cc-switch/cc-switch.db`
|
||||
pub fn init() -> Result<Self, AppError> {
|
||||
let db_path = get_app_config_dir().join("cc-switch.db");
|
||||
let db_exists = db_path.exists();
|
||||
|
||||
// 确保父目录存在
|
||||
if let Some(parent) = db_path.parent() {
|
||||
@@ -100,6 +101,12 @@ impl Database {
|
||||
// 启用外键约束
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
if !db_exists {
|
||||
// For a brand-new database, configure incremental auto-vacuum
|
||||
// before creating any tables so no rebuild is needed later.
|
||||
conn.execute("PRAGMA auto_vacuum = INCREMENTAL;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
register_db_change_hook(&conn);
|
||||
|
||||
let db = Self {
|
||||
@@ -123,8 +130,26 @@ impl Database {
|
||||
}
|
||||
|
||||
db.apply_schema_migrations()?;
|
||||
if let Err(e) = db.ensure_incremental_auto_vacuum() {
|
||||
log::warn!("Failed to ensure incremental auto-vacuum: {e}");
|
||||
}
|
||||
db.ensure_model_pricing_seeded()?;
|
||||
|
||||
// Startup cleanup: prune old logs and reclaim space
|
||||
if let Err(e) = db.cleanup_old_stream_check_logs(7) {
|
||||
log::warn!("Startup stream_check_logs cleanup failed: {e}");
|
||||
}
|
||||
if let Err(e) = db.rollup_and_prune(30) {
|
||||
log::warn!("Startup rollup_and_prune failed: {e}");
|
||||
}
|
||||
// Reclaim disk space after cleanup
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
if let Err(e) = conn.execute_batch("PRAGMA incremental_vacuum;") {
|
||||
log::warn!("Startup incremental vacuum failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
@@ -135,6 +160,8 @@ impl Database {
|
||||
// 启用外键约束
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute("PRAGMA auto_vacuum = INCREMENTAL;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
register_db_change_hook(&conn);
|
||||
|
||||
let db = Self {
|
||||
@@ -146,6 +173,79 @@ impl Database {
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
pub(crate) fn get_auto_vacuum_mode(conn: &Connection) -> Result<i32, AppError> {
|
||||
conn.query_row("PRAGMA auto_vacuum;", [], |row| row.get(0))
|
||||
.map_err(|e| AppError::Database(format!("读取 auto_vacuum 失败: {e}")))
|
||||
}
|
||||
|
||||
fn has_user_tables(conn: &Connection) -> Result<bool, AppError> {
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_incremental_auto_vacuum_on_conn(
|
||||
conn: &Connection,
|
||||
) -> Result<bool, AppError> {
|
||||
let mode = Self::get_auto_vacuum_mode(conn)?;
|
||||
if mode == 2 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let has_tables = Self::has_user_tables(conn)?;
|
||||
conn.execute("PRAGMA auto_vacuum = INCREMENTAL;", [])
|
||||
.map_err(|e| AppError::Database(format!("设置 auto_vacuum 失败: {e}")))?;
|
||||
|
||||
if !has_tables {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
conn.execute("VACUUM;", [])
|
||||
.map_err(|e| AppError::Database(format!("执行 VACUUM 失败: {e}")))?;
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||
.map_err(|e| AppError::Database(format!("恢复 foreign_keys 失败: {e}")))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_incremental_auto_vacuum(&self) -> Result<bool, AppError> {
|
||||
let mode = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
Self::get_auto_vacuum_mode(&conn)?
|
||||
};
|
||||
if mode == 2 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let has_tables = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
Self::has_user_tables(&conn)?
|
||||
};
|
||||
if has_tables {
|
||||
log::info!(
|
||||
"Detected auto_vacuum={mode}, rebuilding database to enable incremental vacuum"
|
||||
);
|
||||
self.backup_database_file()?;
|
||||
}
|
||||
|
||||
let rebuilt = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
Self::ensure_incremental_auto_vacuum_on_conn(&conn)?
|
||||
};
|
||||
|
||||
if rebuilt {
|
||||
log::info!("Incremental auto-vacuum enabled after database rebuild");
|
||||
} else {
|
||||
log::info!("Incremental auto-vacuum configured for new database");
|
||||
}
|
||||
|
||||
Ok(rebuilt)
|
||||
}
|
||||
|
||||
/// 检查 MCP 服务器表是否为空
|
||||
pub fn is_mcp_table_empty(&self) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
@@ -241,6 +241,27 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 17. Usage Daily Rollups 表 (日聚合统计)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS usage_daily_rollups (
|
||||
date TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (date, app_type, provider_id, model)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 尝试添加 live_takeover_active 列到 proxy_config 表
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
|
||||
@@ -360,6 +381,11 @@ impl Database {
|
||||
Self::migrate_v4_to_v5(conn)?;
|
||||
Self::set_user_version(conn, 5)?;
|
||||
}
|
||||
5 => {
|
||||
log::info!("迁移数据库从 v5 到 v6(使用量聚合表)");
|
||||
Self::migrate_v5_to_v6(conn)?;
|
||||
Self::set_user_version(conn, 6)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -914,6 +940,32 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v5 -> v6 迁移:添加使用量日聚合表
|
||||
fn migrate_v5_to_v6(conn: &Connection) -> Result<(), AppError> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS usage_daily_rollups (
|
||||
date TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (date, app_type, provider_id, model)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 usage_daily_rollups 表失败: {e}")))?;
|
||||
|
||||
log::info!("v5 -> v6 迁移完成:已添加使用量日聚合表");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
|
||||
@@ -9,6 +9,7 @@ use indexmap::IndexMap;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
const LEGACY_SCHEMA_SQL: &str = r#"
|
||||
CREATE TABLE providers (
|
||||
@@ -633,3 +634,32 @@ fn schema_model_pricing_is_seeded_on_init() {
|
||||
gemini_count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_incremental_auto_vacuum_rebuilds_existing_file_db() {
|
||||
let temp = NamedTempFile::new().expect("create temp db file");
|
||||
let path = temp.path().to_path_buf();
|
||||
|
||||
let conn = Connection::open(&path).expect("open temp db");
|
||||
conn.execute("PRAGMA auto_vacuum = NONE;", [])
|
||||
.expect("set none auto_vacuum");
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
|
||||
assert_eq!(
|
||||
Database::get_auto_vacuum_mode(&conn).expect("auto_vacuum before rebuild"),
|
||||
0,
|
||||
"existing file db should start with NONE auto_vacuum"
|
||||
);
|
||||
|
||||
let rebuilt =
|
||||
Database::ensure_incremental_auto_vacuum_on_conn(&conn).expect("enable incremental mode");
|
||||
assert!(rebuilt, "existing db should require rebuild via VACUUM");
|
||||
drop(conn);
|
||||
|
||||
let reopened = Connection::open(&path).expect("reopen temp db");
|
||||
assert_eq!(
|
||||
Database::get_auto_vacuum_mode(&reopened).expect("auto_vacuum after rebuild"),
|
||||
2,
|
||||
"file db should persist INCREMENTAL auto_vacuum after VACUUM rebuild"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -805,16 +805,18 @@ pub fn run() {
|
||||
log::warn!("Periodic backup failed on startup: {e}");
|
||||
}
|
||||
|
||||
// Periodic backup timer: check every hour while the app is running
|
||||
// Periodic maintenance timer: run once per day while the app is running
|
||||
let db_for_timer = state.db.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval =
|
||||
tokio::time::interval(std::time::Duration::from_secs(3600));
|
||||
const PERIODIC_MAINTENANCE_INTERVAL_SECS: u64 = 24 * 60 * 60;
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
|
||||
PERIODIC_MAINTENANCE_INTERVAL_SECS,
|
||||
));
|
||||
interval.tick().await; // skip immediate first tick (already checked above)
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(e) = db_for_timer.periodic_backup_if_needed() {
|
||||
log::warn!("Periodic backup timer failed: {e}");
|
||||
log::warn!("Periodic maintenance timer failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1038,6 +1040,7 @@ pub fn run() {
|
||||
// Session manager
|
||||
commands::list_sessions,
|
||||
commands::get_session_messages,
|
||||
commands::delete_session,
|
||||
commands::launch_session_terminal,
|
||||
commands::get_tool_versions,
|
||||
// Provider terminal
|
||||
@@ -1054,6 +1057,8 @@ pub fn run() {
|
||||
// OpenClaw specific
|
||||
commands::import_openclaw_providers_from_live,
|
||||
commands::get_openclaw_live_provider_ids,
|
||||
commands::get_openclaw_live_provider,
|
||||
commands::scan_openclaw_config_health,
|
||||
commands::get_openclaw_default_model,
|
||||
commands::set_openclaw_default_model,
|
||||
commands::get_openclaw_model_catalog,
|
||||
|
||||
+718
-210
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,12 @@ pub struct ProviderMeta {
|
||||
/// 自定义端点列表(按 URL 去重存储)
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
|
||||
/// 是否在写入 live 时应用通用配置片段
|
||||
#[serde(
|
||||
rename = "commonConfigEnabled",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub common_config_enabled: Option<bool>,
|
||||
/// 用量查询脚本配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_script: Option<UsageScript>,
|
||||
@@ -233,6 +239,7 @@ pub struct ProviderMeta {
|
||||
/// Claude API 格式(仅 Claude 供应商使用)
|
||||
/// - "anthropic": 原生 Anthropic Messages API,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||
/// - "openai_responses": OpenAI Responses API 格式,需要转换
|
||||
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
||||
pub api_format: Option<String>,
|
||||
/// Claude 认证字段名(仅 Claude 供应商使用)
|
||||
@@ -243,6 +250,11 @@ pub struct ProviderMeta {
|
||||
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
|
||||
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
|
||||
pub is_full_url: Option<bool>,
|
||||
/// Prompt cache key for OpenAI-compatible endpoints.
|
||||
/// When set, injected into converted requests to improve cache hit rate.
|
||||
/// If not set, provider ID is used automatically during format conversion.
|
||||
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::{
|
||||
body_filter::filter_private_params_with_whitelist,
|
||||
error::*,
|
||||
failover_switch::FailoverSwitchManager,
|
||||
log_codes::fwd as log_fwd,
|
||||
provider_router::ProviderRouter,
|
||||
providers::{get_adapter, ProviderAdapter, ProviderType},
|
||||
thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},
|
||||
@@ -701,13 +702,13 @@ impl RequestForwarder {
|
||||
Some(format!("Provider {} 失败: {}", provider.name, e));
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"[{}] [FWD-001] Provider {} 失败,切换下一个 ({}/{})",
|
||||
app_type_str,
|
||||
provider.name,
|
||||
let (log_code, log_message) = build_retryable_failure_log(
|
||||
&provider.name,
|
||||
attempted_providers,
|
||||
providers.len()
|
||||
providers.len(),
|
||||
&e,
|
||||
);
|
||||
log::warn!("[{app_type_str}] [{log_code}] {log_message}");
|
||||
|
||||
last_error = Some(e);
|
||||
last_provider = Some(provider.clone());
|
||||
@@ -764,7 +765,11 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!("[{app_type_str}] [FWD-002] 所有 Provider 均失败");
|
||||
if let Some((log_code, log_message)) =
|
||||
build_terminal_failure_log(attempted_providers, providers.len(), last_error.as_ref())
|
||||
{
|
||||
log::warn!("[{app_type_str}] [{log_code}] {log_message}");
|
||||
}
|
||||
|
||||
Err(ForwardError {
|
||||
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
|
||||
@@ -815,7 +820,13 @@ impl RequestForwarder {
|
||||
None => (endpoint, None),
|
||||
};
|
||||
if path == "/v1/messages" || path == "/claude/v1/messages" {
|
||||
// 转换到 chat/completions 时剥离 beta=true(Anthropic 专有参数)
|
||||
// 转换到 OpenAI 兼容端点时剥离 beta=true(Anthropic 专有参数)
|
||||
let api_format = super::providers::get_claude_api_format(provider);
|
||||
let target_path = if api_format == "openai_responses" {
|
||||
"/v1/responses"
|
||||
} else {
|
||||
"/v1/chat/completions"
|
||||
};
|
||||
let filtered = query.map(|q| {
|
||||
q.split('&')
|
||||
.filter(|p| !p.starts_with("beta="))
|
||||
@@ -823,8 +834,8 @@ impl RequestForwarder {
|
||||
.join("&")
|
||||
});
|
||||
match filtered.as_deref() {
|
||||
Some(q) if !q.is_empty() => format!("/v1/chat/completions?{q}"),
|
||||
_ => "/v1/chat/completions".to_string(),
|
||||
Some(q) if !q.is_empty() => format!("{target_path}?{q}"),
|
||||
_ => target_path.to_string(),
|
||||
}
|
||||
} else {
|
||||
endpoint.to_string()
|
||||
@@ -1016,3 +1027,191 @@ fn is_bedrock_provider(provider: &Provider) -> bool {
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn build_retryable_failure_log(
|
||||
provider_name: &str,
|
||||
attempted_providers: usize,
|
||||
total_providers: usize,
|
||||
error: &ProxyError,
|
||||
) -> (&'static str, String) {
|
||||
let error_summary = summarize_proxy_error(error);
|
||||
|
||||
if total_providers <= 1 {
|
||||
(
|
||||
log_fwd::SINGLE_PROVIDER_FAILED,
|
||||
format!("Provider {provider_name} 请求失败: {error_summary}"),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
log_fwd::PROVIDER_FAILED_RETRY,
|
||||
format!(
|
||||
"Provider {provider_name} 失败,继续尝试下一个 ({attempted_providers}/{total_providers}): {error_summary}"
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_terminal_failure_log(
|
||||
attempted_providers: usize,
|
||||
total_providers: usize,
|
||||
last_error: Option<&ProxyError>,
|
||||
) -> Option<(&'static str, String)> {
|
||||
if total_providers <= 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let error_summary = last_error
|
||||
.map(summarize_proxy_error)
|
||||
.unwrap_or_else(|| "未知错误".to_string());
|
||||
|
||||
Some((
|
||||
log_fwd::ALL_PROVIDERS_FAILED,
|
||||
format!(
|
||||
"已尝试 {attempted_providers}/{total_providers} 个 Provider,均失败。最后错误: {error_summary}"
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn summarize_proxy_error(error: &ProxyError) -> String {
|
||||
match error {
|
||||
ProxyError::UpstreamError { status, body } => {
|
||||
let body_summary = body
|
||||
.as_deref()
|
||||
.map(summarize_upstream_body)
|
||||
.filter(|summary| !summary.is_empty());
|
||||
|
||||
match body_summary {
|
||||
Some(summary) => format!("上游 HTTP {status}: {summary}"),
|
||||
None => format!("上游 HTTP {status}"),
|
||||
}
|
||||
}
|
||||
ProxyError::Timeout(message) => {
|
||||
format!("请求超时: {}", summarize_text_for_log(message, 180))
|
||||
}
|
||||
ProxyError::ForwardFailed(message) => {
|
||||
format!("请求转发失败: {}", summarize_text_for_log(message, 180))
|
||||
}
|
||||
ProxyError::TransformError(message) => {
|
||||
format!("响应转换失败: {}", summarize_text_for_log(message, 180))
|
||||
}
|
||||
ProxyError::ConfigError(message) => {
|
||||
format!("配置错误: {}", summarize_text_for_log(message, 180))
|
||||
}
|
||||
ProxyError::AuthError(message) => {
|
||||
format!("认证失败: {}", summarize_text_for_log(message, 180))
|
||||
}
|
||||
_ => summarize_text_for_log(&error.to_string(), 180),
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_upstream_body(body: &str) -> String {
|
||||
if let Ok(json_body) = serde_json::from_str::<Value>(body) {
|
||||
if let Some(message) = extract_json_error_message(&json_body) {
|
||||
return summarize_text_for_log(&message, 180);
|
||||
}
|
||||
|
||||
if let Ok(compact_json) = serde_json::to_string(&json_body) {
|
||||
return summarize_text_for_log(&compact_json, 180);
|
||||
}
|
||||
}
|
||||
|
||||
summarize_text_for_log(body, 180)
|
||||
}
|
||||
|
||||
fn extract_json_error_message(body: &Value) -> Option<String> {
|
||||
let candidates = [
|
||||
body.pointer("/error/message"),
|
||||
body.pointer("/message"),
|
||||
body.pointer("/detail"),
|
||||
body.pointer("/error"),
|
||||
];
|
||||
|
||||
candidates
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|value| value.as_str().map(ToString::to_string))
|
||||
}
|
||||
|
||||
fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
|
||||
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let trimmed = normalized.trim();
|
||||
|
||||
if trimmed.chars().count() <= max_chars {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
let truncated: String = trimmed.chars().take(max_chars).collect();
|
||||
let truncated = truncated.trim_end();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn single_provider_retryable_log_uses_single_provider_code() {
|
||||
let error = ProxyError::UpstreamError {
|
||||
status: 429,
|
||||
body: Some(r#"{"error":{"message":"rate limit exceeded"}}"#.to_string()),
|
||||
};
|
||||
|
||||
let (code, message) = build_retryable_failure_log("PackyCode-response", 1, 1, &error);
|
||||
|
||||
assert_eq!(code, log_fwd::SINGLE_PROVIDER_FAILED);
|
||||
assert!(message.contains("Provider PackyCode-response 请求失败"));
|
||||
assert!(message.contains("上游 HTTP 429"));
|
||||
assert!(message.contains("rate limit exceeded"));
|
||||
assert!(!message.contains("切换下一个"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_provider_retryable_log_keeps_failover_wording() {
|
||||
let error = ProxyError::Timeout("upstream timed out after 30s".to_string());
|
||||
|
||||
let (code, message) = build_retryable_failure_log("primary", 1, 3, &error);
|
||||
|
||||
assert_eq!(code, log_fwd::PROVIDER_FAILED_RETRY);
|
||||
assert!(message.contains("继续尝试下一个 (1/3)"));
|
||||
assert!(message.contains("请求超时"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_provider_has_no_terminal_all_failed_log() {
|
||||
assert!(build_terminal_failure_log(1, 1, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_provider_terminal_log_contains_last_error_summary() {
|
||||
let error = ProxyError::ForwardFailed("connection reset by peer".to_string());
|
||||
|
||||
let (code, message) =
|
||||
build_terminal_failure_log(2, 2, Some(&error)).expect("expected terminal log");
|
||||
|
||||
assert_eq!(code, log_fwd::ALL_PROVIDERS_FAILED);
|
||||
assert!(message.contains("已尝试 2/2 个 Provider,均失败"));
|
||||
assert!(message.contains("connection reset by peer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_upstream_body_prefers_json_message() {
|
||||
let body = json!({
|
||||
"error": {
|
||||
"message": "invalid_request_error: unsupported field"
|
||||
},
|
||||
"request_id": "req_123"
|
||||
});
|
||||
|
||||
let summary = summarize_upstream_body(&body.to_string());
|
||||
|
||||
assert_eq!(summary, "invalid_request_error: unsupported field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_text_for_log_collapses_whitespace_and_truncates() {
|
||||
let summary = summarize_text_for_log("line1\n\n line2 line3", 12);
|
||||
|
||||
assert_eq!(summary, "line1 line2...");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ 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},
|
||||
providers::{
|
||||
get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream,
|
||||
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
|
||||
transform_responses,
|
||||
},
|
||||
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
|
||||
server::ProxyState,
|
||||
types::*,
|
||||
@@ -22,6 +26,7 @@ use super::{
|
||||
};
|
||||
use crate::app_config::AppType;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use bytes::Bytes;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
// ============================================================================
|
||||
@@ -114,7 +119,7 @@ pub async fn handle_messages(
|
||||
|
||||
/// Claude 格式转换处理(独有逻辑)
|
||||
///
|
||||
/// 处理 OpenRouter 旧 OpenAI 兼容接口的回退方案(当前默认不启用)
|
||||
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
|
||||
async fn handle_claude_transform(
|
||||
response: reqwest::Response,
|
||||
ctx: &RequestContext,
|
||||
@@ -123,11 +128,18 @@ async fn handle_claude_transform(
|
||||
is_stream: bool,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let status = response.status();
|
||||
let api_format = get_claude_api_format(&ctx.provider);
|
||||
|
||||
if is_stream {
|
||||
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
|
||||
// 根据 api_format 选择流式转换器
|
||||
let stream = response.bytes_stream();
|
||||
let sse_stream = create_anthropic_sse_stream(stream);
|
||||
let sse_stream: Box<
|
||||
dyn futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + Unpin,
|
||||
> = if api_format == "openai_responses" {
|
||||
Box::new(Box::pin(create_anthropic_sse_stream_from_responses(stream)))
|
||||
} else {
|
||||
Box::new(Box::pin(create_anthropic_sse_stream(stream)))
|
||||
};
|
||||
|
||||
// 创建使用量收集器
|
||||
let usage_collector = {
|
||||
@@ -193,7 +205,7 @@ async fn handle_claude_transform(
|
||||
return Ok((headers, body).into_response());
|
||||
}
|
||||
|
||||
// 非流式响应转换 (OpenAI → Anthropic)
|
||||
// 非流式响应转换 (OpenAI/Responses → Anthropic)
|
||||
let response_headers = response.headers().clone();
|
||||
|
||||
let body_bytes = response.bytes().await.map_err(|e| {
|
||||
@@ -203,12 +215,18 @@ async fn handle_claude_transform(
|
||||
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
|
||||
let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||
log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}");
|
||||
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
|
||||
let upstream_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||
log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}");
|
||||
ProxyError::TransformError(format!("Failed to parse upstream response: {e}"))
|
||||
})?;
|
||||
|
||||
let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| {
|
||||
// 根据 api_format 选择非流式转换器
|
||||
let anthropic_response = if api_format == "openai_responses" {
|
||||
transform_responses::responses_to_anthropic(upstream_response)
|
||||
} else {
|
||||
transform::openai_to_anthropic(upstream_response)
|
||||
}
|
||||
.map_err(|e| {
|
||||
log::error!("[Claude] 转换响应失败: {e}");
|
||||
e
|
||||
})?;
|
||||
|
||||
@@ -32,6 +32,7 @@ pub mod srv {
|
||||
pub mod fwd {
|
||||
pub const PROVIDER_FAILED_RETRY: &str = "FWD-001";
|
||||
pub const ALL_PROVIDERS_FAILED: &str = "FWD-002";
|
||||
pub const SINGLE_PROVIDER_FAILED: &str = "FWD-003";
|
||||
}
|
||||
|
||||
/// 故障转移日志码
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Claude (Anthropic) Provider Adapter
|
||||
//!
|
||||
//! 支持透传模式和 OpenAI Chat Completions 格式转换模式
|
||||
//! 支持透传模式和 OpenAI 格式转换模式
|
||||
//!
|
||||
//! ## API 格式
|
||||
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
|
||||
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
|
||||
//! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换
|
||||
//!
|
||||
//! ## 认证模式
|
||||
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||||
@@ -16,6 +17,54 @@ use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
|
||||
/// 获取 Claude 供应商的 API 格式
|
||||
///
|
||||
/// 供 handler/forwarder 外部使用的公开函数。
|
||||
/// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic"
|
||||
pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if let Some(api_format) = meta.api_format.as_deref() {
|
||||
return match api_format {
|
||||
"openai_chat" => "openai_chat",
|
||||
"openai_responses" => "openai_responses",
|
||||
_ => "anthropic",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Backward compatibility: legacy settings_config.api_format
|
||||
if let Some(api_format) = provider
|
||||
.settings_config
|
||||
.get("api_format")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return match api_format {
|
||||
"openai_chat" => "openai_chat",
|
||||
"openai_responses" => "openai_responses",
|
||||
_ => "anthropic",
|
||||
};
|
||||
}
|
||||
|
||||
// 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)
|
||||
let raw = provider.settings_config.get("openrouter_compat_mode");
|
||||
let enabled = match raw {
|
||||
Some(serde_json::Value::Bool(v)) => *v,
|
||||
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"
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if enabled {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude 适配器
|
||||
pub struct ClaudeAdapter;
|
||||
|
||||
@@ -57,48 +106,9 @@ impl ClaudeAdapter {
|
||||
/// 从 provider.meta.api_format 读取格式设置:
|
||||
/// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
/// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||||
fn get_api_format(&self, provider: &Provider) -> &'static str {
|
||||
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if let Some(api_format) = meta.api_format.as_deref() {
|
||||
return if api_format == "openai_chat" {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Backward compatibility: legacy settings_config.api_format
|
||||
if let Some(api_format) = provider
|
||||
.settings_config
|
||||
.get("api_format")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return if api_format == "openai_chat" {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
};
|
||||
}
|
||||
|
||||
// 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)
|
||||
let raw = provider.settings_config.get("openrouter_compat_mode");
|
||||
let enabled = match raw {
|
||||
Some(serde_json::Value::Bool(v)) => *v,
|
||||
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"
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if enabled {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
}
|
||||
get_claude_api_format(provider)
|
||||
}
|
||||
|
||||
/// 检测是否为仅 Bearer 认证模式
|
||||
@@ -291,20 +301,45 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
fn needs_transform(&self, provider: &Provider) -> bool {
|
||||
// 根据 api_format 配置决定是否需要格式转换
|
||||
// - "anthropic" (默认): 直接透传,无需转换
|
||||
// - "openai_chat": 需要 Anthropic ↔ OpenAI 格式转换
|
||||
self.get_api_format(provider) == "openai_chat"
|
||||
// - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换
|
||||
// - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换
|
||||
matches!(
|
||||
self.get_api_format(provider),
|
||||
"openai_chat" | "openai_responses"
|
||||
)
|
||||
}
|
||||
|
||||
fn transform_request(
|
||||
&self,
|
||||
body: serde_json::Value,
|
||||
_provider: &Provider,
|
||||
provider: &Provider,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
super::transform::anthropic_to_openai(body)
|
||||
// Use meta.prompt_cache_key if set by user, otherwise fall back to provider.id
|
||||
let cache_key = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
.unwrap_or(&provider.id);
|
||||
|
||||
match self.get_api_format(provider) {
|
||||
"openai_responses" => {
|
||||
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
|
||||
}
|
||||
_ => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
||||
super::transform::openai_to_anthropic(body)
|
||||
// Heuristic: detect response format by presence of top-level fields.
|
||||
// The ProviderAdapter trait's transform_response doesn't receive the Provider
|
||||
// config, so we can't check api_format here. Instead we rely on the fact that
|
||||
// Responses API always returns "output" while Chat Completions returns "choices".
|
||||
// This is safe because the two formats are structurally disjoint.
|
||||
if body.get("output").is_some() {
|
||||
super::transform_responses::responses_to_anthropic(body)
|
||||
} else {
|
||||
super::transform::openai_to_anthropic(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,6 +620,20 @@ mod tests {
|
||||
);
|
||||
assert!(adapter.needs_transform(&openai_chat_provider));
|
||||
|
||||
// OpenAI Responses format in meta: needs transform
|
||||
let openai_responses_provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_responses".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(adapter.needs_transform(&openai_responses_provider));
|
||||
|
||||
// meta takes precedence over legacy settings_config fields
|
||||
let meta_precedence_over_settings = create_provider_with_meta(
|
||||
json!({
|
||||
|
||||
@@ -18,7 +18,9 @@ mod codex;
|
||||
mod gemini;
|
||||
pub mod models;
|
||||
pub mod streaming;
|
||||
pub mod streaming_responses;
|
||||
pub mod transform;
|
||||
pub mod transform_responses;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::Provider;
|
||||
@@ -27,7 +29,7 @@ use serde::{Deserialize, Serialize};
|
||||
// 公开导出
|
||||
pub use adapter::ProviderAdapter;
|
||||
pub use auth::{AuthInfo, AuthStrategy};
|
||||
pub use claude::ClaudeAdapter;
|
||||
pub use claude::{get_claude_api_format, ClaudeAdapter};
|
||||
pub use codex::CodexAdapter;
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// OpenAI 流式响应数据结构
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -60,6 +61,29 @@ struct Usage {
|
||||
prompt_tokens: u32,
|
||||
#[serde(default)]
|
||||
completion_tokens: u32,
|
||||
#[serde(default)]
|
||||
prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
/// Some compatible servers return Anthropic-style cache fields directly
|
||||
#[serde(default)]
|
||||
cache_read_input_tokens: Option<u32>,
|
||||
#[serde(default)]
|
||||
cache_creation_input_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
/// Nested token details from OpenAI format
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PromptTokensDetails {
|
||||
#[serde(default)]
|
||||
cached_tokens: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ToolBlockState {
|
||||
anthropic_index: u32,
|
||||
id: String,
|
||||
name: String,
|
||||
started: bool,
|
||||
pending_args: String,
|
||||
}
|
||||
|
||||
/// 创建 Anthropic SSE 流
|
||||
@@ -70,10 +94,12 @@ pub fn create_anthropic_sse_stream(
|
||||
let mut buffer = String::new();
|
||||
let mut message_id = None;
|
||||
let mut current_model = None;
|
||||
let mut content_index = 0;
|
||||
let mut next_content_index: u32 = 0;
|
||||
let mut has_sent_message_start = false;
|
||||
let mut current_block_type: Option<String> = None;
|
||||
let mut tool_call_id = None;
|
||||
let mut current_non_tool_block_type: Option<&'static str> = None;
|
||||
let mut current_non_tool_block_index: Option<u32> = None;
|
||||
let mut tool_blocks_by_index: HashMap<usize, ToolBlockState> = HashMap::new();
|
||||
let mut open_tool_block_indices: HashSet<u32> = HashSet::new();
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
@@ -115,6 +141,21 @@ pub fn create_anthropic_sse_stream(
|
||||
|
||||
if let Some(choice) = chunk.choices.first() {
|
||||
if !has_sent_message_start {
|
||||
// Build usage with cache tokens if available from first chunk
|
||||
let mut start_usage = json!({
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
});
|
||||
if let Some(u) = &chunk.usage {
|
||||
start_usage["input_tokens"] = json!(u.prompt_tokens);
|
||||
if let Some(cached) = extract_cache_read_tokens(u) {
|
||||
start_usage["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
if let Some(created) = u.cache_creation_input_tokens {
|
||||
start_usage["cache_creation_input_tokens"] = json!(created);
|
||||
}
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
@@ -122,10 +163,7 @@ pub fn create_anthropic_sse_stream(
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": current_model.clone().unwrap_or_default(),
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
}
|
||||
"usage": start_usage
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: message_start\ndata: {}\n\n",
|
||||
@@ -136,10 +174,21 @@ pub fn create_anthropic_sse_stream(
|
||||
|
||||
// 处理 reasoning(thinking)
|
||||
if let Some(reasoning) = &choice.delta.reasoning {
|
||||
if current_block_type.is_none() {
|
||||
if current_non_tool_block_type != Some("thinking") {
|
||||
if let Some(index) = current_non_tool_block_index.take() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
let index = next_content_index;
|
||||
next_content_index += 1;
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": content_index,
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "thinking",
|
||||
"thinking": ""
|
||||
@@ -148,57 +197,17 @@ pub fn create_anthropic_sse_stream(
|
||||
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("thinking".to_string());
|
||||
current_non_tool_block_type = Some("thinking");
|
||||
current_non_tool_block_index = Some(index);
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": content_index,
|
||||
"delta": {
|
||||
"type": "thinking_delta",
|
||||
"thinking": reasoning
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
|
||||
// 处理文本内容
|
||||
if let Some(content) = &choice.delta.content {
|
||||
if !content.is_empty() {
|
||||
if current_block_type.as_deref() != Some("text") {
|
||||
if current_block_type.is_some() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": content_index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
content_index += 1;
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": content_index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
});
|
||||
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("text".to_string());
|
||||
}
|
||||
|
||||
if let Some(index) = current_non_tool_block_index {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": content_index,
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": content
|
||||
"type": "thinking_delta",
|
||||
"thinking": reasoning
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
@@ -207,76 +216,288 @@ pub fn create_anthropic_sse_stream(
|
||||
}
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if let Some(tool_calls) = &choice.delta.tool_calls {
|
||||
for tool_call in tool_calls {
|
||||
if let Some(id) = &tool_call.id {
|
||||
if current_block_type.is_some() {
|
||||
// 处理文本内容
|
||||
if let Some(content) = &choice.delta.content {
|
||||
if !content.is_empty() {
|
||||
if current_non_tool_block_type != Some("text") {
|
||||
if let Some(index) = current_non_tool_block_index.take() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": content_index
|
||||
"index": index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
content_index += 1;
|
||||
}
|
||||
|
||||
tool_call_id = Some(id.clone());
|
||||
let index = next_content_index;
|
||||
next_content_index += 1;
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
});
|
||||
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_non_tool_block_type = Some("text");
|
||||
current_non_tool_block_index = Some(index);
|
||||
}
|
||||
|
||||
if let Some(function) = &tool_call.function {
|
||||
if let Some(name) = &function.name {
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": content_index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": tool_call_id.clone().unwrap_or_default(),
|
||||
"name": name
|
||||
if let Some(index) = current_non_tool_block_index {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": content
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if let Some(tool_calls) = &choice.delta.tool_calls {
|
||||
if let Some(index) = current_non_tool_block_index.take() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
current_non_tool_block_type = None;
|
||||
|
||||
for tool_call in tool_calls {
|
||||
let (
|
||||
anthropic_index,
|
||||
id,
|
||||
name,
|
||||
should_start,
|
||||
pending_after_start,
|
||||
immediate_delta,
|
||||
) = {
|
||||
let state = tool_blocks_by_index
|
||||
.entry(tool_call.index)
|
||||
.or_insert_with(|| {
|
||||
let index = next_content_index;
|
||||
next_content_index += 1;
|
||||
ToolBlockState {
|
||||
anthropic_index: index,
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
started: false,
|
||||
pending_args: String::new(),
|
||||
}
|
||||
});
|
||||
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());
|
||||
|
||||
if let Some(id) = &tool_call.id {
|
||||
state.id = id.clone();
|
||||
}
|
||||
if let Some(function) = &tool_call.function {
|
||||
if let Some(name) = &function.name {
|
||||
state.name = name.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(args) = &function.arguments {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": content_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": args
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
let should_start =
|
||||
!state.started
|
||||
&& !state.id.is_empty()
|
||||
&& !state.name.is_empty();
|
||||
if should_start {
|
||||
state.started = true;
|
||||
}
|
||||
let pending_after_start = if should_start
|
||||
&& !state.pending_args.is_empty()
|
||||
{
|
||||
Some(std::mem::take(&mut state.pending_args))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let args_delta = tool_call
|
||||
.function
|
||||
.as_ref()
|
||||
.and_then(|f| f.arguments.clone());
|
||||
let immediate_delta = if let Some(args) = args_delta {
|
||||
if state.started {
|
||||
Some(args)
|
||||
} else {
|
||||
state.pending_args.push_str(&args);
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(
|
||||
state.anthropic_index,
|
||||
state.id.clone(),
|
||||
state.name.clone(),
|
||||
should_start,
|
||||
pending_after_start,
|
||||
immediate_delta,
|
||||
)
|
||||
};
|
||||
|
||||
if should_start {
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": anthropic_index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": 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));
|
||||
open_tool_block_indices.insert(anthropic_index);
|
||||
}
|
||||
|
||||
if let Some(args) = pending_after_start {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": anthropic_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": args
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
|
||||
if let Some(args) = immediate_delta {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": anthropic_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": args
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 finish_reason
|
||||
if let Some(finish_reason) = &choice.finish_reason {
|
||||
if current_block_type.is_some() {
|
||||
if let Some(index) = current_non_tool_block_index.take() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": content_index
|
||||
"index": index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
current_non_tool_block_type = None;
|
||||
|
||||
// Late start for blocks that accumulated args before id/name arrived.
|
||||
let mut late_tool_starts: Vec<(u32, String, String, String)> =
|
||||
Vec::new();
|
||||
for (tool_idx, state) in tool_blocks_by_index.iter_mut() {
|
||||
if state.started {
|
||||
continue;
|
||||
}
|
||||
let has_payload = !state.pending_args.is_empty()
|
||||
|| !state.id.is_empty()
|
||||
|| !state.name.is_empty();
|
||||
if !has_payload {
|
||||
continue;
|
||||
}
|
||||
let fallback_id = if state.id.is_empty() {
|
||||
format!("tool_call_{tool_idx}")
|
||||
} else {
|
||||
state.id.clone()
|
||||
};
|
||||
let fallback_name = if state.name.is_empty() {
|
||||
"unknown_tool".to_string()
|
||||
} else {
|
||||
state.name.clone()
|
||||
};
|
||||
state.started = true;
|
||||
let pending = std::mem::take(&mut state.pending_args);
|
||||
late_tool_starts.push((
|
||||
state.anthropic_index,
|
||||
fallback_id,
|
||||
fallback_name,
|
||||
pending,
|
||||
));
|
||||
}
|
||||
late_tool_starts.sort_unstable_by_key(|(index, _, _, _)| *index);
|
||||
for (index, id, name, pending) in late_tool_starts {
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": 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));
|
||||
open_tool_block_indices.insert(index);
|
||||
if !pending.is_empty() {
|
||||
let delta_event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": pending
|
||||
}
|
||||
});
|
||||
let delta_sse = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&delta_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(delta_sse));
|
||||
}
|
||||
}
|
||||
|
||||
if !open_tool_block_indices.is_empty() {
|
||||
let mut tool_indices: Vec<u32> =
|
||||
open_tool_block_indices.iter().copied().collect();
|
||||
tool_indices.sort_unstable();
|
||||
for index in tool_indices {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
open_tool_block_indices.clear();
|
||||
}
|
||||
|
||||
let stop_reason = map_stop_reason(Some(finish_reason));
|
||||
// 构建 usage 信息,包含 input_tokens 和 output_tokens
|
||||
let usage_json = chunk.usage.as_ref().map(|u| json!({
|
||||
"input_tokens": u.prompt_tokens,
|
||||
"output_tokens": u.completion_tokens
|
||||
}));
|
||||
// Build usage with cache token fields
|
||||
let usage_json = chunk.usage.as_ref().map(|u| {
|
||||
let mut uj = json!({
|
||||
"input_tokens": u.prompt_tokens,
|
||||
"output_tokens": u.completion_tokens
|
||||
});
|
||||
if let Some(cached) = extract_cache_read_tokens(u) {
|
||||
uj["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
if let Some(created) = u.cache_creation_input_tokens {
|
||||
uj["cache_creation_input_tokens"] = json!(created);
|
||||
}
|
||||
uj
|
||||
});
|
||||
let event = json!({
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
@@ -314,15 +535,210 @@ pub fn create_anthropic_sse_stream(
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract cache_read tokens from Usage, checking both direct field and nested details
|
||||
fn extract_cache_read_tokens(usage: &Usage) -> Option<u32> {
|
||||
// Direct field takes priority (compatible servers)
|
||||
if let Some(v) = usage.cache_read_input_tokens {
|
||||
return Some(v);
|
||||
}
|
||||
// OpenAI standard: prompt_tokens_details.cached_tokens
|
||||
usage
|
||||
.prompt_tokens_details
|
||||
.as_ref()
|
||||
.map(|d| d.cached_tokens)
|
||||
.filter(|&v| v > 0)
|
||||
}
|
||||
|
||||
/// 映射停止原因
|
||||
fn map_stop_reason(finish_reason: Option<&str>) -> Option<String> {
|
||||
finish_reason.map(|r| {
|
||||
match r {
|
||||
"tool_calls" => "tool_use",
|
||||
"tool_calls" | "function_call" => "tool_use",
|
||||
"stop" => "end_turn",
|
||||
"length" => "max_tokens",
|
||||
_ => "end_turn",
|
||||
"content_filter" => "end_turn",
|
||||
other => {
|
||||
log::warn!("[Claude/OpenRouter] Unknown finish_reason in streaming: {other}");
|
||||
"end_turn"
|
||||
}
|
||||
}
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::stream;
|
||||
use futures::StreamExt;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_map_stop_reason_legacy_and_filtered_values() {
|
||||
assert_eq!(
|
||||
map_stop_reason(Some("function_call")),
|
||||
Some("tool_use".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_stop_reason(Some("content_filter")),
|
||||
Some("end_turn".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_tool_calls_routed_by_index() {
|
||||
let input = concat!(
|
||||
"data: {\"id\":\"chatcmpl_1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"first_tool\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"second_tool\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"{\\\"b\\\":2}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"a\\\":1}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":8,\"completion_tokens\":4}}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let converted = create_anthropic_sse_stream(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
let events: Vec<Value> = merged
|
||||
.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
let data = block.lines().find_map(|line| line.strip_prefix("data: "))?;
|
||||
serde_json::from_str::<Value>(data).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut tool_index_by_call: HashMap<String, u64> = HashMap::new();
|
||||
for event in &events {
|
||||
if event.get("type").and_then(|v| v.as_str()) == Some("content_block_start")
|
||||
&& event
|
||||
.pointer("/content_block/type")
|
||||
.and_then(|v| v.as_str())
|
||||
== Some("tool_use")
|
||||
{
|
||||
if let (Some(call_id), Some(index)) = (
|
||||
event.pointer("/content_block/id").and_then(|v| v.as_str()),
|
||||
event.get("index").and_then(|v| v.as_u64()),
|
||||
) {
|
||||
tool_index_by_call.insert(call_id.to_string(), index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(tool_index_by_call.len(), 2);
|
||||
assert_ne!(
|
||||
tool_index_by_call.get("call_0"),
|
||||
tool_index_by_call.get("call_1")
|
||||
);
|
||||
|
||||
let deltas: Vec<(u64, String)> = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_delta")
|
||||
&& event.pointer("/delta/type").and_then(|v| v.as_str())
|
||||
== Some("input_json_delta")
|
||||
})
|
||||
.filter_map(|event| {
|
||||
let index = event.get("index").and_then(|v| v.as_u64())?;
|
||||
let partial_json = event
|
||||
.pointer("/delta/partial_json")
|
||||
.and_then(|v| v.as_str())?
|
||||
.to_string();
|
||||
Some((index, partial_json))
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(deltas.len(), 2);
|
||||
let second_idx = deltas
|
||||
.iter()
|
||||
.find_map(|(index, payload)| (payload == "{\"b\":2}").then_some(*index))
|
||||
.unwrap();
|
||||
let first_idx = deltas
|
||||
.iter()
|
||||
.find_map(|(index, payload)| (payload == "{\"a\":1}").then_some(*index))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(second_idx, *tool_index_by_call.get("call_1").unwrap());
|
||||
assert_eq!(first_idx, *tool_index_by_call.get("call_0").unwrap());
|
||||
|
||||
assert!(events.iter().any(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("message_delta")
|
||||
&& event.pointer("/delta/stop_reason").and_then(|v| v.as_str()) == Some("tool_use")
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_delays_tool_start_until_id_and_name_ready() {
|
||||
let input = concat!(
|
||||
"data: {\"id\":\"chatcmpl_2\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"a\\\":\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_2\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"first_tool\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_2\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"1}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_2\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":6,\"completion_tokens\":2}}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let converted = create_anthropic_sse_stream(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
let events: Vec<Value> = merged
|
||||
.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
let data = block.lines().find_map(|line| line.strip_prefix("data: "))?;
|
||||
serde_json::from_str::<Value>(data).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let starts: Vec<&Value> = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_start")
|
||||
&& event
|
||||
.pointer("/content_block/type")
|
||||
.and_then(|v| v.as_str())
|
||||
== Some("tool_use")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(
|
||||
starts[0]
|
||||
.pointer("/content_block/id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(""),
|
||||
"call_0"
|
||||
);
|
||||
assert_eq!(
|
||||
starts[0]
|
||||
.pointer("/content_block/name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(""),
|
||||
"first_tool"
|
||||
);
|
||||
|
||||
let deltas: Vec<&str> = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_delta")
|
||||
&& event.pointer("/delta/type").and_then(|v| v.as_str())
|
||||
== Some("input_json_delta")
|
||||
})
|
||||
.filter_map(|event| {
|
||||
event
|
||||
.pointer("/delta/partial_json")
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
.collect();
|
||||
assert!(deltas.contains(&"{\"a\":"));
|
||||
assert!(deltas.contains(&"1}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,902 @@
|
||||
//! OpenAI Responses API 流式转换模块
|
||||
//!
|
||||
//! 实现 Responses API SSE → Anthropic SSE 格式转换。
|
||||
//!
|
||||
//! Responses API 使用命名事件 (named events) 的生命周期模型:
|
||||
//! response.created → output_item.added → content_part.added →
|
||||
//! output_text.delta → content_part.done → output_item.done → response.completed
|
||||
//!
|
||||
//! 与 Chat Completions 的 delta chunk 模型完全不同,需要独立的状态机处理。
|
||||
|
||||
use super::transform_responses::{build_anthropic_usage_from_responses, map_responses_stop_reason};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[inline]
|
||||
fn response_object_from_event(data: &Value) -> &Value {
|
||||
data.get("response").unwrap_or(data)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn content_part_key(data: &Value) -> Option<String> {
|
||||
if let (Some(item_id), Some(content_index)) = (
|
||||
data.get("item_id").and_then(|v| v.as_str()),
|
||||
data.get("content_index").and_then(|v| v.as_u64()),
|
||||
) {
|
||||
return Some(format!("part:{item_id}:{content_index}"));
|
||||
}
|
||||
if let (Some(output_index), Some(content_index)) = (
|
||||
data.get("output_index").and_then(|v| v.as_u64()),
|
||||
data.get("content_index").and_then(|v| v.as_u64()),
|
||||
) {
|
||||
return Some(format!("part:out:{output_index}:{content_index}"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn tool_item_key_from_added(data: &Value, item: &Value) -> Option<String> {
|
||||
if let Some(item_id) = item.get("id").and_then(|v| v.as_str()) {
|
||||
return Some(format!("tool:{item_id}"));
|
||||
}
|
||||
if let Some(item_id) = data.get("item_id").and_then(|v| v.as_str()) {
|
||||
return Some(format!("tool:{item_id}"));
|
||||
}
|
||||
if let Some(output_index) = data.get("output_index").and_then(|v| v.as_u64()) {
|
||||
return Some(format!("tool:out:{output_index}"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn tool_item_key_from_event(data: &Value) -> Option<String> {
|
||||
if let Some(item_id) = data.get("item_id").and_then(|v| v.as_str()) {
|
||||
return Some(format!("tool:{item_id}"));
|
||||
}
|
||||
if let Some(output_index) = data.get("output_index").and_then(|v| v.as_u64()) {
|
||||
return Some(format!("tool:out:{output_index}"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve content index for a text/refusal content part event.
|
||||
///
|
||||
/// Uses `content_part_key` to look up or assign a stable index, falling back to
|
||||
/// `fallback_open_index` when no key is available.
|
||||
#[inline]
|
||||
fn resolve_content_index(
|
||||
data: &Value,
|
||||
next_content_index: &mut u32,
|
||||
index_by_key: &mut HashMap<String, u32>,
|
||||
fallback_open_index: &mut Option<u32>,
|
||||
) -> u32 {
|
||||
if let Some(k) = content_part_key(data) {
|
||||
if let Some(existing) = index_by_key.get(&k).copied() {
|
||||
existing
|
||||
} else {
|
||||
let assigned = *next_content_index;
|
||||
*next_content_index += 1;
|
||||
index_by_key.insert(k, assigned);
|
||||
assigned
|
||||
}
|
||||
} else if let Some(existing) = *fallback_open_index {
|
||||
existing
|
||||
} else {
|
||||
let assigned = *next_content_index;
|
||||
*next_content_index += 1;
|
||||
*fallback_open_index = Some(assigned);
|
||||
assigned
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建从 Responses API SSE 到 Anthropic SSE 的转换流
|
||||
///
|
||||
/// 状态机跟踪: message_id, current_model, has_sent_message_start, item/content index map
|
||||
/// SSE 解析支持 named events (event: + data: 行)
|
||||
pub fn create_anthropic_sse_stream_from_responses(
|
||||
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut message_id: Option<String> = None;
|
||||
let mut current_model: Option<String> = None;
|
||||
let mut has_sent_message_start = false;
|
||||
let mut has_tool_use = false;
|
||||
let mut next_content_index: u32 = 0;
|
||||
let mut index_by_key: HashMap<String, u32> = HashMap::new();
|
||||
let mut open_indices: HashSet<u32> = HashSet::new();
|
||||
let mut fallback_open_index: Option<u32> = None;
|
||||
let mut tool_index_by_item_id: HashMap<String, u32> = HashMap::new();
|
||||
let mut last_tool_index: Option<u32> = None;
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
|
||||
// SSE 事件由 \n\n 分隔
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let block = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
if block.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析 SSE 块:提取 event: 和 data: 行
|
||||
let mut event_type: Option<String> = None;
|
||||
let mut data_parts: Vec<String> = Vec::new();
|
||||
|
||||
for line in block.lines() {
|
||||
if let Some(evt) = line.strip_prefix("event: ") {
|
||||
event_type = Some(evt.trim().to_string());
|
||||
} else if let Some(d) = line.strip_prefix("data: ") {
|
||||
data_parts.push(d.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if data_parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data_str = data_parts.join("\n");
|
||||
let event_name = event_type.as_deref().unwrap_or("");
|
||||
|
||||
// 解析 JSON 数据
|
||||
let data: Value = match serde_json::from_str(&data_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
log::debug!("[Claude/Responses] <<< SSE event: {event_name}");
|
||||
|
||||
match event_name {
|
||||
// ================================================
|
||||
// response.created → message_start
|
||||
// ================================================
|
||||
"response.created" => {
|
||||
let response_obj = response_object_from_event(&data);
|
||||
if let Some(id) = response_obj.get("id").and_then(|i| i.as_str()) {
|
||||
message_id = Some(id.to_string());
|
||||
}
|
||||
if let Some(model) =
|
||||
response_obj.get("model").and_then(|m| m.as_str())
|
||||
{
|
||||
current_model = Some(model.to_string());
|
||||
}
|
||||
|
||||
has_sent_message_start = true;
|
||||
// Build usage with cache tokens if available
|
||||
let start_usage = build_anthropic_usage_from_responses(
|
||||
response_obj.get("usage"),
|
||||
);
|
||||
|
||||
let event = json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": message_id.clone().unwrap_or_default(),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": current_model.clone().unwrap_or_default(),
|
||||
"usage": start_usage
|
||||
}
|
||||
});
|
||||
let sse = format!("event: message_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
log::debug!("[Claude/Responses] >>> Anthropic SSE: message_start");
|
||||
yield Ok(Bytes::from(sse));
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.content_part.added → content_block_start (text)
|
||||
// ================================================
|
||||
"response.content_part.added" => {
|
||||
// 确保 message_start 已发送
|
||||
if !has_sent_message_start {
|
||||
let start_event = json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": message_id.clone().unwrap_or_default(),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": current_model.clone().unwrap_or_default(),
|
||||
"usage": { "input_tokens": 0, "output_tokens": 0 }
|
||||
}
|
||||
});
|
||||
let sse = format!("event: message_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&start_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
has_sent_message_start = true;
|
||||
}
|
||||
|
||||
if let Some(part) = data.get("part") {
|
||||
let part_type = part.get("type").and_then(|t| t.as_str());
|
||||
if matches!(part_type, Some("output_text") | Some("refusal")) {
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
|
||||
if open_indices.contains(&index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
});
|
||||
let sse = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
open_indices.insert(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.output_text.delta → content_block_delta (text_delta)
|
||||
// ================================================
|
||||
"response.output_text.delta" => {
|
||||
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
|
||||
if !open_indices.contains(&index) {
|
||||
let start_event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
});
|
||||
let start_sse = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&start_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(start_sse));
|
||||
open_indices.insert(index);
|
||||
}
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": delta
|
||||
}
|
||||
});
|
||||
let sse = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.refusal.delta → content_block_delta (text_delta)
|
||||
// ================================================
|
||||
"response.refusal.delta" => {
|
||||
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
|
||||
if !open_indices.contains(&index) {
|
||||
let start_event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
});
|
||||
let start_sse = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&start_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(start_sse));
|
||||
open_indices.insert(index);
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": delta
|
||||
}
|
||||
});
|
||||
let sse = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.content_part.done → content_block_stop
|
||||
// ================================================
|
||||
"response.content_part.done" => {
|
||||
let key = content_part_key(&data);
|
||||
let index = if let Some(k) = key {
|
||||
index_by_key.get(&k).copied()
|
||||
} else {
|
||||
fallback_open_index
|
||||
};
|
||||
if let Some(index) = index {
|
||||
if !open_indices.remove(&index) {
|
||||
continue;
|
||||
}
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
if fallback_open_index == Some(index) {
|
||||
fallback_open_index = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.output_item.added (function_call) → content_block_start (tool_use)
|
||||
// ================================================
|
||||
"response.output_item.added" => {
|
||||
if let Some(item) = data.get("item") {
|
||||
let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if item_type == "function_call" {
|
||||
has_tool_use = true;
|
||||
// 确保 message_start 已发送
|
||||
if !has_sent_message_start {
|
||||
let start_event = json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": message_id.clone().unwrap_or_default(),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": current_model.clone().unwrap_or_default(),
|
||||
"usage": { "input_tokens": 0, "output_tokens": 0 }
|
||||
}
|
||||
});
|
||||
let sse = format!("event: message_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&start_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
has_sent_message_start = true;
|
||||
}
|
||||
|
||||
let call_id = item.get("call_id").and_then(|i| i.as_str()).unwrap_or("");
|
||||
let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
let index = if let Some(k) = tool_item_key_from_added(&data, item) {
|
||||
if let Some(existing) = index_by_key.get(&k).copied() {
|
||||
existing
|
||||
} else {
|
||||
let assigned = next_content_index;
|
||||
next_content_index += 1;
|
||||
index_by_key.insert(k, assigned);
|
||||
assigned
|
||||
}
|
||||
} else {
|
||||
let assigned = next_content_index;
|
||||
next_content_index += 1;
|
||||
assigned
|
||||
};
|
||||
if let Some(item_id) = item
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| data.get("item_id").and_then(|v| v.as_str()))
|
||||
{
|
||||
tool_index_by_item_id.insert(item_id.to_string(), index);
|
||||
}
|
||||
last_tool_index = Some(index);
|
||||
|
||||
if open_indices.contains(&index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": call_id,
|
||||
"name": name
|
||||
}
|
||||
});
|
||||
let sse = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
open_indices.insert(index);
|
||||
}
|
||||
// message type output_item.added is handled via content_part.added
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.function_call_arguments.delta → content_block_delta (input_json_delta)
|
||||
// ================================================
|
||||
"response.function_call_arguments.delta" => {
|
||||
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
|
||||
let item_id = data.get("item_id").and_then(|v| v.as_str());
|
||||
let index = if let Some(id) = item_id {
|
||||
tool_index_by_item_id.get(id).copied()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.or_else(|| {
|
||||
tool_item_key_from_event(&data)
|
||||
.and_then(|k| index_by_key.get(&k).copied())
|
||||
})
|
||||
.or(last_tool_index)
|
||||
.unwrap_or_else(|| {
|
||||
let assigned = next_content_index;
|
||||
next_content_index += 1;
|
||||
assigned
|
||||
});
|
||||
|
||||
if !open_indices.contains(&index) {
|
||||
let start_event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": data
|
||||
.get("call_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.or(item_id)
|
||||
.unwrap_or(""),
|
||||
"name": data
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
}
|
||||
});
|
||||
let start_sse = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&start_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(start_sse));
|
||||
open_indices.insert(index);
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": delta
|
||||
}
|
||||
});
|
||||
let sse = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.function_call_arguments.done → content_block_stop
|
||||
// ================================================
|
||||
"response.function_call_arguments.done" => {
|
||||
let item_id = data.get("item_id").and_then(|v| v.as_str());
|
||||
let index = if let Some(id) = item_id {
|
||||
tool_index_by_item_id.get(id).copied()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.or_else(|| {
|
||||
tool_item_key_from_event(&data)
|
||||
.and_then(|k| index_by_key.get(&k).copied())
|
||||
})
|
||||
.or(last_tool_index);
|
||||
if let Some(index) = index {
|
||||
if !open_indices.remove(&index) {
|
||||
continue;
|
||||
}
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
if let Some(item_id) = item_id {
|
||||
tool_index_by_item_id.remove(item_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.refusal.done → content_block_stop
|
||||
// ================================================
|
||||
"response.refusal.done" => {
|
||||
let key = content_part_key(&data);
|
||||
let index = if let Some(k) = key {
|
||||
index_by_key.get(&k).copied()
|
||||
} else {
|
||||
fallback_open_index
|
||||
};
|
||||
if let Some(index) = index {
|
||||
if !open_indices.remove(&index) {
|
||||
continue;
|
||||
}
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
if fallback_open_index == Some(index) {
|
||||
fallback_open_index = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.reasoning.delta → content_block_delta (thinking_delta)
|
||||
// ================================================
|
||||
"response.reasoning.delta" => {
|
||||
if let Some(delta) = data
|
||||
.get("delta")
|
||||
.or_else(|| data.get("text"))
|
||||
.and_then(|d| d.as_str())
|
||||
{
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
|
||||
if !open_indices.contains(&index) {
|
||||
let start_event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "thinking",
|
||||
"thinking": ""
|
||||
}
|
||||
});
|
||||
let start_sse = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&start_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(start_sse));
|
||||
open_indices.insert(index);
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "thinking_delta",
|
||||
"thinking": delta
|
||||
}
|
||||
});
|
||||
let sse = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.reasoning.done → content_block_stop
|
||||
// ================================================
|
||||
"response.reasoning.done" => {
|
||||
let key = content_part_key(&data);
|
||||
let index = if let Some(k) = key {
|
||||
index_by_key.get(&k).copied()
|
||||
} else {
|
||||
fallback_open_index
|
||||
};
|
||||
if let Some(index) = index {
|
||||
if !open_indices.remove(&index) {
|
||||
continue;
|
||||
}
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
if fallback_open_index == Some(index) {
|
||||
fallback_open_index = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.completed → message_delta + message_stop
|
||||
// ================================================
|
||||
"response.completed" => {
|
||||
let response_obj = response_object_from_event(&data);
|
||||
let stop_reason = map_responses_stop_reason(
|
||||
response_obj.get("status").and_then(|s| s.as_str()),
|
||||
has_tool_use,
|
||||
response_obj
|
||||
.pointer("/incomplete_details/reason")
|
||||
.and_then(|r| r.as_str()),
|
||||
);
|
||||
|
||||
// Best effort: close any dangling blocks before message_delta/message_stop.
|
||||
if !open_indices.is_empty() {
|
||||
let mut remaining: Vec<u32> = open_indices.iter().copied().collect();
|
||||
remaining.sort_unstable();
|
||||
for index in remaining {
|
||||
let stop_event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&stop_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(stop_sse));
|
||||
open_indices.remove(&index);
|
||||
}
|
||||
}
|
||||
fallback_open_index = None;
|
||||
|
||||
let usage_json = response_obj.get("usage").map(|u| {
|
||||
build_anthropic_usage_from_responses(Some(u))
|
||||
});
|
||||
|
||||
// Emit message_delta (with usage + stop_reason)
|
||||
let delta_event = json!({
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": null
|
||||
},
|
||||
"usage": usage_json
|
||||
});
|
||||
let sse = format!("event: message_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&delta_event).unwrap_or_default());
|
||||
log::debug!("[Claude/Responses] >>> Anthropic SSE: message_delta");
|
||||
yield Ok(Bytes::from(sse));
|
||||
|
||||
// Emit message_stop
|
||||
let stop_event = json!({"type": "message_stop"});
|
||||
let stop_sse = format!("event: message_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&stop_event).unwrap_or_default());
|
||||
log::debug!("[Claude/Responses] >>> Anthropic SSE: message_stop");
|
||||
yield Ok(Bytes::from(stop_sse));
|
||||
}
|
||||
|
||||
// Lifecycle events that don't need Anthropic counterparts.
|
||||
// Listed explicitly so new events trigger a match-completeness review.
|
||||
"response.output_text.done"
|
||||
| "response.output_item.done"
|
||||
| "response.in_progress" => {}
|
||||
|
||||
// Any other unknown/future events — silently skip.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Responses stream error: {e}");
|
||||
let error_event = json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "stream_error",
|
||||
"message": format!("Stream error: {e}")
|
||||
}
|
||||
});
|
||||
let sse = format!("event: error\ndata: {}\n\n",
|
||||
serde_json::to_string(&error_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::stream;
|
||||
use futures::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_map_responses_stop_reason_tool_use() {
|
||||
assert_eq!(
|
||||
map_responses_stop_reason(Some("completed"), true, None),
|
||||
Some("tool_use")
|
||||
);
|
||||
assert_eq!(
|
||||
map_responses_stop_reason(Some("completed"), false, None),
|
||||
Some("end_turn")
|
||||
);
|
||||
assert_eq!(
|
||||
map_responses_stop_reason(Some("incomplete"), false, Some("max_output_tokens")),
|
||||
Some("max_tokens")
|
||||
);
|
||||
assert_eq!(
|
||||
map_responses_stop_reason(Some("incomplete"), false, Some("content_filter")),
|
||||
Some("end_turn")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_object_from_event_with_wrapper() {
|
||||
let data = json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_1",
|
||||
"model": "gpt-4o"
|
||||
}
|
||||
});
|
||||
let obj = response_object_from_event(&data);
|
||||
assert_eq!(obj["id"], "resp_1");
|
||||
assert_eq!(obj["model"], "gpt-4o");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_conversion_with_wrapped_response_events() {
|
||||
let input = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-4o\",\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n",
|
||||
"event: response.output_item.added\n",
|
||||
"data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"get_weather\"}}\n\n",
|
||||
"event: response.function_call_arguments.delta\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}\n\n",
|
||||
"event: response.function_call_arguments.done\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.done\"}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":3}}}\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
assert!(merged.contains("\"type\":\"message_start\""));
|
||||
assert!(merged.contains("\"id\":\"resp_1\""));
|
||||
assert!(merged.contains("\"model\":\"gpt-4o\""));
|
||||
assert!(merged.contains("\"type\":\"tool_use\""));
|
||||
assert!(merged.contains("\"name\":\"get_weather\""));
|
||||
assert!(merged.contains("\"type\":\"input_json_delta\""));
|
||||
assert!(merged.contains("\"stop_reason\":\"tool_use\""));
|
||||
assert!(merged.contains("\"input_tokens\":12"));
|
||||
assert!(merged.contains("\"output_tokens\":3"));
|
||||
assert!(merged.contains("\"type\":\"message_stop\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_conversion_interleaved_tool_deltas_by_item_id() {
|
||||
let input = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_2\",\"model\":\"gpt-4o\"}}\n\n",
|
||||
"event: response.output_item.added\n",
|
||||
"data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_1\",\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"first_tool\"}}\n\n",
|
||||
"event: response.output_item.added\n",
|
||||
"data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_2\",\"type\":\"function_call\",\"call_id\":\"call_2\",\"name\":\"second_tool\"}}\n\n",
|
||||
"event: response.function_call_arguments.delta\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_2\",\"delta\":\"{\\\"b\\\":2}\"}\n\n",
|
||||
"event: response.function_call_arguments.delta\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_1\",\"delta\":\"{\\\"a\\\":1}\"}\n\n",
|
||||
"event: response.function_call_arguments.done\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.done\",\"item_id\":\"fc_1\"}\n\n",
|
||||
"event: response.function_call_arguments.done\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.done\",\"item_id\":\"fc_2\"}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":8,\"output_tokens\":4}}}\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
let events: Vec<Value> = merged
|
||||
.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
let data = block.lines().find_map(|line| line.strip_prefix("data: "))?;
|
||||
serde_json::from_str::<Value>(data).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut tool_index_by_call: HashMap<String, u64> = HashMap::new();
|
||||
for event in &events {
|
||||
if event.get("type").and_then(|v| v.as_str()) == Some("content_block_start") {
|
||||
let cb = event.get("content_block");
|
||||
if cb.and_then(|v| v.get("type")).and_then(|v| v.as_str()) == Some("tool_use") {
|
||||
if let (Some(call_id), Some(index)) = (
|
||||
cb.and_then(|v| v.get("id")).and_then(|v| v.as_str()),
|
||||
event.get("index").and_then(|v| v.as_u64()),
|
||||
) {
|
||||
tool_index_by_call.insert(call_id.to_string(), index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let delta_indices: Vec<u64> = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_delta")
|
||||
&& event.pointer("/delta/type").and_then(|v| v.as_str())
|
||||
== Some("input_json_delta")
|
||||
})
|
||||
.filter_map(|event| event.get("index").and_then(|v| v.as_u64()))
|
||||
.collect();
|
||||
|
||||
assert_eq!(delta_indices.len(), 2);
|
||||
assert_eq!(delta_indices[0], *tool_index_by_call.get("call_2").unwrap());
|
||||
assert_eq!(delta_indices[1], *tool_index_by_call.get("call_1").unwrap());
|
||||
assert_ne!(
|
||||
tool_index_by_call.get("call_1"),
|
||||
tool_index_by_call.get("call_2")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_reasoning_delta_emits_thinking_blocks() {
|
||||
let input = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_r\",\"model\":\"o3\",\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\n\n",
|
||||
"event: response.reasoning.delta\n",
|
||||
"data: {\"type\":\"response.reasoning.delta\",\"delta\":\"Let me think...\"}\n\n",
|
||||
"event: response.reasoning.done\n",
|
||||
"data: {\"type\":\"response.reasoning.done\"}\n\n",
|
||||
"event: response.content_part.added\n",
|
||||
"data: {\"type\":\"response.content_part.added\",\"part\":{\"type\":\"output_text\",\"text\":\"\"},\"output_index\":0,\"content_index\":0}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"42\",\"output_index\":0,\"content_index\":0}\n\n",
|
||||
"event: response.content_part.done\n",
|
||||
"data: {\"type\":\"response.content_part.done\",\"output_index\":0,\"content_index\":0}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":10}}}\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
// Should contain thinking block start, thinking delta, and text content
|
||||
assert!(
|
||||
merged.contains("\"type\":\"thinking\""),
|
||||
"should emit thinking content_block_start"
|
||||
);
|
||||
assert!(
|
||||
merged.contains("\"type\":\"thinking_delta\""),
|
||||
"should emit thinking_delta"
|
||||
);
|
||||
assert!(
|
||||
merged.contains("\"thinking\":\"Let me think...\""),
|
||||
"should contain thinking text"
|
||||
);
|
||||
assert!(
|
||||
merged.contains("\"type\":\"text_delta\""),
|
||||
"should also emit text content"
|
||||
);
|
||||
assert!(
|
||||
merged.contains("\"text\":\"42\""),
|
||||
"should contain text delta"
|
||||
);
|
||||
assert!(merged.contains("\"stop_reason\":\"end_turn\""));
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ use crate::proxy::error::ProxyError;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Anthropic 请求 → OpenAI 请求
|
||||
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
///
|
||||
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
|
||||
pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
|
||||
@@ -23,10 +25,14 @@ pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
// 单个字符串
|
||||
messages.push(json!({"role": "system", "content": text}));
|
||||
} else if let Some(arr) = system.as_array() {
|
||||
// 多个 system message
|
||||
// 多个 system message — preserve cache_control for compatible proxies
|
||||
for msg in arr {
|
||||
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
|
||||
messages.push(json!({"role": "system", "content": text}));
|
||||
let mut sys_msg = json!({"role": "system", "content": text});
|
||||
if let Some(cc) = msg.get("cache_control") {
|
||||
sys_msg["cache_control"] = cc.clone();
|
||||
}
|
||||
messages.push(sys_msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,14 +73,18 @@ pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
.iter()
|
||||
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
|
||||
.map(|t| {
|
||||
json!({
|
||||
let mut tool = 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!({})))
|
||||
}
|
||||
})
|
||||
});
|
||||
if let Some(cc) = t.get("cache_control") {
|
||||
tool["cache_control"] = cc.clone();
|
||||
}
|
||||
tool
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -87,6 +97,11 @@ pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
result["tool_choice"] = v.clone();
|
||||
}
|
||||
|
||||
// Inject prompt_cache_key for improved cache routing on OpenAI-compatible endpoints
|
||||
if let Some(key) = cache_key {
|
||||
result["prompt_cache_key"] = json!(key);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -122,7 +137,11 @@ fn convert_message_to_openai(
|
||||
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}));
|
||||
let mut part = json!({"type": "text", "text": text});
|
||||
if let Some(cc) = block.get("cache_control") {
|
||||
part["cache_control"] = cc.clone();
|
||||
}
|
||||
content_parts.push(part);
|
||||
}
|
||||
}
|
||||
"image" => {
|
||||
@@ -184,8 +203,14 @@ fn convert_message_to_openai(
|
||||
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();
|
||||
// When cache_control is present, keep array format to preserve it
|
||||
let has_cache_control = content_parts[0].get("cache_control").is_some();
|
||||
if !has_cache_control {
|
||||
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);
|
||||
}
|
||||
@@ -210,7 +235,7 @@ fn convert_message_to_openai(
|
||||
}
|
||||
|
||||
/// 清理 JSON schema(移除不支持的 format)
|
||||
fn clean_schema(mut schema: Value) -> Value {
|
||||
pub 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") {
|
||||
@@ -247,16 +272,49 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
|
||||
|
||||
let mut content = Vec::new();
|
||||
let mut has_tool_use = false;
|
||||
|
||||
// 文本内容
|
||||
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(msg_content) = message.get("content") {
|
||||
if let Some(text) = msg_content.as_str() {
|
||||
if !text.is_empty() {
|
||||
content.push(json!({"type": "text", "text": text}));
|
||||
}
|
||||
} else if let Some(parts) = msg_content.as_array() {
|
||||
for part in parts {
|
||||
let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
match part_type {
|
||||
"text" | "output_text" => {
|
||||
if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
|
||||
if !text.is_empty() {
|
||||
content.push(json!({"type": "text", "text": text}));
|
||||
}
|
||||
}
|
||||
}
|
||||
"refusal" => {
|
||||
if let Some(refusal) = part.get("refusal").and_then(|r| r.as_str()) {
|
||||
if !refusal.is_empty() {
|
||||
content.push(json!({"type": "text", "text": refusal}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Some providers put refusal at message-level.
|
||||
if let Some(refusal) = message.get("refusal").and_then(|r| r.as_str()) {
|
||||
if !refusal.is_empty() {
|
||||
content.push(json!({"type": "text", "text": refusal}));
|
||||
}
|
||||
}
|
||||
|
||||
// 工具调用
|
||||
// 工具调用(tool_calls)
|
||||
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
|
||||
if !tool_calls.is_empty() {
|
||||
has_tool_use = true;
|
||||
}
|
||||
for tc in tool_calls {
|
||||
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||
let empty_obj = json!({});
|
||||
@@ -276,6 +334,36 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
}));
|
||||
}
|
||||
}
|
||||
// 兼容旧格式(function_call)
|
||||
if !has_tool_use {
|
||||
if let Some(function_call) = message.get("function_call") {
|
||||
let id = function_call
|
||||
.get("id")
|
||||
.and_then(|i| i.as_str())
|
||||
.unwrap_or("");
|
||||
let name = function_call
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("");
|
||||
let has_arguments = function_call.get("arguments").is_some();
|
||||
|
||||
let input = match function_call.get("arguments") {
|
||||
Some(Value::String(s)) => serde_json::from_str(s).unwrap_or(json!({})),
|
||||
Some(v @ Value::Object(_)) | Some(v @ Value::Array(_)) => v.clone(),
|
||||
_ => json!({}),
|
||||
};
|
||||
|
||||
if !name.is_empty() || has_arguments {
|
||||
content.push(json!({
|
||||
"type": "tool_use",
|
||||
"id": id,
|
||||
"name": name,
|
||||
"input": input
|
||||
}));
|
||||
has_tool_use = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 映射 finish_reason → stop_reason
|
||||
let stop_reason = choice
|
||||
@@ -284,11 +372,18 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
.map(|r| match r {
|
||||
"stop" => "end_turn",
|
||||
"length" => "max_tokens",
|
||||
"tool_calls" => "tool_use",
|
||||
other => other,
|
||||
});
|
||||
"tool_calls" | "function_call" => "tool_use",
|
||||
"content_filter" => "end_turn",
|
||||
other => {
|
||||
log::warn!(
|
||||
"[Claude/OpenAI] Unknown finish_reason in non-streaming response: {other}"
|
||||
);
|
||||
"end_turn"
|
||||
}
|
||||
})
|
||||
.or(if has_tool_use { Some("tool_use") } else { None });
|
||||
|
||||
// usage
|
||||
// usage — map cache tokens from OpenAI format to Anthropic format
|
||||
let usage = body.get("usage").cloned().unwrap_or(json!({}));
|
||||
let input_tokens = usage
|
||||
.get("prompt_tokens")
|
||||
@@ -299,6 +394,26 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
let mut usage_json = json!({
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens
|
||||
});
|
||||
|
||||
// OpenAI standard: prompt_tokens_details.cached_tokens
|
||||
if let Some(cached) = usage
|
||||
.pointer("/prompt_tokens_details/cached_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
usage_json["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
// Some compatible servers return these fields directly
|
||||
if let Some(v) = usage.get("cache_read_input_tokens") {
|
||||
usage_json["cache_read_input_tokens"] = v.clone();
|
||||
}
|
||||
if let Some(v) = usage.get("cache_creation_input_tokens") {
|
||||
usage_json["cache_creation_input_tokens"] = v.clone();
|
||||
}
|
||||
|
||||
let result = json!({
|
||||
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
|
||||
"type": "message",
|
||||
@@ -307,10 +422,7 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
"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
|
||||
}
|
||||
"usage": usage_json
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
@@ -328,7 +440,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["model"], "claude-3-opus");
|
||||
assert_eq!(result["max_tokens"], 1024);
|
||||
assert_eq!(result["messages"][0]["role"], "user");
|
||||
@@ -344,7 +456,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
@@ -366,7 +478,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["tools"][0]["type"], "function");
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
@@ -385,7 +497,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "assistant");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
@@ -405,7 +517,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "tool");
|
||||
assert_eq!(msg["tool_call_id"], "call_123");
|
||||
@@ -477,7 +589,187 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["model"], "gpt-4o");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_with_cache_key() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, Some("provider-123")).unwrap();
|
||||
assert_eq!(result["prompt_cache_key"], "provider-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_no_cache_key() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert!(result.get("prompt_cache_key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_cache_control_preserved() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "System prompt", "cache_control": {"type": "ephemeral"}}
|
||||
],
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
]
|
||||
}],
|
||||
"tools": [{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"input_schema": {"type": "object"},
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
// System message cache_control preserved
|
||||
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
|
||||
// Text block cache_control preserved
|
||||
assert_eq!(
|
||||
result["messages"][1]["content"][0]["cache_control"]["type"],
|
||||
"ephemeral"
|
||||
);
|
||||
assert_eq!(
|
||||
result["messages"][1]["content"][0]["cache_control"]["ttl"],
|
||||
"5m"
|
||||
);
|
||||
// Tool cache_control preserved
|
||||
assert_eq!(result["tools"][0]["cache_control"]["type"], "ephemeral");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_with_cache_tokens() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Hello!"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 80
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["usage"]["input_tokens"], 100);
|
||||
assert_eq!(result["usage"]["output_tokens"], 50);
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_with_direct_cache_fields() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Hello!"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"cache_read_input_tokens": 60,
|
||||
"cache_creation_input_tokens": 20
|
||||
}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
|
||||
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_finish_reason_content_filter_maps_end_turn() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Blocked"},
|
||||
"finish_reason": "content_filter"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 1}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["stop_reason"], "end_turn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_with_legacy_function_call() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"function_call": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"location\":\"Tokyo\"}"
|
||||
}
|
||||
},
|
||||
"finish_reason": "function_call"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["content"][0]["type"], "tool_use");
|
||||
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_openai_to_anthropic_with_content_parts_and_refusal() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello"},
|
||||
{"type": "refusal", "refusal": "I can't do that"}
|
||||
]
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["content"][0]["type"], "text");
|
||||
assert_eq!(result["content"][0]["text"], "Hello");
|
||||
assert_eq!(result["content"][1]["type"], "text");
|
||||
assert_eq!(result["content"][1]["text"], "I can't do that");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,900 @@
|
||||
//! OpenAI Responses API 格式转换模块
|
||||
//!
|
||||
//! 实现 Anthropic Messages ↔ OpenAI Responses API 格式转换。
|
||||
//! Responses API 是 OpenAI 2025 年推出的新一代 API,采用扁平化的 input/output 结构。
|
||||
//!
|
||||
//! 与 Chat Completions 的主要差异:
|
||||
//! - tool_use/tool_result 从 message content 中"提升"为顶层 input item
|
||||
//! - system prompt 使用 `instructions` 字段而非 system role message
|
||||
//! - usage 字段命名与 Anthropic 一致 (input_tokens/output_tokens)
|
||||
|
||||
use crate::proxy::error::ProxyError;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Anthropic 请求 → OpenAI Responses 请求
|
||||
///
|
||||
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
|
||||
pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
|
||||
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
|
||||
result["model"] = json!(model);
|
||||
}
|
||||
|
||||
// system → instructions (Responses API 使用 instructions 字段)
|
||||
if let Some(system) = body.get("system") {
|
||||
let instructions = if let Some(text) = system.as_str() {
|
||||
text.to_string()
|
||||
} else if let Some(arr) = system.as_array() {
|
||||
arr.iter()
|
||||
.filter_map(|msg| msg.get("text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if !instructions.is_empty() {
|
||||
result["instructions"] = json!(instructions);
|
||||
}
|
||||
}
|
||||
|
||||
// messages → input
|
||||
if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) {
|
||||
let input = convert_messages_to_input(msgs)?;
|
||||
result["input"] = json!(input);
|
||||
}
|
||||
|
||||
// max_tokens → max_output_tokens
|
||||
if let Some(v) = body.get("max_tokens") {
|
||||
result["max_output_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("stream") {
|
||||
result["stream"] = v.clone();
|
||||
}
|
||||
|
||||
// stop_sequences → 丢弃 (Responses API 不支持)
|
||||
|
||||
// 转换 tools (过滤 BatchTool)
|
||||
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
|
||||
let response_tools: Vec<Value> = tools
|
||||
.iter()
|
||||
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
|
||||
.map(|t| {
|
||||
json!({
|
||||
"type": "function",
|
||||
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
|
||||
"description": t.get("description"),
|
||||
"parameters": super::transform::clean_schema(
|
||||
t.get("input_schema").cloned().unwrap_or(json!({}))
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !response_tools.is_empty() {
|
||||
result["tools"] = json!(response_tools);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(v) = body.get("tool_choice") {
|
||||
result["tool_choice"] = map_tool_choice_to_responses(v);
|
||||
}
|
||||
|
||||
// Inject prompt_cache_key for improved cache routing on OpenAI-compatible endpoints
|
||||
if let Some(key) = cache_key {
|
||||
result["prompt_cache_key"] = json!(key);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn map_tool_choice_to_responses(tool_choice: &Value) -> Value {
|
||||
match tool_choice {
|
||||
Value::String(_) => tool_choice.clone(),
|
||||
Value::Object(obj) => match obj.get("type").and_then(|t| t.as_str()) {
|
||||
// Anthropic "any" means at least one tool call is required
|
||||
Some("any") => json!("required"),
|
||||
Some("auto") => json!("auto"),
|
||||
Some("none") => json!("none"),
|
||||
// Anthropic forced tool -> Responses function tool selector
|
||||
Some("tool") => {
|
||||
let name = obj.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
json!({
|
||||
"type": "function",
|
||||
"name": name
|
||||
})
|
||||
}
|
||||
_ => tool_choice.clone(),
|
||||
},
|
||||
_ => tool_choice.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_responses_stop_reason(
|
||||
status: Option<&str>,
|
||||
has_tool_use: bool,
|
||||
incomplete_reason: Option<&str>,
|
||||
) -> Option<&'static str> {
|
||||
status.map(|s| match s {
|
||||
"completed" => {
|
||||
if has_tool_use {
|
||||
"tool_use"
|
||||
} else {
|
||||
"end_turn"
|
||||
}
|
||||
}
|
||||
"incomplete" => {
|
||||
if matches!(
|
||||
incomplete_reason,
|
||||
Some("max_output_tokens") | Some("max_tokens")
|
||||
) || incomplete_reason.is_none()
|
||||
{
|
||||
"max_tokens"
|
||||
} else {
|
||||
"end_turn"
|
||||
}
|
||||
}
|
||||
_ => "end_turn",
|
||||
})
|
||||
}
|
||||
|
||||
/// Build Anthropic-style usage JSON from Responses API usage, including cache tokens.
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. OpenAI nested details (`input_tokens_details.cached_tokens`, `prompt_tokens_details.cached_tokens`) as initial value
|
||||
/// 2. Direct Anthropic-style fields (`cache_read_input_tokens`, `cache_creation_input_tokens`) override if present
|
||||
pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Value {
|
||||
let u = match usage {
|
||||
Some(v) if !v.is_null() => v,
|
||||
_ => {
|
||||
return json!({
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let input = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let output = u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
|
||||
let mut result = json!({
|
||||
"input_tokens": input,
|
||||
"output_tokens": output
|
||||
});
|
||||
|
||||
// Step 1: OpenAI nested details as fallback
|
||||
// OpenAI Responses API: input_tokens_details.cached_tokens
|
||||
if let Some(cached) = u
|
||||
.pointer("/input_tokens_details/cached_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
result["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
// OpenAI standard: prompt_tokens_details.cached_tokens
|
||||
if let Some(cached) = u
|
||||
.pointer("/prompt_tokens_details/cached_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
if result.get("cache_read_input_tokens").is_none() {
|
||||
result["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Direct Anthropic-style fields override (authoritative if present)
|
||||
if let Some(v) = u.get("cache_read_input_tokens") {
|
||||
result["cache_read_input_tokens"] = v.clone();
|
||||
}
|
||||
if let Some(v) = u.get("cache_creation_input_tokens") {
|
||||
result["cache_creation_input_tokens"] = v.clone();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 将 Anthropic messages 数组转换为 Responses API input 数组
|
||||
///
|
||||
/// 核心转换逻辑:
|
||||
/// - user/assistant 的 text 内容 → 对应 role 的 message item
|
||||
/// - tool_use 从 assistant message 中"提升"为独立的 function_call item
|
||||
/// - tool_result 从 user message 中"提升"为独立的 function_call_output item
|
||||
/// - thinking blocks → 丢弃
|
||||
fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyError> {
|
||||
let mut input = Vec::new();
|
||||
|
||||
for msg in messages {
|
||||
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
|
||||
let content = msg.get("content");
|
||||
|
||||
match content {
|
||||
// 字符串内容
|
||||
Some(Value::String(text)) => {
|
||||
let content_type = if role == "assistant" {
|
||||
"output_text"
|
||||
} else {
|
||||
"input_text"
|
||||
};
|
||||
input.push(json!({
|
||||
"role": role,
|
||||
"content": [{ "type": content_type, "text": text }]
|
||||
}));
|
||||
}
|
||||
|
||||
// 数组内容(多模态/工具调用)
|
||||
Some(Value::Array(blocks)) => {
|
||||
let mut message_content = 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()) {
|
||||
let content_type = if role == "assistant" {
|
||||
"output_text"
|
||||
} else {
|
||||
"input_text"
|
||||
};
|
||||
// OpenAI Responses API does not accept Anthropic cache_control
|
||||
// under input[].content[].
|
||||
message_content.push(json!({ "type": content_type, "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("");
|
||||
message_content.push(json!({
|
||||
"type": "input_image",
|
||||
"image_url": format!("data:{media_type};base64,{data}")
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
"tool_use" => {
|
||||
// 先刷新已累积的消息内容
|
||||
if !message_content.is_empty() {
|
||||
input.push(json!({
|
||||
"role": role,
|
||||
"content": message_content.clone()
|
||||
}));
|
||||
message_content.clear();
|
||||
}
|
||||
|
||||
// 提升为独立的 function_call item
|
||||
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 arguments = block.get("input").cloned().unwrap_or(json!({}));
|
||||
|
||||
input.push(json!({
|
||||
"type": "function_call",
|
||||
"call_id": id,
|
||||
"name": name,
|
||||
"arguments": serde_json::to_string(&arguments).unwrap_or_default()
|
||||
}));
|
||||
}
|
||||
|
||||
"tool_result" => {
|
||||
// 先刷新已累积的消息内容
|
||||
if !message_content.is_empty() {
|
||||
input.push(json!({
|
||||
"role": role,
|
||||
"content": message_content.clone()
|
||||
}));
|
||||
message_content.clear();
|
||||
}
|
||||
|
||||
// 提升为独立的 function_call_output item
|
||||
let call_id = block
|
||||
.get("tool_use_id")
|
||||
.and_then(|i| i.as_str())
|
||||
.unwrap_or("");
|
||||
let output = match block.get("content") {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(v) => serde_json::to_string(v).unwrap_or_default(),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
input.push(json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"output": output
|
||||
}));
|
||||
}
|
||||
|
||||
"thinking" => {
|
||||
// 丢弃 thinking blocks(与 openai_chat 一致)
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新剩余的消息内容
|
||||
if !message_content.is_empty() {
|
||||
input.push(json!({
|
||||
"role": role,
|
||||
"content": message_content
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
// 无内容或 null
|
||||
input.push(json!({ "role": role }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
/// OpenAI Responses 响应 → Anthropic 响应
|
||||
pub fn responses_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
let output = body
|
||||
.get("output")
|
||||
.and_then(|o| o.as_array())
|
||||
.ok_or_else(|| ProxyError::TransformError("No output in response".to_string()))?;
|
||||
|
||||
let mut content = Vec::new();
|
||||
|
||||
let mut has_tool_use = false;
|
||||
for item in output {
|
||||
let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
match item_type {
|
||||
"message" => {
|
||||
if let Some(msg_content) = item.get("content").and_then(|c| c.as_array()) {
|
||||
for block in msg_content {
|
||||
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if block_type == "output_text" {
|
||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||
if !text.is_empty() {
|
||||
content.push(json!({"type": "text", "text": text}));
|
||||
}
|
||||
}
|
||||
} else if block_type == "refusal" {
|
||||
if let Some(refusal) = block.get("refusal").and_then(|t| t.as_str()) {
|
||||
if !refusal.is_empty() {
|
||||
content.push(json!({"type": "text", "text": refusal}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"function_call" => {
|
||||
let call_id = item.get("call_id").and_then(|i| i.as_str()).unwrap_or("");
|
||||
let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
let args_str = item
|
||||
.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": call_id,
|
||||
"name": name,
|
||||
"input": input
|
||||
}));
|
||||
has_tool_use = true;
|
||||
}
|
||||
|
||||
"reasoning" => {
|
||||
// 映射 reasoning summary → thinking block
|
||||
if let Some(summary) = item.get("summary").and_then(|s| s.as_array()) {
|
||||
let thinking_text: String = summary
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
if s.get("type").and_then(|t| t.as_str()) == Some("summary_text") {
|
||||
s.get("text").and_then(|t| t.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
if !thinking_text.is_empty() {
|
||||
content.push(json!({
|
||||
"type": "thinking",
|
||||
"thinking": thinking_text
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// status → stop_reason
|
||||
let stop_reason = map_responses_stop_reason(
|
||||
body.get("status").and_then(|s| s.as_str()),
|
||||
has_tool_use,
|
||||
body.pointer("/incomplete_details/reason")
|
||||
.and_then(|r| r.as_str()),
|
||||
);
|
||||
|
||||
let usage_json = build_anthropic_usage_from_responses(body.get("usage"));
|
||||
|
||||
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": usage_json
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_simple() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["model"], "gpt-4o");
|
||||
assert_eq!(result["max_output_tokens"], 1024);
|
||||
assert_eq!(result["input"][0]["role"], "user");
|
||||
assert_eq!(result["input"][0]["content"][0]["type"], "input_text");
|
||||
assert_eq!(result["input"][0]["content"][0]["text"], "Hello");
|
||||
// stop_sequences should not appear
|
||||
assert!(result.get("stop_sequences").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_with_system_string() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"system": "You are a helpful assistant.",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["instructions"], "You are a helpful assistant.");
|
||||
// system should not appear in input
|
||||
assert_eq!(result["input"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_with_system_array() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "Part 1"},
|
||||
{"type": "text", "text": "Part 2"}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["instructions"], "Part 1\n\nPart 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_with_tools() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Weather?"}],
|
||||
"tools": [{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather info",
|
||||
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["tools"][0]["type"], "function");
|
||||
assert_eq!(result["tools"][0]["name"], "get_weather");
|
||||
assert!(result["tools"][0].get("parameters").is_some());
|
||||
// input_schema should not appear
|
||||
assert!(result["tools"][0].get("input_schema").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_tool_choice_any_to_required() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Weather?"}],
|
||||
"tool_choice": {"type": "any"}
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["tool_choice"], "required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_tool_choice_tool_to_function() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Weather?"}],
|
||||
"tool_choice": {"type": "tool", "name": "get_weather"}
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["tool_choice"]["type"], "function");
|
||||
assert_eq!(result["tool_choice"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_tool_use_lifting() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"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_responses(input, None).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// Should produce: assistant message (text) + function_call item
|
||||
assert_eq!(input_arr.len(), 2);
|
||||
|
||||
// First: assistant message with output_text
|
||||
assert_eq!(input_arr[0]["role"], "assistant");
|
||||
assert_eq!(input_arr[0]["content"][0]["type"], "output_text");
|
||||
assert_eq!(input_arr[0]["content"][0]["text"], "Let me check");
|
||||
|
||||
// Second: function_call item (lifted from message)
|
||||
assert_eq!(input_arr[1]["type"], "function_call");
|
||||
assert_eq!(input_arr[1]["call_id"], "call_123");
|
||||
assert_eq!(input_arr[1]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_tool_result_lifting() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "call_123", "content": "Sunny, 25°C"}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// Should produce: function_call_output item (lifted)
|
||||
assert_eq!(input_arr.len(), 1);
|
||||
assert_eq!(input_arr[0]["type"], "function_call_output");
|
||||
assert_eq!(input_arr[0]["call_id"], "call_123");
|
||||
assert_eq!(input_arr[0]["output"], "Sunny, 25°C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_thinking_discarded() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Let me think..."},
|
||||
{"type": "text", "text": "The answer is 42"}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// thinking should be discarded, only text remains
|
||||
assert_eq!(input_arr.len(), 1);
|
||||
assert_eq!(input_arr[0]["content"][0]["type"], "output_text");
|
||||
assert_eq!(input_arr[0]["content"][0]["text"], "The answer is 42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_image() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What is this?"},
|
||||
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "abc123"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let content = result["input"][0]["content"].as_array().unwrap();
|
||||
|
||||
assert_eq!(content[0]["type"], "input_text");
|
||||
assert_eq!(content[1]["type"], "input_image");
|
||||
assert_eq!(content[1]["image_url"], "data:image/png;base64,abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_simple() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": "gpt-4o",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"id": "msg_123",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "Hello!"}]
|
||||
}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["id"], "resp_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_responses_to_anthropic_with_function_call() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": "gpt-4o",
|
||||
"output": [{
|
||||
"type": "function_call",
|
||||
"id": "fc_123",
|
||||
"call_id": "call_123",
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"location\": \"Tokyo\"}",
|
||||
"status": "completed"
|
||||
}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 15}
|
||||
});
|
||||
|
||||
let result = responses_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_responses_to_anthropic_with_refusal_block() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"status": "completed",
|
||||
"model": "gpt-4o",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"content": [{"type": "refusal", "refusal": "I can't help with that."}]
|
||||
}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5}
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["content"][0]["type"], "text");
|
||||
assert_eq!(result["content"][0]["text"], "I can't help with that.");
|
||||
assert_eq!(result["stop_reason"], "end_turn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_with_reasoning() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": "gpt-4o",
|
||||
"output": [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_123",
|
||||
"summary": [
|
||||
{"type": "summary_text", "text": "Thinking about the problem..."}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_123",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "The answer is 42"}]
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 20}
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
// Should have thinking + text
|
||||
assert_eq!(result["content"][0]["type"], "thinking");
|
||||
assert_eq!(
|
||||
result["content"][0]["thinking"],
|
||||
"Thinking about the problem..."
|
||||
);
|
||||
assert_eq!(result["content"][1]["type"], "text");
|
||||
assert_eq!(result["content"][1]["text"], "The answer is 42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_incomplete_status() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"status": "incomplete",
|
||||
"model": "gpt-4o",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"content": [{"type": "output_text", "text": "Partial..."}]
|
||||
}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 4096}
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["stop_reason"], "max_tokens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_incomplete_non_token_reason() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"status": "incomplete",
|
||||
"incomplete_details": {"reason": "content_filter"},
|
||||
"model": "gpt-4o",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"content": [{"type": "output_text", "text": "Blocked"}]
|
||||
}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 1}
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["stop_reason"], "end_turn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_passthrough() {
|
||||
let input = json!({
|
||||
"model": "o3-mini",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["model"], "o3-mini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_with_cache_key() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, Some("my-provider-id")).unwrap();
|
||||
assert_eq!(result["prompt_cache_key"], "my-provider-id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_strip_cache_control_on_tools() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Weather?"}],
|
||||
"tools": [{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"input_schema": {"type": "object"},
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert!(result["tools"][0].get("cache_control").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_strip_cache_control_on_text() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert!(result["input"][0]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_with_cache_tokens() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"status": "completed",
|
||||
"model": "gpt-4o",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"content": [{"type": "output_text", "text": "Hello!"}]
|
||||
}],
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 80
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["usage"]["input_tokens"], 100);
|
||||
assert_eq!(result["usage"]["output_tokens"], 50);
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_with_direct_cache_fields() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"status": "completed",
|
||||
"model": "gpt-4o",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"content": [{"type": "output_text", "text": "Hello!"}]
|
||||
}],
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"cache_read_input_tokens": 60,
|
||||
"cache_creation_input_tokens": 20
|
||||
}
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
|
||||
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,11 @@ fn create_usage_collector(
|
||||
status_code: u16,
|
||||
parser_config: &UsageParserConfig,
|
||||
) -> SseUsageCollector {
|
||||
let logging_enabled = state
|
||||
.config
|
||||
.try_read()
|
||||
.map(|c| c.enable_logging)
|
||||
.unwrap_or(true);
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let request_model = ctx.request_model.clone();
|
||||
@@ -294,6 +299,9 @@ fn create_usage_collector(
|
||||
let session_id = ctx.session_id.clone();
|
||||
|
||||
SseUsageCollector::new(start_time, move |events, first_token_ms| {
|
||||
if !logging_enabled {
|
||||
return;
|
||||
}
|
||||
if let Some(usage) = stream_parser(&events) {
|
||||
let model = model_extractor(&events, &request_model);
|
||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||
@@ -358,6 +366,13 @@ fn spawn_log_usage(
|
||||
status_code: u16,
|
||||
is_streaming: bool,
|
||||
) {
|
||||
// Check enable_logging before spawning the log task
|
||||
if let Ok(config) = state.config.try_read() {
|
||||
if !config.enable_logging {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let app_type_str = ctx.app_type_str.to_string();
|
||||
|
||||
@@ -111,8 +111,9 @@ impl TokenUsage {
|
||||
}
|
||||
// 从 message_delta 中处理缓存命中(cache_read_input_tokens)
|
||||
if usage.cache_read_tokens == 0 {
|
||||
if let Some(cache_read) =
|
||||
delta_usage.get("cache_read_input_tokens").and_then(|v| v.as_u64())
|
||||
if let Some(cache_read) = delta_usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
usage.cache_read_tokens = cache_read as u32;
|
||||
}
|
||||
@@ -120,8 +121,9 @@ impl TokenUsage {
|
||||
// 从 message_delta 中处理缓存创建(cache_creation_input_tokens)
|
||||
// 注: 现在 zhipu 没有返回 cache_creation_input_tokens 字段
|
||||
if usage.cache_creation_tokens == 0 {
|
||||
if let Some(cache_creation) =
|
||||
delta_usage.get("cache_creation_input_tokens").and_then(|v| v.as_u64())
|
||||
if let Some(cache_creation) = delta_usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
usage.cache_creation_tokens = cache_creation as u32;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use toml_edit::{DocumentMut, Item, TableLike};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
|
||||
use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file};
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::services::mcp::McpService;
|
||||
@@ -31,6 +33,537 @@ pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
fn json_is_subset(target: &Value, source: &Value) -> bool {
|
||||
match source {
|
||||
Value::Object(source_map) => {
|
||||
let Some(target_map) = target.as_object() else {
|
||||
return false;
|
||||
};
|
||||
source_map.iter().all(|(key, source_value)| {
|
||||
target_map
|
||||
.get(key)
|
||||
.is_some_and(|target_value| json_is_subset(target_value, source_value))
|
||||
})
|
||||
}
|
||||
Value::Array(source_arr) => {
|
||||
let Some(target_arr) = target.as_array() else {
|
||||
return false;
|
||||
};
|
||||
json_array_contains_subset(target_arr, source_arr)
|
||||
}
|
||||
_ => target == source,
|
||||
}
|
||||
}
|
||||
|
||||
fn json_array_contains_subset(target_arr: &[Value], source_arr: &[Value]) -> bool {
|
||||
let mut matched = vec![false; target_arr.len()];
|
||||
|
||||
source_arr.iter().all(|source_item| {
|
||||
if let Some((index, _)) = target_arr.iter().enumerate().find(|(index, target_item)| {
|
||||
!matched[*index] && json_is_subset(target_item, source_item)
|
||||
}) {
|
||||
matched[index] = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn json_remove_array_items(target_arr: &mut Vec<Value>, source_arr: &[Value]) {
|
||||
for source_item in source_arr {
|
||||
if let Some(index) = target_arr
|
||||
.iter()
|
||||
.position(|target_item| json_is_subset(target_item, source_item))
|
||||
{
|
||||
target_arr.remove(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_deep_merge(target: &mut Value, source: &Value) {
|
||||
match (target, source) {
|
||||
(Value::Object(target_map), Value::Object(source_map)) => {
|
||||
for (key, source_value) in source_map {
|
||||
match target_map.get_mut(key) {
|
||||
Some(target_value) => json_deep_merge(target_value, source_value),
|
||||
None => {
|
||||
target_map.insert(key.clone(), source_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(target_value, source_value) => {
|
||||
*target_value = source_value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_deep_remove(target: &mut Value, source: &Value) {
|
||||
let (Some(target_map), Some(source_map)) = (target.as_object_mut(), source.as_object()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (key, source_value) in source_map {
|
||||
let mut remove_key = false;
|
||||
|
||||
if let Some(target_value) = target_map.get_mut(key) {
|
||||
if source_value.is_object() && target_value.is_object() {
|
||||
json_deep_remove(target_value, source_value);
|
||||
remove_key = target_value.as_object().is_some_and(|obj| obj.is_empty());
|
||||
} else if let (Some(target_arr), Some(source_arr)) =
|
||||
(target_value.as_array_mut(), source_value.as_array())
|
||||
{
|
||||
json_remove_array_items(target_arr, source_arr);
|
||||
remove_key = target_arr.is_empty();
|
||||
} else if json_is_subset(target_value, source_value) {
|
||||
remove_key = true;
|
||||
}
|
||||
}
|
||||
|
||||
if remove_key {
|
||||
target_map.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toml_value_is_subset(target: &toml_edit::Value, source: &toml_edit::Value) -> bool {
|
||||
match (target, source) {
|
||||
(toml_edit::Value::String(target), toml_edit::Value::String(source)) => {
|
||||
target.value() == source.value()
|
||||
}
|
||||
(toml_edit::Value::Integer(target), toml_edit::Value::Integer(source)) => {
|
||||
target.value() == source.value()
|
||||
}
|
||||
(toml_edit::Value::Float(target), toml_edit::Value::Float(source)) => {
|
||||
target.value() == source.value()
|
||||
}
|
||||
(toml_edit::Value::Boolean(target), toml_edit::Value::Boolean(source)) => {
|
||||
target.value() == source.value()
|
||||
}
|
||||
(toml_edit::Value::Datetime(target), toml_edit::Value::Datetime(source)) => {
|
||||
target.value() == source.value()
|
||||
}
|
||||
(toml_edit::Value::Array(target), toml_edit::Value::Array(source)) => {
|
||||
toml_array_contains_subset(target, source)
|
||||
}
|
||||
(toml_edit::Value::InlineTable(target), toml_edit::Value::InlineTable(source)) => {
|
||||
source.iter().all(|(key, source_item)| {
|
||||
target
|
||||
.get(key)
|
||||
.is_some_and(|target_item| toml_value_is_subset(target_item, source_item))
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn toml_array_contains_subset(target: &toml_edit::Array, source: &toml_edit::Array) -> bool {
|
||||
let mut matched = vec![false; target.len()];
|
||||
let target_items: Vec<&toml_edit::Value> = target.iter().collect();
|
||||
|
||||
source.iter().all(|source_item| {
|
||||
if let Some((index, _)) = target_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(index, target_item)| {
|
||||
!matched[*index] && toml_value_is_subset(target_item, source_item)
|
||||
})
|
||||
{
|
||||
matched[index] = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn toml_remove_array_items(target: &mut toml_edit::Array, source: &toml_edit::Array) {
|
||||
for source_item in source.iter() {
|
||||
let index = {
|
||||
let target_items: Vec<&toml_edit::Value> = target.iter().collect();
|
||||
target_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, target_item)| toml_value_is_subset(target_item, source_item))
|
||||
.map(|(index, _)| index)
|
||||
};
|
||||
|
||||
if let Some(index) = index {
|
||||
target.remove(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toml_item_is_subset(target: &Item, source: &Item) -> bool {
|
||||
if let Some(source_table) = source.as_table_like() {
|
||||
let Some(target_table) = target.as_table_like() else {
|
||||
return false;
|
||||
};
|
||||
return source_table.iter().all(|(key, source_item)| {
|
||||
target_table
|
||||
.get(key)
|
||||
.is_some_and(|target_item| toml_item_is_subset(target_item, source_item))
|
||||
});
|
||||
}
|
||||
|
||||
match (target.as_value(), source.as_value()) {
|
||||
(Some(target_value), Some(source_value)) => {
|
||||
toml_value_is_subset(target_value, source_value)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_toml_item(target: &mut Item, source: &Item) {
|
||||
if let Some(source_table) = source.as_table_like() {
|
||||
if let Some(target_table) = target.as_table_like_mut() {
|
||||
merge_toml_table_like(target_table, source_table);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
*target = source.clone();
|
||||
}
|
||||
|
||||
fn merge_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) {
|
||||
for (key, source_item) in source.iter() {
|
||||
match target.get_mut(key) {
|
||||
Some(target_item) => merge_toml_item(target_item, source_item),
|
||||
None => {
|
||||
target.insert(key, source_item.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_toml_item(target: &mut Item, source: &Item) {
|
||||
if let Some(source_table) = source.as_table_like() {
|
||||
if let Some(target_table) = target.as_table_like_mut() {
|
||||
remove_toml_table_like(target_table, source_table);
|
||||
if target_table.is_empty() {
|
||||
*target = Item::None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(source_value) = source.as_value() {
|
||||
let mut remove_item = false;
|
||||
|
||||
if let Some(target_value) = target.as_value_mut() {
|
||||
match (target_value, source_value) {
|
||||
(toml_edit::Value::Array(target_arr), toml_edit::Value::Array(source_arr)) => {
|
||||
toml_remove_array_items(target_arr, source_arr);
|
||||
remove_item = target_arr.is_empty();
|
||||
}
|
||||
(target_value, source_value)
|
||||
if toml_value_is_subset(target_value, source_value) =>
|
||||
{
|
||||
remove_item = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if remove_item {
|
||||
*target = Item::None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) {
|
||||
let keys: Vec<String> = source.iter().map(|(key, _)| key.to_string()).collect();
|
||||
|
||||
for key in keys {
|
||||
let mut remove_key = false;
|
||||
if let (Some(target_item), Some(source_item)) = (target.get_mut(&key), source.get(&key)) {
|
||||
remove_toml_item(target_item, source_item);
|
||||
remove_key = target_item.is_none()
|
||||
|| target_item
|
||||
.as_table_like()
|
||||
.is_some_and(|table_like| table_like.is_empty());
|
||||
}
|
||||
|
||||
if remove_key {
|
||||
target.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: &str) -> bool {
|
||||
let trimmed = snippet.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => match serde_json::from_str::<Value>(trimmed) {
|
||||
Ok(source) if source.is_object() => json_is_subset(settings, &source),
|
||||
_ => false,
|
||||
},
|
||||
AppType::Codex => {
|
||||
let config_toml = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
if config_toml.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let target_doc = match config_toml.parse::<DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let source_doc = match trimmed.parse::<DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
toml_item_is_subset(target_doc.as_item(), source_doc.as_item())
|
||||
}
|
||||
AppType::Gemini => match serde_json::from_str::<Value>(trimmed) {
|
||||
Ok(Value::Object(source_map)) => {
|
||||
let Some(target_map) = settings.get("env").and_then(Value::as_object) else {
|
||||
return false;
|
||||
};
|
||||
source_map.iter().all(|(key, source_value)| {
|
||||
target_map
|
||||
.get(key)
|
||||
.is_some_and(|target_value| json_is_subset(target_value, source_value))
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
AppType::OpenCode | AppType::OpenClaw => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn provider_uses_common_config(
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
snippet: Option<&str>,
|
||||
) -> bool {
|
||||
match provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.common_config_enabled)
|
||||
{
|
||||
Some(explicit) => explicit && snippet.is_some_and(|value| !value.trim().is_empty()),
|
||||
None => snippet.is_some_and(|value| {
|
||||
settings_contain_common_config(app_type, &provider.settings_config, value)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove_common_config_from_settings(
|
||||
app_type: &AppType,
|
||||
settings: &Value,
|
||||
snippet: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
let trimmed = snippet.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(settings.clone());
|
||||
}
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let source = serde_json::from_str::<Value>(trimmed)
|
||||
.map_err(|e| AppError::Message(format!("Invalid Claude common config: {e}")))?;
|
||||
let mut result = settings.clone();
|
||||
json_deep_remove(&mut result, &source);
|
||||
Ok(result)
|
||||
}
|
||||
AppType::Codex => {
|
||||
let mut result = settings.clone();
|
||||
let config_toml = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
let mut target_doc = if config_toml.trim().is_empty() {
|
||||
DocumentMut::new()
|
||||
} else {
|
||||
config_toml.parse::<DocumentMut>().map_err(|e| {
|
||||
AppError::Message(format!(
|
||||
"Invalid Codex config.toml while removing common config: {e}"
|
||||
))
|
||||
})?
|
||||
};
|
||||
let source_doc = trimmed.parse::<DocumentMut>().map_err(|e| {
|
||||
AppError::Message(format!("Invalid Codex common config snippet: {e}"))
|
||||
})?;
|
||||
|
||||
remove_toml_table_like(target_doc.as_table_mut(), source_doc.as_table());
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("config".to_string(), Value::String(target_doc.to_string()));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::Gemini => {
|
||||
let source = serde_json::from_str::<Value>(trimmed)
|
||||
.map_err(|e| AppError::Message(format!("Invalid Gemini common config: {e}")))?;
|
||||
let mut result = settings.clone();
|
||||
if let Some(env) = result.get_mut("env") {
|
||||
json_deep_remove(env, &source);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_common_config_to_settings(
|
||||
app_type: &AppType,
|
||||
settings: &Value,
|
||||
snippet: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
let trimmed = snippet.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(settings.clone());
|
||||
}
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let source = serde_json::from_str::<Value>(trimmed)
|
||||
.map_err(|e| AppError::Message(format!("Invalid Claude common config: {e}")))?;
|
||||
let mut result = settings.clone();
|
||||
json_deep_merge(&mut result, &source);
|
||||
Ok(result)
|
||||
}
|
||||
AppType::Codex => {
|
||||
let mut result = settings.clone();
|
||||
let config_toml = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
let mut target_doc = if config_toml.trim().is_empty() {
|
||||
DocumentMut::new()
|
||||
} else {
|
||||
config_toml.parse::<DocumentMut>().map_err(|e| {
|
||||
AppError::Message(format!(
|
||||
"Invalid Codex config.toml while applying common config: {e}"
|
||||
))
|
||||
})?
|
||||
};
|
||||
let source_doc = trimmed.parse::<DocumentMut>().map_err(|e| {
|
||||
AppError::Message(format!("Invalid Codex common config snippet: {e}"))
|
||||
})?;
|
||||
|
||||
merge_toml_table_like(target_doc.as_table_mut(), source_doc.as_table());
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("config".to_string(), Value::String(target_doc.to_string()));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::Gemini => {
|
||||
let source = serde_json::from_str::<Value>(trimmed)
|
||||
.map_err(|e| AppError::Message(format!("Invalid Gemini common config: {e}")))?;
|
||||
let mut result = settings.clone();
|
||||
if let Some(env) = result.get_mut("env") {
|
||||
json_deep_merge(env, &source);
|
||||
} else if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("env".to_string(), source);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_live_with_common_config(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
let snippet = db.get_config_snippet(app_type.as_str())?;
|
||||
let mut effective_provider = provider.clone();
|
||||
|
||||
if provider_uses_common_config(app_type, provider, snippet.as_deref()) {
|
||||
if let Some(snippet_text) = snippet.as_deref() {
|
||||
match apply_common_config_to_settings(app_type, &provider.settings_config, snippet_text)
|
||||
{
|
||||
Ok(settings) => effective_provider.settings_config = settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to apply common config for {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
write_live_snapshot(app_type, &effective_provider)
|
||||
}
|
||||
|
||||
pub(crate) fn strip_common_config_from_live_settings(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
live_settings: Value,
|
||||
) -> Value {
|
||||
let snippet = match db.get_config_snippet(app_type.as_str()) {
|
||||
Ok(snippet) => snippet,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to load common config for {} while backfilling '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
return live_settings;
|
||||
}
|
||||
};
|
||||
|
||||
if !provider_uses_common_config(app_type, provider, snippet.as_deref()) {
|
||||
return live_settings;
|
||||
}
|
||||
|
||||
let Some(snippet_text) = snippet.as_deref() else {
|
||||
return live_settings;
|
||||
};
|
||||
|
||||
match remove_common_config_from_settings(app_type, &live_settings, snippet_text) {
|
||||
Ok(settings) => settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to strip common config for {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
live_settings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_provider_common_config_for_storage(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
provider: &mut Provider,
|
||||
) -> Result<(), AppError> {
|
||||
let uses_common_config = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.common_config_enabled)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !uses_common_config {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(snippet) = db.get_config_snippet(app_type.as_str())? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if snippet.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match remove_common_config_from_settings(app_type, &provider.settings_config, &snippet) {
|
||||
Ok(settings) => provider.settings_config = settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to normalize common config before saving {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Live configuration snapshot for backup/restore
|
||||
#[derive(Clone)]
|
||||
#[allow(dead_code)]
|
||||
@@ -245,7 +778,7 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<()
|
||||
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) {
|
||||
if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {
|
||||
log::warn!(
|
||||
"Failed to sync {:?} provider '{}' to live: {e}",
|
||||
app_type,
|
||||
@@ -263,6 +796,30 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn sync_current_provider_for_app_to_live(
|
||||
state: &AppState,
|
||||
app_type: &AppType,
|
||||
) -> Result<(), AppError> {
|
||||
if app_type.is_additive_mode() {
|
||||
sync_all_providers_to_live(state, app_type)?;
|
||||
} else {
|
||||
let current_id = match crate::settings::get_effective_current_provider(&state.db, app_type)?
|
||||
{
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get(¤t_id) {
|
||||
write_live_with_common_config(state.db.as_ref(), app_type, provider)?;
|
||||
}
|
||||
}
|
||||
|
||||
McpService::sync_all_enabled(state)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync current provider to live configuration
|
||||
///
|
||||
/// 使用有效的当前供应商 ID(验证过存在性)。
|
||||
@@ -286,7 +843,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
|
||||
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)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
|
||||
}
|
||||
// Note: get_effective_current_provider already validates existence,
|
||||
// so providers.get() should always succeed here
|
||||
@@ -742,3 +1299,132 @@ pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppEr
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn claude_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields() {
|
||||
let settings = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-test"
|
||||
}
|
||||
});
|
||||
let snippet = r#"{
|
||||
"includeCoAuthoredBy": false,
|
||||
"env": {
|
||||
"CLAUDE_CODE_USE_BEDROCK": "1"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let applied =
|
||||
apply_common_config_to_settings(&AppType::Claude, &settings, snippet).unwrap();
|
||||
assert_eq!(applied["includeCoAuthoredBy"], json!(false));
|
||||
assert_eq!(applied["env"]["CLAUDE_CODE_USE_BEDROCK"], json!("1"));
|
||||
|
||||
let stripped =
|
||||
remove_common_config_from_settings(&AppType::Claude, &applied, snippet).unwrap();
|
||||
assert_eq!(stripped, settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields() {
|
||||
let settings = json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "sk-test"
|
||||
},
|
||||
"config": "model_provider = \"openai\"\n[general]\nmodel = \"gpt-5\"\n"
|
||||
});
|
||||
let snippet = "[shared]\nreasoning = \"medium\"\n";
|
||||
|
||||
let applied = apply_common_config_to_settings(&AppType::Codex, &settings, snippet).unwrap();
|
||||
let applied_config = applied["config"].as_str().unwrap_or_default();
|
||||
assert!(applied_config.contains("[shared]"));
|
||||
assert!(applied_config.contains("reasoning = \"medium\""));
|
||||
|
||||
let stripped =
|
||||
remove_common_config_from_settings(&AppType::Codex, &applied, snippet).unwrap();
|
||||
assert_eq!(stripped, settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_common_config_flag_overrides_legacy_subset_detection() {
|
||||
let mut provider = Provider::with_id(
|
||||
"claude-test".to_string(),
|
||||
"Claude Test".to_string(),
|
||||
json!({
|
||||
"includeCoAuthoredBy": false
|
||||
}),
|
||||
None,
|
||||
);
|
||||
provider.meta = Some(crate::provider::ProviderMeta {
|
||||
common_config_enabled: Some(false),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert!(
|
||||
!provider_uses_common_config(
|
||||
&AppType::Claude,
|
||||
&provider,
|
||||
Some(r#"{ "includeCoAuthoredBy": false }"#),
|
||||
),
|
||||
"explicit false should win over legacy subset detection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_common_config_array_subset_detection_and_strip_preserve_extra_items() {
|
||||
let settings = json!({
|
||||
"allowedTools": ["tool1", "tool2"]
|
||||
});
|
||||
let snippet = r#"{
|
||||
"allowedTools": ["tool1"]
|
||||
}"#;
|
||||
|
||||
assert!(
|
||||
settings_contain_common_config(&AppType::Claude, &settings, snippet),
|
||||
"array subset should be detected for legacy providers"
|
||||
);
|
||||
|
||||
let stripped =
|
||||
remove_common_config_from_settings(&AppType::Claude, &settings, snippet).unwrap();
|
||||
assert_eq!(
|
||||
stripped,
|
||||
json!({
|
||||
"allowedTools": ["tool2"]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_common_config_array_subset_detection_and_strip_preserve_extra_items() {
|
||||
let settings = json!({
|
||||
"auth": {},
|
||||
"config": "allowed_tools = [\"tool1\", \"tool2\"]\n"
|
||||
});
|
||||
let snippet = "allowed_tools = [\"tool1\"]\n";
|
||||
|
||||
assert!(
|
||||
settings_contain_common_config(&AppType::Codex, &settings, snippet),
|
||||
"TOML array subset should be detected for legacy providers"
|
||||
);
|
||||
|
||||
let stripped =
|
||||
remove_common_config_from_settings(&AppType::Codex, &settings, snippet).unwrap();
|
||||
assert_eq!(stripped["auth"], json!({}));
|
||||
let stripped_config = stripped["config"].as_str().unwrap_or_default();
|
||||
let parsed = stripped_config
|
||||
.parse::<DocumentMut>()
|
||||
.expect("stripped codex config should remain valid TOML");
|
||||
let allowed_tools = parsed["allowed_tools"]
|
||||
.as_array()
|
||||
.expect("allowed_tools should remain an array");
|
||||
let values: Vec<&str> = allowed_tools
|
||||
.iter()
|
||||
.map(|value| value.as_str().expect("tool id should be string"))
|
||||
.collect();
|
||||
assert_eq!(values, vec!["tool2"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ pub use live::{
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
pub(crate) use live::sanitize_claude_settings_for_live;
|
||||
pub(crate) use live::write_live_snapshot;
|
||||
pub(crate) use live::{
|
||||
normalize_provider_common_config_for_storage, strip_common_config_from_live_settings,
|
||||
sync_current_provider_for_app_to_live, write_live_with_common_config,
|
||||
};
|
||||
|
||||
// Internal re-exports
|
||||
use live::{
|
||||
@@ -167,6 +170,7 @@ impl ProviderService {
|
||||
// Normalize Claude model keys
|
||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||
Self::validate_provider_settings(&app_type, &provider)?;
|
||||
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
|
||||
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
@@ -181,7 +185,7 @@ impl ProviderService {
|
||||
// Users must explicitly switch/apply an OMO provider to activate it.
|
||||
return Ok(true);
|
||||
}
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -192,7 +196,7 @@ impl ProviderService {
|
||||
state
|
||||
.db
|
||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
@@ -208,6 +212,7 @@ impl ProviderService {
|
||||
// Normalize Claude model keys
|
||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||
Self::validate_provider_settings(&app_type, &provider)?;
|
||||
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
|
||||
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
@@ -244,7 +249,7 @@ impl ProviderService {
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -273,7 +278,7 @@ impl ProviderService {
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||
} else {
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
}
|
||||
@@ -560,7 +565,13 @@ impl ProviderService {
|
||||
// Only backfill when switching to a different provider
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
current_provider.settings_config = live_config;
|
||||
current_provider.settings_config =
|
||||
strip_common_config_from_live_settings(
|
||||
state.db.as_ref(),
|
||||
&app_type,
|
||||
¤t_provider,
|
||||
live_config,
|
||||
);
|
||||
if let Err(e) =
|
||||
state.db.save_provider(app_type.as_str(), ¤t_provider)
|
||||
{
|
||||
@@ -585,7 +596,7 @@ impl ProviderService {
|
||||
}
|
||||
|
||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
|
||||
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
@@ -598,6 +609,67 @@ impl ProviderService {
|
||||
sync_current_to_live(state)
|
||||
}
|
||||
|
||||
pub fn sync_current_provider_for_app(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
) -> Result<(), AppError> {
|
||||
sync_current_provider_for_app_to_live(state, &app_type)
|
||||
}
|
||||
|
||||
pub fn migrate_legacy_common_config_usage(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
legacy_snippet: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if app_type.is_additive_mode() || legacy_snippet.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
|
||||
for provider in providers.values() {
|
||||
if provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.common_config_enabled)
|
||||
.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if !live::provider_uses_common_config(&app_type, provider, Some(legacy_snippet)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut updated_provider = provider.clone();
|
||||
updated_provider
|
||||
.meta
|
||||
.get_or_insert_with(Default::default)
|
||||
.common_config_enabled = Some(true);
|
||||
|
||||
match live::remove_common_config_from_settings(
|
||||
&app_type,
|
||||
&updated_provider.settings_config,
|
||||
legacy_snippet,
|
||||
) {
|
||||
Ok(settings) => updated_provider.settings_config = settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to normalize legacy common config for {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
updated_provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
.db
|
||||
.save_provider(app_type.as_str(), &updated_provider)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract common config snippet from current provider
|
||||
///
|
||||
/// Extracts the current provider's configuration and removes provider-specific fields
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::database::Database;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::server::ProxyServer;
|
||||
use crate::proxy::types::*;
|
||||
use crate::services::provider::write_live_snapshot;
|
||||
use crate::services::provider::write_live_with_common_config;
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -1266,7 +1266,7 @@ impl ProxyService {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
write_live_snapshot(app_type, provider)
|
||||
write_live_with_common_config(self.db.as_ref(), app_type, provider)
|
||||
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
|
||||
@@ -142,20 +142,60 @@ impl Database {
|
||||
(String::new(), Vec::new())
|
||||
};
|
||||
|
||||
// Build rollup WHERE clause using date strings (use ? for sequential binding)
|
||||
let (rollup_where, rollup_params) = if start_date.is_some() || end_date.is_some() {
|
||||
let mut conditions: Vec<String> = Vec::new();
|
||||
let mut params = Vec::new();
|
||||
|
||||
if let Some(start) = start_date {
|
||||
conditions.push("date >= date(?, 'unixepoch', 'localtime')".to_string());
|
||||
params.push(start);
|
||||
}
|
||||
if let Some(end) = end_date {
|
||||
conditions.push("date <= date(?, 'unixepoch', 'localtime')".to_string());
|
||||
params.push(end);
|
||||
}
|
||||
|
||||
(format!("WHERE {}", conditions.join(" AND ")), params)
|
||||
} else {
|
||||
(String::new(), Vec::new())
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"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,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,
|
||||
COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END), 0) as success_count
|
||||
FROM proxy_request_logs
|
||||
{where_clause}"
|
||||
COALESCE(d.total_requests, 0) + COALESCE(r.total_requests, 0),
|
||||
COALESCE(d.total_cost, 0) + COALESCE(r.total_cost, 0),
|
||||
COALESCE(d.total_input_tokens, 0) + COALESCE(r.total_input_tokens, 0),
|
||||
COALESCE(d.total_output_tokens, 0) + COALESCE(r.total_output_tokens, 0),
|
||||
COALESCE(d.total_cache_creation_tokens, 0) + COALESCE(r.total_cache_creation_tokens, 0),
|
||||
COALESCE(d.total_cache_read_tokens, 0) + COALESCE(r.total_cache_read_tokens, 0),
|
||||
COALESCE(d.success_count, 0) + COALESCE(r.success_count, 0)
|
||||
FROM
|
||||
(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,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,
|
||||
COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END), 0) as success_count
|
||||
FROM proxy_request_logs {where_clause}) d,
|
||||
(SELECT
|
||||
COALESCE(SUM(request_count), 0) as total_requests,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,
|
||||
COALESCE(SUM(success_count), 0) as success_count
|
||||
FROM usage_daily_rollups {rollup_where}) r"
|
||||
);
|
||||
|
||||
let result = conn.query_row(&sql, rusqlite::params_from_iter(params_vec), |row| {
|
||||
// Combine params: detail params first, then rollup params
|
||||
let mut all_params: Vec<i64> = params_vec;
|
||||
all_params.extend(rollup_params);
|
||||
|
||||
let result = conn.query_row(&sql, rusqlite::params_from_iter(all_params), |row| {
|
||||
let total_requests: i64 = row.get(0)?;
|
||||
let total_cost: f64 = row.get(1)?;
|
||||
let total_input_tokens: i64 = row.get(2)?;
|
||||
@@ -220,6 +260,7 @@ impl Database {
|
||||
bucket_count = 1;
|
||||
}
|
||||
|
||||
// Query detail logs
|
||||
let sql = "
|
||||
SELECT
|
||||
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
|
||||
@@ -264,6 +305,68 @@ impl Database {
|
||||
map.insert(bucket_idx, stat);
|
||||
}
|
||||
|
||||
// Also query rollup data (daily granularity, only useful for daily buckets)
|
||||
if bucket_seconds >= 86400 {
|
||||
let rollup_sql = "
|
||||
SELECT
|
||||
CAST((CAST(strftime('%s', date) AS INTEGER) - ?1) / ?3 AS INTEGER) as bucket_idx,
|
||||
COALESCE(SUM(request_count), 0),
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0),
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0),
|
||||
COALESCE(SUM(input_tokens), 0),
|
||||
COALESCE(SUM(output_tokens), 0),
|
||||
COALESCE(SUM(cache_creation_tokens), 0),
|
||||
COALESCE(SUM(cache_read_tokens), 0)
|
||||
FROM usage_daily_rollups
|
||||
WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime')
|
||||
GROUP BY bucket_idx
|
||||
ORDER BY bucket_idx ASC";
|
||||
|
||||
let mut rstmt = conn.prepare(rollup_sql)?;
|
||||
let rrows = rstmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
(
|
||||
row.get::<_, i64>(1)? as u64,
|
||||
row.get::<_, f64>(2)?,
|
||||
row.get::<_, i64>(3)? as u64,
|
||||
row.get::<_, i64>(4)? as u64,
|
||||
row.get::<_, i64>(5)? as u64,
|
||||
row.get::<_, i64>(6)? as u64,
|
||||
row.get::<_, i64>(7)? as u64,
|
||||
),
|
||||
))
|
||||
})?;
|
||||
|
||||
for row in rrows {
|
||||
let (mut bucket_idx, (req, cost, tok, inp, out, cc, cr)) = row?;
|
||||
if bucket_idx < 0 {
|
||||
continue;
|
||||
}
|
||||
if bucket_idx >= bucket_count {
|
||||
bucket_idx = bucket_count - 1;
|
||||
}
|
||||
let entry = map.entry(bucket_idx).or_insert_with(|| DailyStats {
|
||||
date: String::new(),
|
||||
request_count: 0,
|
||||
total_cost: "0.000000".to_string(),
|
||||
total_tokens: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_creation_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
});
|
||||
entry.request_count += req;
|
||||
let existing_cost: f64 = entry.total_cost.parse().unwrap_or(0.0);
|
||||
entry.total_cost = format!("{:.6}", existing_cost + cost);
|
||||
entry.total_tokens += tok;
|
||||
entry.total_input_tokens += inp;
|
||||
entry.total_output_tokens += out;
|
||||
entry.total_cache_creation_tokens += cc;
|
||||
entry.total_cache_read_tokens += cr;
|
||||
}
|
||||
}
|
||||
|
||||
let mut stats = Vec::with_capacity(bucket_count as usize);
|
||||
for i in 0..bucket_count {
|
||||
let bucket_start_ts = start_ts + i * bucket_seconds;
|
||||
@@ -298,23 +401,46 @@ impl Database {
|
||||
pub fn get_provider_stats(&self) -> Result<Vec<ProviderStats>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// UNION detail logs + rollup data, then aggregate
|
||||
let sql = "SELECT
|
||||
l.provider_id,
|
||||
p.name as provider_name,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count,
|
||||
COALESCE(AVG(l.latency_ms), 0) as avg_latency
|
||||
FROM proxy_request_logs l
|
||||
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
|
||||
GROUP BY l.provider_id, l.app_type
|
||||
ORDER BY total_cost DESC";
|
||||
provider_id, app_type, provider_name,
|
||||
SUM(request_count) as request_count,
|
||||
SUM(total_tokens) as total_tokens,
|
||||
SUM(total_cost) as total_cost,
|
||||
SUM(success_count) as success_count,
|
||||
CASE WHEN SUM(request_count) > 0
|
||||
THEN SUM(latency_sum) / SUM(request_count)
|
||||
ELSE 0 END as avg_latency
|
||||
FROM (
|
||||
SELECT l.provider_id, l.app_type,
|
||||
p.name as provider_name,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count,
|
||||
COALESCE(SUM(l.latency_ms), 0) as latency_sum
|
||||
FROM proxy_request_logs l
|
||||
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
|
||||
GROUP BY l.provider_id, l.app_type
|
||||
UNION ALL
|
||||
SELECT r.provider_id, r.app_type,
|
||||
p2.name as provider_name,
|
||||
COALESCE(SUM(r.request_count), 0),
|
||||
COALESCE(SUM(r.input_tokens + r.output_tokens), 0),
|
||||
COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0),
|
||||
COALESCE(SUM(r.success_count), 0),
|
||||
COALESCE(SUM(r.avg_latency_ms * r.request_count), 0)
|
||||
FROM usage_daily_rollups r
|
||||
LEFT JOIN providers p2 ON r.provider_id = p2.id AND r.app_type = p2.app_type
|
||||
GROUP BY r.provider_id, r.app_type
|
||||
)
|
||||
GROUP BY provider_id, app_type
|
||||
ORDER BY total_cost DESC";
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let request_count: i64 = row.get(2)?;
|
||||
let success_count: i64 = row.get(5)?;
|
||||
let request_count: i64 = row.get(3)?;
|
||||
let success_count: i64 = row.get(6)?;
|
||||
let success_rate = if request_count > 0 {
|
||||
(success_count as f32 / request_count as f32) * 100.0
|
||||
} else {
|
||||
@@ -324,13 +450,13 @@ impl Database {
|
||||
Ok(ProviderStats {
|
||||
provider_id: row.get(0)?,
|
||||
provider_name: row
|
||||
.get::<_, Option<String>>(1)?
|
||||
.get::<_, Option<String>>(2)?
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
request_count: request_count as u64,
|
||||
total_tokens: row.get::<_, i64>(3)? as u64,
|
||||
total_cost: format!("{:.6}", row.get::<_, f64>(4)?),
|
||||
total_tokens: row.get::<_, i64>(4)? as u64,
|
||||
total_cost: format!("{:.6}", row.get::<_, f64>(5)?),
|
||||
success_rate,
|
||||
avg_latency_ms: row.get::<_, f64>(6)? as u64,
|
||||
avg_latency_ms: row.get::<_, f64>(7)? as u64,
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -346,14 +472,29 @@ impl Database {
|
||||
pub fn get_model_stats(&self) -> Result<Vec<ModelStats>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// UNION detail logs + rollup data
|
||||
let sql = "SELECT
|
||||
model,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost
|
||||
FROM proxy_request_logs
|
||||
GROUP BY model
|
||||
ORDER BY total_cost DESC";
|
||||
SUM(request_count) as request_count,
|
||||
SUM(total_tokens) as total_tokens,
|
||||
SUM(total_cost) as total_cost
|
||||
FROM (
|
||||
SELECT model,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost
|
||||
FROM proxy_request_logs
|
||||
GROUP BY model
|
||||
UNION ALL
|
||||
SELECT model,
|
||||
COALESCE(SUM(request_count), 0),
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0),
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
||||
FROM usage_daily_rollups
|
||||
GROUP BY model
|
||||
)
|
||||
GROUP BY model
|
||||
ORDER BY total_cost DESC";
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
@@ -607,26 +748,40 @@ impl Database {
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
|
||||
// 计算今日使用量
|
||||
// 计算今日使用量 (detail logs + rollup)
|
||||
let daily_usage: f64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
||||
FROM proxy_request_logs
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND date(datetime(created_at, 'unixepoch', 'localtime')) = date('now', 'localtime')",
|
||||
params![provider_id, app_type],
|
||||
"SELECT COALESCE(SUM(cost), 0) FROM (
|
||||
SELECT CAST(total_cost_usd AS REAL) as cost
|
||||
FROM proxy_request_logs
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND date(datetime(created_at, 'unixepoch', 'localtime')) = date('now', 'localtime')
|
||||
UNION ALL
|
||||
SELECT CAST(total_cost_usd AS REAL)
|
||||
FROM usage_daily_rollups
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND date = date('now', 'localtime')
|
||||
)",
|
||||
params![provider_id, app_type, provider_id, app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
// 计算本月使用量
|
||||
// 计算本月使用量 (detail logs + rollup)
|
||||
let monthly_usage: f64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
||||
FROM proxy_request_logs
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND strftime('%Y-%m', datetime(created_at, 'unixepoch', 'localtime')) = strftime('%Y-%m', 'now', 'localtime')",
|
||||
params![provider_id, app_type],
|
||||
"SELECT COALESCE(SUM(cost), 0) FROM (
|
||||
SELECT CAST(total_cost_usd AS REAL) as cost
|
||||
FROM proxy_request_logs
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND strftime('%Y-%m', datetime(created_at, 'unixepoch', 'localtime')) = strftime('%Y-%m', 'now', 'localtime')
|
||||
UNION ALL
|
||||
SELECT CAST(total_cost_usd AS REAL)
|
||||
FROM usage_daily_rollups
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND strftime('%Y-%m', date) = strftime('%Y-%m', 'now', 'localtime')
|
||||
)",
|
||||
params![provider_id, app_type, provider_id, app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
@@ -478,6 +478,7 @@ mod tests {
|
||||
&[
|
||||
"cc switch-sync".to_string(),
|
||||
"v2".to_string(),
|
||||
"db-v6".to_string(),
|
||||
"default profile".to_string(),
|
||||
"manifest.json".to_string(),
|
||||
],
|
||||
@@ -485,7 +486,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://dav.example.com/remote.php/dav/files/demo/cc%20switch-sync/v2/default%20profile/manifest.json"
|
||||
"https://dav.example.com/remote.php/dav/files/demo/cc%20switch-sync/v2/db-v6/default%20profile/manifest.json"
|
||||
);
|
||||
assert!(!url.contains("//cc"), "should not have double-slash");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! WebDAV v2 sync protocol layer.
|
||||
//! WebDAV v2 sync protocol layer with DB compatibility subdirectories.
|
||||
//!
|
||||
//! Implements manifest-based synchronization on top of the HTTP transport
|
||||
//! primitives in [`super::webdav`]. Artifact set: `db.sql` + `skills.zip`.
|
||||
@@ -31,6 +31,8 @@ use archive::{
|
||||
|
||||
const PROTOCOL_FORMAT: &str = "cc-switch-webdav-sync";
|
||||
const PROTOCOL_VERSION: u32 = 2;
|
||||
const DB_COMPAT_VERSION: u32 = 6;
|
||||
const LEGACY_DB_COMPAT_VERSION: u32 = 5;
|
||||
const REMOTE_DB_SQL: &str = "db.sql";
|
||||
const REMOTE_SKILLS_ZIP: &str = "skills.zip";
|
||||
const REMOTE_MANIFEST: &str = "manifest.json";
|
||||
@@ -76,6 +78,8 @@ fn io_context_localized(
|
||||
struct SyncManifest {
|
||||
format: String,
|
||||
version: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
db_compat_version: Option<u32>,
|
||||
device_name: String,
|
||||
created_at: String,
|
||||
artifacts: BTreeMap<String, ArtifactMeta>,
|
||||
@@ -95,6 +99,28 @@ struct LocalSnapshot {
|
||||
manifest_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RemoteLayout {
|
||||
Current,
|
||||
Legacy,
|
||||
}
|
||||
|
||||
impl RemoteLayout {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Current => "current",
|
||||
Self::Legacy => "legacy",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteSnapshot {
|
||||
layout: RemoteLayout,
|
||||
manifest: SyncManifest,
|
||||
manifest_bytes: Vec<u8>,
|
||||
manifest_etag: Option<String>,
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────
|
||||
|
||||
/// Check WebDAV connectivity and ensure remote directory structure.
|
||||
@@ -102,7 +128,7 @@ pub async fn check_connection(settings: &WebDavSyncSettings) -> Result<(), AppEr
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
test_connection(&settings.base_url, &auth).await?;
|
||||
let dir_segs = remote_dir_segments(settings);
|
||||
let dir_segs = remote_dir_segments(settings, RemoteLayout::Current);
|
||||
ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -114,19 +140,19 @@ pub async fn upload(
|
||||
) -> Result<Value, AppError> {
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
let dir_segs = remote_dir_segments(settings);
|
||||
let dir_segs = remote_dir_segments(settings, RemoteLayout::Current);
|
||||
ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;
|
||||
|
||||
let snapshot = build_local_snapshot(db, settings)?;
|
||||
|
||||
// Upload order: artifacts first, manifest last (best-effort consistency)
|
||||
let db_url = remote_file_url(settings, REMOTE_DB_SQL)?;
|
||||
let db_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_DB_SQL)?;
|
||||
put_bytes(&db_url, &auth, snapshot.db_sql, "application/sql").await?;
|
||||
|
||||
let skills_url = remote_file_url(settings, REMOTE_SKILLS_ZIP)?;
|
||||
let skills_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_SKILLS_ZIP)?;
|
||||
put_bytes(&skills_url, &auth, snapshot.skills_zip, "application/zip").await?;
|
||||
|
||||
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||||
let manifest_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_MANIFEST)?;
|
||||
put_bytes(
|
||||
&manifest_url,
|
||||
&auth,
|
||||
@@ -160,9 +186,7 @@ pub async fn download(
|
||||
) -> Result<Value, AppError> {
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
|
||||
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||||
let (manifest_bytes, etag) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES)
|
||||
let snapshot = find_remote_snapshot(settings, &auth)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
localized(
|
||||
@@ -172,52 +196,64 @@ pub async fn download(
|
||||
)
|
||||
})?;
|
||||
|
||||
let manifest: SyncManifest =
|
||||
serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {
|
||||
path: REMOTE_MANIFEST.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
validate_manifest_compat(&manifest)?;
|
||||
validate_manifest_compat(&snapshot.manifest, snapshot.layout)?;
|
||||
|
||||
// Download and verify artifacts
|
||||
let db_sql = download_and_verify(settings, &auth, REMOTE_DB_SQL, &manifest.artifacts).await?;
|
||||
let skills_zip =
|
||||
download_and_verify(settings, &auth, REMOTE_SKILLS_ZIP, &manifest.artifacts).await?;
|
||||
let db_sql = download_and_verify(
|
||||
settings,
|
||||
&auth,
|
||||
snapshot.layout,
|
||||
REMOTE_DB_SQL,
|
||||
&snapshot.manifest.artifacts,
|
||||
)
|
||||
.await?;
|
||||
let skills_zip = download_and_verify(
|
||||
settings,
|
||||
&auth,
|
||||
snapshot.layout,
|
||||
REMOTE_SKILLS_ZIP,
|
||||
&snapshot.manifest.artifacts,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Apply snapshot
|
||||
apply_snapshot(db, &db_sql, &skills_zip)?;
|
||||
|
||||
let manifest_hash = sha256_hex(&manifest_bytes);
|
||||
let _persisted =
|
||||
persist_sync_success_best_effort(settings, manifest_hash, etag, persist_sync_success);
|
||||
Ok(serde_json::json!({ "status": "downloaded" }))
|
||||
let manifest_hash = sha256_hex(&snapshot.manifest_bytes);
|
||||
let _persisted = persist_sync_success_best_effort(
|
||||
settings,
|
||||
manifest_hash,
|
||||
snapshot.manifest_etag,
|
||||
persist_sync_success,
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"status": "downloaded",
|
||||
"sourceLayout": snapshot.layout.as_str(),
|
||||
"sourcePath": remote_dir_display(settings, snapshot.layout),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fetch remote manifest info without downloading artifacts.
|
||||
pub async fn fetch_remote_info(settings: &WebDavSyncSettings) -> Result<Option<Value>, AppError> {
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||||
|
||||
let Some((bytes, _)) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES).await? else {
|
||||
let Some(snapshot) = find_remote_snapshot(settings, &auth).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let manifest: SyncManifest = serde_json::from_slice(&bytes).map_err(|e| AppError::Json {
|
||||
path: REMOTE_MANIFEST.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let compatible = validate_manifest_compat(&manifest).is_ok();
|
||||
let compatible = validate_manifest_compat(&snapshot.manifest, snapshot.layout).is_ok();
|
||||
let db_compat_version = effective_db_compat_version(&snapshot.manifest, snapshot.layout);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"deviceName": manifest.device_name,
|
||||
"createdAt": manifest.created_at,
|
||||
"snapshotId": manifest.snapshot_id,
|
||||
"version": manifest.version,
|
||||
"deviceName": snapshot.manifest.device_name,
|
||||
"createdAt": snapshot.manifest.created_at,
|
||||
"snapshotId": snapshot.manifest.snapshot_id,
|
||||
"version": snapshot.manifest.version,
|
||||
"protocolVersion": snapshot.manifest.version,
|
||||
"dbCompatVersion": db_compat_version,
|
||||
"compatible": compatible,
|
||||
"artifacts": manifest.artifacts.keys().collect::<Vec<_>>(),
|
||||
"artifacts": snapshot.manifest.artifacts.keys().collect::<Vec<_>>(),
|
||||
"layout": snapshot.layout.as_str(),
|
||||
"remotePath": remote_dir_display(settings, snapshot.layout),
|
||||
});
|
||||
|
||||
Ok(Some(payload))
|
||||
@@ -267,7 +303,7 @@ fn build_local_snapshot(
|
||||
_settings: &WebDavSyncSettings,
|
||||
) -> Result<LocalSnapshot, AppError> {
|
||||
// Export database to SQL string
|
||||
let sql_string = db.export_sql_string()?;
|
||||
let sql_string = db.export_sql_string_for_sync()?;
|
||||
let db_sql = sql_string.into_bytes();
|
||||
|
||||
// Pack skills into deterministic ZIP
|
||||
@@ -304,6 +340,7 @@ fn build_local_snapshot(
|
||||
let manifest = SyncManifest {
|
||||
format: PROTOCOL_FORMAT.to_string(),
|
||||
version: PROTOCOL_VERSION,
|
||||
db_compat_version: Some(DB_COMPAT_VERSION),
|
||||
device_name: detect_system_device_name().unwrap_or_else(|| "Unknown Device".to_string()),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
artifacts,
|
||||
@@ -384,7 +421,13 @@ fn normalize_device_name(raw: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_manifest_compat(manifest: &SyncManifest) -> Result<(), AppError> {
|
||||
fn effective_db_compat_version(manifest: &SyncManifest, layout: RemoteLayout) -> Option<u32> {
|
||||
manifest
|
||||
.db_compat_version
|
||||
.or_else(|| (layout == RemoteLayout::Legacy).then_some(LEGACY_DB_COMPAT_VERSION))
|
||||
}
|
||||
|
||||
fn validate_manifest_compat(manifest: &SyncManifest, layout: RemoteLayout) -> Result<(), AppError> {
|
||||
if manifest.format != PROTOCOL_FORMAT {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_format_incompatible",
|
||||
@@ -408,14 +451,87 @@ fn validate_manifest_compat(manifest: &SyncManifest) -> Result<(), AppError> {
|
||||
),
|
||||
));
|
||||
}
|
||||
let Some(db_compat_version) = effective_db_compat_version(manifest, layout) else {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_db_version_missing",
|
||||
"远端 manifest 缺少数据库兼容版本",
|
||||
"Remote manifest is missing the database compatibility version.",
|
||||
));
|
||||
};
|
||||
match layout {
|
||||
RemoteLayout::Current if db_compat_version != DB_COMPAT_VERSION => {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_db_version_incompatible",
|
||||
format!(
|
||||
"远端数据库快照版本不兼容: db-v{} (本地 db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
),
|
||||
format!(
|
||||
"Remote database snapshot version is incompatible: db-v{} (local db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
),
|
||||
));
|
||||
}
|
||||
RemoteLayout::Legacy if db_compat_version > DB_COMPAT_VERSION => {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_db_version_incompatible",
|
||||
format!(
|
||||
"远端数据库快照版本不兼容: db-v{} (本地最高支持 db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
),
|
||||
format!(
|
||||
"Remote database snapshot version is incompatible: db-v{} (local supports up to db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_remote_snapshot(
|
||||
settings: &WebDavSyncSettings,
|
||||
auth: &WebDavAuth,
|
||||
) -> Result<Option<RemoteSnapshot>, AppError> {
|
||||
if let Some(snapshot) = fetch_remote_snapshot(settings, auth, RemoteLayout::Current).await? {
|
||||
return Ok(Some(snapshot));
|
||||
}
|
||||
fetch_remote_snapshot(settings, auth, RemoteLayout::Legacy).await
|
||||
}
|
||||
|
||||
async fn fetch_remote_snapshot(
|
||||
settings: &WebDavSyncSettings,
|
||||
auth: &WebDavAuth,
|
||||
layout: RemoteLayout,
|
||||
) -> Result<Option<RemoteSnapshot>, AppError> {
|
||||
let manifest_url = remote_file_url(settings, layout, REMOTE_MANIFEST)?;
|
||||
let Some((manifest_bytes, manifest_etag)) =
|
||||
get_bytes(&manifest_url, auth, MAX_MANIFEST_BYTES).await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let manifest: SyncManifest =
|
||||
serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {
|
||||
path: REMOTE_MANIFEST.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
Ok(Some(RemoteSnapshot {
|
||||
layout,
|
||||
manifest,
|
||||
manifest_bytes,
|
||||
manifest_etag,
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── Download & verify ───────────────────────────────────────
|
||||
|
||||
async fn download_and_verify(
|
||||
settings: &WebDavSyncSettings,
|
||||
auth: &WebDavAuth,
|
||||
layout: RemoteLayout,
|
||||
artifact_name: &str,
|
||||
artifacts: &BTreeMap<String, ArtifactMeta>,
|
||||
) -> Result<Vec<u8>, AppError> {
|
||||
@@ -428,7 +544,7 @@ async fn download_and_verify(
|
||||
})?;
|
||||
validate_artifact_size_limit(artifact_name, meta.size)?;
|
||||
|
||||
let url = remote_file_url(settings, artifact_name)?;
|
||||
let url = remote_file_url(settings, layout, artifact_name)?;
|
||||
let (bytes, _) = get_bytes(&url, auth, MAX_SYNC_ARTIFACT_BYTES as usize)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
@@ -492,7 +608,7 @@ fn apply_snapshot(
|
||||
// 先替换 skills,再导入数据库;若导入失败则回滚 skills,避免“半恢复”。
|
||||
restore_skills_zip(skills_zip)?;
|
||||
|
||||
if let Err(db_err) = db.import_sql_string(sql_str) {
|
||||
if let Err(db_err) = db.import_sql_string_for_sync(sql_str) {
|
||||
if let Err(rollback_err) = restore_skills_from_backup(&skills_backup) {
|
||||
return Err(localized(
|
||||
"webdav.sync.db_import_and_rollback_failed",
|
||||
@@ -510,20 +626,32 @@ fn apply_snapshot(
|
||||
|
||||
// ─── Remote path helpers ─────────────────────────────────────
|
||||
|
||||
fn remote_dir_segments(settings: &WebDavSyncSettings) -> Vec<String> {
|
||||
fn remote_dir_segments(settings: &WebDavSyncSettings, layout: RemoteLayout) -> Vec<String> {
|
||||
let mut segs = Vec::new();
|
||||
segs.extend(path_segments(&settings.remote_root).map(str::to_string));
|
||||
segs.push(format!("v{PROTOCOL_VERSION}"));
|
||||
if layout == RemoteLayout::Current {
|
||||
segs.push(format!("db-v{DB_COMPAT_VERSION}"));
|
||||
}
|
||||
segs.extend(path_segments(&settings.profile).map(str::to_string));
|
||||
segs
|
||||
}
|
||||
|
||||
fn remote_file_url(settings: &WebDavSyncSettings, file_name: &str) -> Result<String, AppError> {
|
||||
let mut segs = remote_dir_segments(settings);
|
||||
fn remote_file_url(
|
||||
settings: &WebDavSyncSettings,
|
||||
layout: RemoteLayout,
|
||||
file_name: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let mut segs = remote_dir_segments(settings, layout);
|
||||
segs.extend(path_segments(file_name).map(str::to_string));
|
||||
build_remote_url(&settings.base_url, &segs)
|
||||
}
|
||||
|
||||
fn remote_dir_display(settings: &WebDavSyncSettings, layout: RemoteLayout) -> String {
|
||||
let segs = remote_dir_segments(settings, layout);
|
||||
format!("/{}", segs.join("/"))
|
||||
}
|
||||
|
||||
fn auth_for(settings: &WebDavSyncSettings) -> WebDavAuth {
|
||||
auth_from_credentials(&settings.username, &settings.password)
|
||||
}
|
||||
@@ -576,13 +704,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_dir_segments_uses_v2() {
|
||||
fn remote_dir_segments_uses_current_layout() {
|
||||
let settings = WebDavSyncSettings {
|
||||
remote_root: "cc-switch-sync".to_string(),
|
||||
profile: "default".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
};
|
||||
let segs = remote_dir_segments(&settings);
|
||||
let segs = remote_dir_segments(&settings, RemoteLayout::Current);
|
||||
assert_eq!(segs, vec!["cc-switch-sync", "v2", "db-v6", "default"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_dir_segments_uses_legacy_layout() {
|
||||
let settings = WebDavSyncSettings {
|
||||
remote_root: "cc-switch-sync".to_string(),
|
||||
profile: "default".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
};
|
||||
let segs = remote_dir_segments(&settings, RemoteLayout::Legacy);
|
||||
assert_eq!(segs, vec!["cc-switch-sync", "v2", "default"]);
|
||||
}
|
||||
|
||||
@@ -619,13 +758,14 @@ mod tests {
|
||||
assert!(!ok);
|
||||
}
|
||||
|
||||
fn manifest_with(format: &str, version: u32) -> SyncManifest {
|
||||
fn manifest_with(format: &str, version: u32, db_compat_version: Option<u32>) -> SyncManifest {
|
||||
let mut artifacts = BTreeMap::new();
|
||||
artifacts.insert("db.sql".to_string(), artifact("abc", 1));
|
||||
artifacts.insert("skills.zip".to_string(), artifact("def", 2));
|
||||
SyncManifest {
|
||||
format: format.to_string(),
|
||||
version,
|
||||
db_compat_version,
|
||||
device_name: "My MacBook".to_string(),
|
||||
created_at: "2026-02-12T00:00:00Z".to_string(),
|
||||
artifacts,
|
||||
@@ -635,20 +775,63 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_accepts_supported_manifest() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION);
|
||||
assert!(validate_manifest_compat(&manifest).is_ok());
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_wrong_format() {
|
||||
let manifest = manifest_with("other-format", PROTOCOL_VERSION);
|
||||
assert!(validate_manifest_compat(&manifest).is_err());
|
||||
let manifest = manifest_with("other-format", PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_wrong_version() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION + 1);
|
||||
assert!(validate_manifest_compat(&manifest).is_err());
|
||||
let manifest = manifest_with(
|
||||
PROTOCOL_FORMAT,
|
||||
PROTOCOL_VERSION + 1,
|
||||
Some(DB_COMPAT_VERSION),
|
||||
);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_accepts_legacy_manifest_without_db_compat() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_current_manifest_with_wrong_db_compat() {
|
||||
let manifest = manifest_with(
|
||||
PROTOCOL_FORMAT,
|
||||
PROTOCOL_VERSION,
|
||||
Some(LEGACY_DB_COMPAT_VERSION),
|
||||
);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_legacy_manifest_from_newer_db_generation() {
|
||||
let manifest = manifest_with(
|
||||
PROTOCOL_FORMAT,
|
||||
PROTOCOL_VERSION,
|
||||
Some(DB_COMPAT_VERSION + 1),
|
||||
);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_db_compat_version_defaults_legacy_layout_to_v5() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None);
|
||||
assert_eq!(
|
||||
effective_db_compat_version(&manifest, RemoteLayout::Legacy),
|
||||
Some(LEGACY_DB_COMPAT_VERSION)
|
||||
);
|
||||
assert_eq!(
|
||||
effective_db_compat_version(&manifest, RemoteLayout::Current),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -672,12 +855,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn manifest_serialization_uses_device_name_only() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION);
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
|
||||
let value = serde_json::to_value(&manifest).expect("serialize manifest");
|
||||
assert!(
|
||||
value.get("deviceName").is_some(),
|
||||
"manifest should contain deviceName"
|
||||
);
|
||||
assert_eq!(
|
||||
value.get("dbCompatVersion").and_then(|v| v.as_u64()),
|
||||
Some(DB_COMPAT_VERSION as u64)
|
||||
);
|
||||
assert!(
|
||||
value.get("deviceId").is_none(),
|
||||
"manifest should not contain deviceId"
|
||||
|
||||
@@ -2,7 +2,7 @@ pub mod providers;
|
||||
pub mod terminal;
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use providers::{claude, codex, gemini, openclaw, opencode};
|
||||
|
||||
@@ -79,3 +79,90 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_session(
|
||||
provider_id: &str,
|
||||
session_id: &str,
|
||||
source_path: &str,
|
||||
) -> Result<bool, String> {
|
||||
let root = provider_root(provider_id)?;
|
||||
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
|
||||
}
|
||||
|
||||
fn delete_session_with_root(
|
||||
provider_id: &str,
|
||||
session_id: &str,
|
||||
source_path: &Path,
|
||||
root: &Path,
|
||||
) -> Result<bool, String> {
|
||||
let validated_root = canonicalize_existing_path(root, "session root")?;
|
||||
let validated_source = canonicalize_existing_path(source_path, "session source")?;
|
||||
|
||||
if !validated_source.starts_with(&validated_root) {
|
||||
return Err(format!(
|
||||
"Session source path is outside provider root: {}",
|
||||
source_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
match provider_id {
|
||||
"codex" => codex::delete_session(&validated_root, &validated_source, session_id),
|
||||
"claude" => claude::delete_session(&validated_root, &validated_source, session_id),
|
||||
"opencode" => opencode::delete_session(&validated_root, &validated_source, session_id),
|
||||
"openclaw" => openclaw::delete_session(&validated_root, &validated_source, session_id),
|
||||
"gemini" => gemini::delete_session(&validated_root, &validated_source, session_id),
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_root(provider_id: &str) -> Result<PathBuf, String> {
|
||||
let root = match provider_id {
|
||||
"codex" => crate::codex_config::get_codex_config_dir().join("sessions"),
|
||||
"claude" => crate::config::get_claude_config_dir().join("projects"),
|
||||
"opencode" => opencode::get_opencode_data_dir(),
|
||||
"openclaw" => crate::openclaw_config::get_openclaw_dir().join("agents"),
|
||||
"gemini" => crate::gemini_config::get_gemini_dir().join("tmp"),
|
||||
_ => return Err(format!("Unsupported provider: {provider_id}")),
|
||||
};
|
||||
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf, String> {
|
||||
if !path.exists() {
|
||||
return Err(format!("{label} not found: {}", path.display()));
|
||||
}
|
||||
|
||||
path.canonicalize()
|
||||
.map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn rejects_source_path_outside_provider_root() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
let outside = tempdir().expect("tempdir");
|
||||
let source = outside.path().join("session.jsonl");
|
||||
std::fs::write(&source, "{}").expect("write source");
|
||||
|
||||
let err = delete_session_with_root("codex", "session-1", &source, root.path())
|
||||
.expect_err("expected outside-root path to be rejected");
|
||||
|
||||
assert!(err.contains("outside provider root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_source_path() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
let missing = root.path().join("missing.jsonl");
|
||||
|
||||
let err = delete_session_with_root("codex", "session-1", &missing, root.path())
|
||||
.expect_err("expected missing source path to fail");
|
||||
|
||||
assert!(err.contains("session source not found"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,41 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
let meta = parse_session(path).ok_or_else(|| {
|
||||
format!(
|
||||
"Failed to parse Claude session metadata: {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if meta.session_id != session_id {
|
||||
return Err(format!(
|
||||
"Claude session ID mismatch: expected {session_id}, found {}",
|
||||
meta.session_id
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(stem) = path.file_stem() {
|
||||
let sibling = path.parent().unwrap_or_else(|| Path::new("")).join(stem);
|
||||
remove_path_if_exists(&sibling).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete Claude session sidecar {}: {e}",
|
||||
sibling.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
std::fs::remove_file(path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete Claude session file {}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
if is_agent_session(path) {
|
||||
return None;
|
||||
@@ -187,3 +222,50 @@ fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_path_if_exists(path: &Path) -> std::io::Result<()> {
|
||||
match std::fs::metadata(path) {
|
||||
Ok(meta) => {
|
||||
if meta.is_dir() {
|
||||
std::fs::remove_dir_all(path)
|
||||
} else {
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_main_file_and_sidecar_directory() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("abc123-session.jsonl");
|
||||
let sidecar = temp.path().join("abc123-session");
|
||||
let subagents = sidecar.join("subagents");
|
||||
let tool_results = sidecar.join("tool-results");
|
||||
|
||||
std::fs::create_dir_all(&subagents).expect("create subagents");
|
||||
std::fs::create_dir_all(&tool_results).expect("create tool-results");
|
||||
std::fs::write(subagents.join("agent-1.jsonl"), "{}").expect("write subagent");
|
||||
std::fs::write(tool_results.join("tool-1.txt"), "result").expect("write tool result");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"sessionId\":\"session-123\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write session");
|
||||
|
||||
delete_session(temp.path(), &path, "session-123").expect("delete session");
|
||||
|
||||
assert!(!path.exists());
|
||||
assert!(!sidecar.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,27 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
let meta = parse_session(path)
|
||||
.ok_or_else(|| format!("Failed to parse Codex session metadata: {}", path.display()))?;
|
||||
|
||||
if meta.session_id != session_id {
|
||||
return Err(format!(
|
||||
"Codex session ID mismatch: expected {session_id}, found {}",
|
||||
meta.session_id
|
||||
));
|
||||
}
|
||||
|
||||
std::fs::remove_file(path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete Codex session file {}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||
|
||||
@@ -192,3 +213,30 @@ fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_jsonl_file() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp
|
||||
.path()
|
||||
.join("rollout-2026-03-06T21-50-12-019cc369-bd7c-7891-b371-7b20b4fe0b18.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"019cc369-bd7c-7891-b371-7b20b4fe0b18\",\"cwd\":\"/tmp/project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"hello\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write session");
|
||||
|
||||
delete_session(temp.path(), &path, "019cc369-bd7c-7891-b371-7b20b4fe0b18")
|
||||
.expect("delete session");
|
||||
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,31 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
let meta = parse_session(path).ok_or_else(|| {
|
||||
format!(
|
||||
"Failed to parse Gemini session metadata: {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if meta.session_id != session_id {
|
||||
return Err(format!(
|
||||
"Gemini session ID mismatch: expected {session_id}, found {}",
|
||||
meta.session_id
|
||||
));
|
||||
}
|
||||
|
||||
std::fs::remove_file(path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete Gemini session file {}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let data = std::fs::read_to_string(path).ok()?;
|
||||
let value: Value = serde_json::from_str(&data).ok()?;
|
||||
@@ -115,3 +140,36 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
resume_command: Some(format!("gemini --resume {session_id}")),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_json_file() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-2026-03-06T10-17-test.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{
|
||||
"sessionId": "gemini-session-123",
|
||||
"startTime": "2026-03-06T10:17:58.000Z",
|
||||
"lastUpdated": "2026-03-06T10:20:00.000Z",
|
||||
"messages": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"timestamp": "2026-03-06T10:17:58.000Z",
|
||||
"type": "user",
|
||||
"content": "hello"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("write session");
|
||||
|
||||
delete_session(temp.path(), &path, "gemini-session-123").expect("delete session");
|
||||
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ use std::path::Path;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::openclaw_config::get_openclaw_dir;
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
use crate::{
|
||||
config::write_json_file,
|
||||
session_manager::{SessionMessage, SessionMeta},
|
||||
};
|
||||
|
||||
use super::utils::{
|
||||
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
|
||||
@@ -115,6 +118,37 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
let meta = parse_session(path).ok_or_else(|| {
|
||||
format!(
|
||||
"Failed to parse OpenClaw session metadata: {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if meta.session_id != session_id {
|
||||
return Err(format!(
|
||||
"OpenClaw session ID mismatch: expected {session_id}, found {}",
|
||||
meta.session_id
|
||||
));
|
||||
}
|
||||
|
||||
let index_path = path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(""))
|
||||
.join("sessions.json");
|
||||
prune_sessions_index(&index_path, session_id, path)?;
|
||||
|
||||
std::fs::remove_file(path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete OpenClaw session file {}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||
|
||||
@@ -206,3 +240,92 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume
|
||||
})
|
||||
}
|
||||
|
||||
fn prune_sessions_index(
|
||||
index_path: &Path,
|
||||
session_id: &str,
|
||||
source_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
if !index_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(index_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to read OpenClaw sessions index {}: {e}",
|
||||
index_path.display()
|
||||
)
|
||||
})?;
|
||||
let mut index: serde_json::Map<String, Value> =
|
||||
serde_json::from_str(&content).map_err(|e| {
|
||||
format!(
|
||||
"Failed to parse OpenClaw sessions index {}: {e}",
|
||||
index_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let source = source_path.to_string_lossy();
|
||||
index.retain(|_, entry| {
|
||||
let same_id = entry.get("sessionId").and_then(Value::as_str) == Some(session_id);
|
||||
let same_file = entry.get("sessionFile").and_then(Value::as_str) == Some(source.as_ref());
|
||||
!(same_id || same_file)
|
||||
});
|
||||
|
||||
write_json_file(index_path, &index).map_err(|e| {
|
||||
format!(
|
||||
"Failed to update OpenClaw sessions index {}: {e}",
|
||||
index_path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn delete_session_updates_index_and_removes_jsonl() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let sessions_dir = temp.path().join("main").join("sessions");
|
||||
std::fs::create_dir_all(&sessions_dir).expect("create sessions dir");
|
||||
|
||||
let session_path = sessions_dir.join("session-123.jsonl");
|
||||
std::fs::write(
|
||||
&session_path,
|
||||
concat!(
|
||||
"{\"type\":\"session\",\"id\":\"session-123\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write session");
|
||||
std::fs::write(
|
||||
sessions_dir.join("sessions.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"agent:main:main": {{
|
||||
"sessionId": "session-123",
|
||||
"sessionFile": "{}"
|
||||
}},
|
||||
"agent:main:other": {{
|
||||
"sessionId": "session-456",
|
||||
"sessionFile": "{}/session-456.jsonl"
|
||||
}}
|
||||
}}"#,
|
||||
session_path.display(),
|
||||
sessions_dir.display()
|
||||
),
|
||||
)
|
||||
.expect("write index");
|
||||
|
||||
delete_session(temp.path(), &session_path, "session-123").expect("delete session");
|
||||
|
||||
assert!(!session_path.exists());
|
||||
let updated: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(sessions_dir.join("sessions.json")).expect("read index"),
|
||||
)
|
||||
.expect("parse index");
|
||||
assert!(updated.get("agent:main:main").is_none());
|
||||
assert!(updated.get("agent:main:other").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const PROVIDER_ID: &str = "opencode";
|
||||
///
|
||||
/// Respects `XDG_DATA_HOME` on all platforms; falls back to
|
||||
/// `~/.local/share/opencode/storage/`.
|
||||
fn get_opencode_data_dir() -> PathBuf {
|
||||
pub(crate) fn get_opencode_data_dir() -> PathBuf {
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("opencode").join("storage");
|
||||
@@ -111,6 +111,71 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub fn delete_session(storage: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
if path.file_name().and_then(|name| name.to_str()) != Some(session_id) {
|
||||
return Err(format!(
|
||||
"OpenCode session path does not match session ID: expected {session_id}, found {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let mut message_files = Vec::new();
|
||||
collect_json_files(path, &mut message_files);
|
||||
|
||||
let mut message_ids = Vec::new();
|
||||
for message_path in &message_files {
|
||||
let data = match std::fs::read_to_string(message_path) {
|
||||
Ok(data) => data,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&data) {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let Some(message_id) = value.get("id").and_then(Value::as_str) {
|
||||
message_ids.push(message_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
for message_id in &message_ids {
|
||||
let part_dir = storage.join("part").join(message_id);
|
||||
remove_dir_all_if_exists(&part_dir).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete OpenCode part directory {}: {e}",
|
||||
part_dir.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let session_diff_path = storage
|
||||
.join("session_diff")
|
||||
.join(format!("{session_id}.json"));
|
||||
remove_file_if_exists(&session_diff_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete OpenCode session diff {}: {e}",
|
||||
session_diff_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
remove_dir_all_if_exists(path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete OpenCode message directory {}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(session_file) = find_session_file(storage, session_id) {
|
||||
remove_file_if_exists(&session_file).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete OpenCode session file {}: {e}",
|
||||
session_file.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(storage: &Path, path: &Path) -> Option<SessionMeta> {
|
||||
let data = std::fs::read_to_string(path).ok()?;
|
||||
let value: Value = serde_json::from_str(&data).ok()?;
|
||||
@@ -274,3 +339,97 @@ fn collect_json_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_session_file(storage: &Path, session_id: &str) -> Option<PathBuf> {
|
||||
let session_root = storage.join("session");
|
||||
let mut files = Vec::new();
|
||||
collect_json_files(&session_root, &mut files);
|
||||
let expected = format!("{session_id}.json");
|
||||
|
||||
files
|
||||
.into_iter()
|
||||
.find(|path| path.file_name().and_then(|name| name.to_str()) == Some(expected.as_str()))
|
||||
}
|
||||
|
||||
fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_dir_all_if_exists(path: &Path) -> std::io::Result<()> {
|
||||
match std::fs::remove_dir_all(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_session_diff_messages_and_parts() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let storage = temp.path();
|
||||
let project_id = "project-123";
|
||||
let session_id = "ses_123";
|
||||
let session_dir = storage.join("session").join(project_id);
|
||||
let message_dir = storage.join("message").join(session_id);
|
||||
let session_diff = storage
|
||||
.join("session_diff")
|
||||
.join(format!("{session_id}.json"));
|
||||
let part_dir = storage.join("part").join("msg_1");
|
||||
let session_file = session_dir.join(format!("{session_id}.json"));
|
||||
|
||||
std::fs::create_dir_all(&session_dir).expect("create session dir");
|
||||
std::fs::create_dir_all(&message_dir).expect("create message dir");
|
||||
std::fs::create_dir_all(&part_dir).expect("create part dir");
|
||||
std::fs::create_dir_all(storage.join("project")).expect("create project dir");
|
||||
std::fs::create_dir_all(storage.join("session_diff")).expect("create session diff dir");
|
||||
|
||||
std::fs::write(
|
||||
&session_file,
|
||||
format!(
|
||||
r#"{{
|
||||
"id": "{session_id}",
|
||||
"projectID": "{project_id}",
|
||||
"directory": "/tmp/project",
|
||||
"time": {{ "created": 1, "updated": 2 }}
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
.expect("write session file");
|
||||
std::fs::write(
|
||||
message_dir.join("msg_1.json"),
|
||||
format!(r#"{{"id":"msg_1","sessionID":"{session_id}","role":"user"}}"#),
|
||||
)
|
||||
.expect("write message file");
|
||||
std::fs::write(
|
||||
part_dir.join("prt_1.json"),
|
||||
r#"{"id":"prt_1","messageID":"msg_1"}"#,
|
||||
)
|
||||
.expect("write part file");
|
||||
std::fs::write(&session_diff, "[]").expect("write session diff");
|
||||
std::fs::write(
|
||||
storage.join("project").join(format!("{project_id}.json")),
|
||||
r#"{"id":"project-123"}"#,
|
||||
)
|
||||
.expect("write project file");
|
||||
|
||||
delete_session(storage, &message_dir, session_id).expect("delete session");
|
||||
|
||||
assert!(!session_file.exists());
|
||||
assert!(!message_dir.exists());
|
||||
assert!(!session_diff.exists());
|
||||
assert!(!part_dir.exists());
|
||||
assert!(storage
|
||||
.join("project")
|
||||
.join(format!("{project_id}.json"))
|
||||
.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ fn default_profile() -> String {
|
||||
"default".to_string()
|
||||
}
|
||||
|
||||
/// WebDAV v2 同步设置
|
||||
/// WebDAV 同步设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebDavSyncSettings {
|
||||
@@ -199,6 +199,12 @@ pub struct AppSettings {
|
||||
/// User has confirmed the stream check first-run notice
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stream_check_confirmed: Option<bool>,
|
||||
/// Whether to show the failover toggle independently on the main page
|
||||
#[serde(default)]
|
||||
pub enable_failover_toggle: bool,
|
||||
/// User has confirmed the failover toggle first-run notice
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub failover_confirmed: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
|
||||
@@ -286,6 +292,8 @@ impl Default for AppSettings {
|
||||
proxy_confirmed: None,
|
||||
usage_confirmed: None,
|
||||
stream_check_confirmed: None,
|
||||
enable_failover_toggle: false,
|
||||
failover_confirmed: None,
|
||||
language: None,
|
||||
visible_apps: None,
|
||||
claude_config_dir: None,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.11.1",
|
||||
"version": "3.12.0",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
Reference in New Issue
Block a user