mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-31 02:51:21 +08:00
Merge branch 'main' into feat/proxy-url-refactor-full-url-mode
# Conflicts: # src-tauri/src/provider.rs # src-tauri/src/services/stream_check.rs # src/components/providers/forms/ClaudeFormFields.tsx # src/components/providers/forms/ProviderForm.tsx # src/types.ts
This commit is contained in:
Generated
+1
-1
@@ -701,7 +701,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.11.0"
|
||||
version = "3.11.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.11.0"
|
||||
version = "3.11.1"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -352,6 +352,49 @@ impl FromStr for AppType {
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用配置片段(按应用分治)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CommonConfigSnippets {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub claude: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub codex: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gemini: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub openclaw: Option<String>,
|
||||
}
|
||||
|
||||
impl CommonConfigSnippets {
|
||||
/// 获取指定应用的通用配置片段
|
||||
pub fn get(&self, app: &AppType) -> Option<&String> {
|
||||
match app {
|
||||
AppType::Claude => self.claude.as_ref(),
|
||||
AppType::Codex => self.codex.as_ref(),
|
||||
AppType::Gemini => self.gemini.as_ref(),
|
||||
AppType::OpenCode => self.opencode.as_ref(),
|
||||
AppType::OpenClaw => self.openclaw.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置指定应用的通用配置片段
|
||||
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
|
||||
match app {
|
||||
AppType::Claude => self.claude = snippet,
|
||||
AppType::Codex => self.codex = snippet,
|
||||
AppType::Gemini => self.gemini = snippet,
|
||||
AppType::OpenCode => self.opencode = snippet,
|
||||
AppType::OpenClaw => self.openclaw = snippet,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 多应用配置结构(向后兼容)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MultiAppConfig {
|
||||
@@ -369,6 +412,12 @@ pub struct MultiAppConfig {
|
||||
/// Claude Skills 配置
|
||||
#[serde(default)]
|
||||
pub skills: SkillStore,
|
||||
/// 通用配置片段(按应用分治)
|
||||
#[serde(default)]
|
||||
pub common_config_snippets: CommonConfigSnippets,
|
||||
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub claude_common_config_snippet: Option<String>,
|
||||
}
|
||||
|
||||
fn default_version() -> u32 {
|
||||
@@ -390,6 +439,8 @@ impl Default for MultiAppConfig {
|
||||
mcp: McpRoot::default(),
|
||||
prompts: PromptRoot::default(),
|
||||
skills: SkillStore::default(),
|
||||
common_config_snippets: CommonConfigSnippets::default(),
|
||||
claude_common_config_snippet: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -483,6 +534,15 @@ impl MultiAppConfig {
|
||||
updated = true;
|
||||
}
|
||||
|
||||
// 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
|
||||
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
|
||||
log::info!(
|
||||
"迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"
|
||||
);
|
||||
config.common_config_snippets.claude = Some(old_claude_snippet);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if updated {
|
||||
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
|
||||
config.save()?;
|
||||
|
||||
@@ -7,6 +7,7 @@ use tauri_plugin_opener::OpenerExt;
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config;
|
||||
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||
use crate::settings;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||
@@ -15,6 +16,18 @@ pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
fn invalid_json_format_error(error: serde_json::Error) -> String {
|
||||
let lang = settings::get_settings()
|
||||
.language
|
||||
.unwrap_or_else(|| "zh".to_string());
|
||||
|
||||
match lang.as_str() {
|
||||
"en" => format!("Invalid JSON format: {error}"),
|
||||
"ja" => format!("JSON形式が無効です: {error}"),
|
||||
_ => format!("无效的 JSON 格式: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||
@@ -150,3 +163,126 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_common_config_snippet(
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<Option<String>, String> {
|
||||
state
|
||||
.db
|
||||
.get_config_snippet("claude")
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_claude_common_config_snippet(
|
||||
snippet: String,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<(), String> {
|
||||
if !snippet.trim().is_empty() {
|
||||
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
|
||||
}
|
||||
|
||||
let value = if snippet.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(snippet)
|
||||
};
|
||||
|
||||
state
|
||||
.db
|
||||
.set_config_snippet("claude", value)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_common_config_snippet(
|
||||
app_type: String,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<Option<String>, String> {
|
||||
state
|
||||
.db
|
||||
.get_config_snippet(&app_type)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_common_config_snippet(
|
||||
app_type: String,
|
||||
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 value = if snippet.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(snippet)
|
||||
};
|
||||
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(&app_type, value)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if app_type == "omo"
|
||||
&& state
|
||||
.db
|
||||
.get_current_omo_provider("opencode", "omo")
|
||||
.map_err(|e| e.to_string())?
|
||||
.is_some()
|
||||
{
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state.inner(),
|
||||
&crate::services::omo::STANDARD,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
if app_type == "omo-slim"
|
||||
&& state
|
||||
.db
|
||||
.get_current_omo_provider("opencode", "omo-slim")
|
||||
.map_err(|e| e.to_string())?
|
||||
.is_some()
|
||||
{
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state.inner(),
|
||||
&crate::services::omo::SLIM,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extract_common_config_snippet(
|
||||
appType: String,
|
||||
settingsConfig: Option<String>,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<String, String> {
|
||||
let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {
|
||||
let settings: serde_json::Value =
|
||||
serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;
|
||||
|
||||
return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
|
||||
app,
|
||||
&settings,
|
||||
)
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
|
||||
crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -133,27 +133,40 @@ pub async fn get_tool_versions(
|
||||
tools: Option<Vec<String>>,
|
||||
wsl_shell_by_tool: Option<HashMap<String, WslShellPreferenceInput>>,
|
||||
) -> Result<Vec<ToolVersion>, String> {
|
||||
let requested: Vec<&str> = if let Some(tools) = tools.as_ref() {
|
||||
let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();
|
||||
VALID_TOOLS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| set.contains(t))
|
||||
.collect()
|
||||
} else {
|
||||
VALID_TOOLS.to_vec()
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
|
||||
for tool in requested {
|
||||
let pref = wsl_shell_by_tool.as_ref().and_then(|m| m.get(tool));
|
||||
let tool_wsl_shell = pref.and_then(|p| p.wsl_shell.as_deref());
|
||||
let tool_wsl_shell_flag = pref.and_then(|p| p.wsl_shell_flag.as_deref());
|
||||
|
||||
results.push(get_single_tool_version_impl(tool, tool_wsl_shell, tool_wsl_shell_flag).await);
|
||||
// Windows: completely disable tool version detection to prevent
|
||||
// accidentally launching apps (e.g. Claude Code) via protocol handlers.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = (tools, wsl_shell_by_tool);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let requested: Vec<&str> = if let Some(tools) = tools.as_ref() {
|
||||
let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();
|
||||
VALID_TOOLS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| set.contains(t))
|
||||
.collect()
|
||||
} else {
|
||||
VALID_TOOLS.to_vec()
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
|
||||
for tool in requested {
|
||||
let pref = wsl_shell_by_tool.as_ref().and_then(|m| m.get(tool));
|
||||
let tool_wsl_shell = pref.and_then(|p| p.wsl_shell.as_deref());
|
||||
let tool_wsl_shell_flag = pref.and_then(|p| p.wsl_shell_flag.as_deref());
|
||||
|
||||
results.push(
|
||||
get_single_tool_version_impl(tool, tool_wsl_shell, tool_wsl_shell_flag).await,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取单个工具的版本信息(内部实现)
|
||||
|
||||
@@ -97,7 +97,27 @@ pub fn switch_provider(
|
||||
}
|
||||
|
||||
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||
let imported = ProviderService::import_default_config(state, app_type)?;
|
||||
let imported = ProviderService::import_default_config(state, app_type.clone())?;
|
||||
|
||||
if imported {
|
||||
// Extract common config snippet (mirrors old startup logic in lib.rs)
|
||||
if state
|
||||
.db
|
||||
.get_config_snippet(app_type.as_str())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none()
|
||||
{
|
||||
match ProviderService::extract_common_config_snippet(state, app_type.clone()) {
|
||||
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
||||
let _ = state
|
||||
.db
|
||||
.set_config_snippet(app_type.as_str(), Some(snippet));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
@@ -167,12 +187,6 @@ pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, Str
|
||||
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn patch_claude_live_settings(patch: serde_json::Value) -> Result<bool, String> {
|
||||
ProviderService::patch_claude_live(patch).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_api_endpoints(
|
||||
urls: Vec<String>,
|
||||
|
||||
@@ -145,6 +145,36 @@ pub async fn set_rectifier_config(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取优化器配置
|
||||
#[tauri::command]
|
||||
pub async fn get_optimizer_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> Result<crate::proxy::types::OptimizerConfig, String> {
|
||||
state.db.get_optimizer_config().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置优化器配置
|
||||
#[tauri::command]
|
||||
pub async fn set_optimizer_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
config: crate::proxy::types::OptimizerConfig,
|
||||
) -> Result<bool, String> {
|
||||
// Validate cache_ttl: only allow known values
|
||||
match config.cache_ttl.as_str() {
|
||||
"5m" | "1h" => {}
|
||||
other => {
|
||||
return Err(format!(
|
||||
"Invalid cache_ttl value: '{other}'. Allowed values: '5m', '1h'"
|
||||
))
|
||||
}
|
||||
}
|
||||
state
|
||||
.db
|
||||
.set_optimizer_config(&config)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取日志配置
|
||||
#[tauri::command]
|
||||
pub async fn get_log_config(
|
||||
|
||||
@@ -38,6 +38,31 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- 通用配置片段 (Common Config Snippet) ---
|
||||
|
||||
/// 获取通用配置片段
|
||||
pub fn get_config_snippet(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
||||
self.get_setting(&format!("common_config_{app_type}"))
|
||||
}
|
||||
|
||||
/// 设置通用配置片段
|
||||
pub fn set_config_snippet(
|
||||
&self,
|
||||
app_type: &str,
|
||||
snippet: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
let key = format!("common_config_{app_type}");
|
||||
if let Some(value) = snippet {
|
||||
self.set_setting(&key, &value)
|
||||
} else {
|
||||
// 如果为 None 则删除
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- 全局出站代理 ---
|
||||
|
||||
/// 全局代理 URL 的存储键名
|
||||
@@ -162,6 +187,29 @@ impl Database {
|
||||
self.set_setting("rectifier_config", &json)
|
||||
}
|
||||
|
||||
// --- 优化器配置 ---
|
||||
|
||||
/// 获取优化器配置
|
||||
///
|
||||
/// 返回优化器配置,如果不存在则返回默认值(默认关闭)
|
||||
pub fn get_optimizer_config(&self) -> Result<crate::proxy::types::OptimizerConfig, AppError> {
|
||||
match self.get_setting("optimizer_config")? {
|
||||
Some(json) => serde_json::from_str(&json)
|
||||
.map_err(|e| AppError::Database(format!("解析优化器配置失败: {e}"))),
|
||||
None => Ok(crate::proxy::types::OptimizerConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新优化器配置
|
||||
pub fn set_optimizer_config(
|
||||
&self,
|
||||
config: &crate::proxy::types::OptimizerConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let json = serde_json::to_string(config)
|
||||
.map_err(|e| AppError::Database(format!("序列化优化器配置失败: {e}")))?;
|
||||
self.set_setting("optimizer_config", &json)
|
||||
}
|
||||
|
||||
// --- 日志配置 ---
|
||||
|
||||
/// 获取日志配置
|
||||
|
||||
@@ -58,6 +58,9 @@ impl Database {
|
||||
// 4. 迁移 Skills
|
||||
Self::migrate_skills(tx, config)?;
|
||||
|
||||
// 5. 迁移 Common Config
|
||||
Self::migrate_common_config(tx, config)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -209,4 +212,34 @@ impl Database {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 迁移通用配置片段
|
||||
fn migrate_common_config(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
config: &MultiAppConfig,
|
||||
) -> Result<(), AppError> {
|
||||
if let Some(snippet) = &config.common_config_snippets.claude {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["common_config_claude", snippet],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||||
}
|
||||
if let Some(snippet) = &config.common_config_snippets.codex {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["common_config_codex", snippet],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||||
}
|
||||
if let Some(snippet) = &config.common_config_snippets.gemini {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["common_config_gemini", snippet],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +513,8 @@ fn schema_dry_run_does_not_write_to_disk() {
|
||||
mcp: Default::default(),
|
||||
prompts: Default::default(),
|
||||
skills: Default::default(),
|
||||
common_config_snippets: Default::default(),
|
||||
claude_common_config_snippet: None,
|
||||
};
|
||||
|
||||
// Dry-run should succeed without any file I/O errors
|
||||
@@ -561,6 +563,8 @@ fn dry_run_validates_schema_compatibility() {
|
||||
mcp: Default::default(),
|
||||
prompts: Default::default(),
|
||||
skills: Default::default(),
|
||||
common_config_snippets: Default::default(),
|
||||
claude_common_config_snippet: None,
|
||||
};
|
||||
|
||||
// Dry-run should validate the full migration path
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
//! - Prompts
|
||||
//! - Skills
|
||||
//!
|
||||
//! See docs/ccswitch-deeplink-design.md for detailed design.
|
||||
|
||||
mod mcp;
|
||||
mod parser;
|
||||
|
||||
@@ -79,9 +79,12 @@ fn parse_provider_deeplink(
|
||||
.clone();
|
||||
|
||||
// Validate app type
|
||||
if app != "claude" && app != "codex" && app != "gemini" {
|
||||
if !matches!(
|
||||
app.as_str(),
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'"
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -185,9 +188,12 @@ fn parse_prompt_deeplink(
|
||||
.clone();
|
||||
|
||||
// Validate app type
|
||||
if app != "claude" && app != "codex" && app != "gemini" {
|
||||
if !matches!(
|
||||
app.as_str(),
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'"
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -254,9 +260,12 @@ fn parse_mcp_deeplink(
|
||||
// Validate apps format
|
||||
for app in apps.split(',') {
|
||||
let trimmed = app.trim();
|
||||
if trimmed != "claude" && trimmed != "codex" && trimmed != "gemini" {
|
||||
if !matches!(
|
||||
trimmed,
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app in 'apps': must be 'claude', 'codex', or 'gemini', got '{trimmed}'"
|
||||
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{trimmed}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
+73
-27
@@ -448,18 +448,7 @@ pub fn run() {
|
||||
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
|
||||
}
|
||||
|
||||
// 2. OpenCode 供应商导入(累加式模式,需特殊处理)
|
||||
// OpenCode 与其他应用不同:配置文件中可同时存在多个供应商
|
||||
// 需要遍历 provider 字段下的每个供应商并导入
|
||||
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No OpenCode providers found to import"),
|
||||
Err(e) => log::warn!("○ Failed to import OpenCode providers: {e}"),
|
||||
}
|
||||
|
||||
// 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
|
||||
// 2. OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
|
||||
{
|
||||
let has_omo = app_state
|
||||
.db
|
||||
@@ -510,17 +499,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2.4 OpenClaw 供应商导入(累加式模式,需特殊处理)
|
||||
// OpenClaw 与 OpenCode 类似:配置文件中可同时存在多个供应商
|
||||
// 需要遍历 models.providers 字段下的每个供应商并导入
|
||||
match crate::services::provider::import_openclaw_providers_from_live(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} OpenClaw provider(s) from live config");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No OpenClaw providers found to import"),
|
||||
Err(e) => log::warn!("○ Failed to import OpenClaw providers: {e}"),
|
||||
}
|
||||
|
||||
// 3. 导入 MCP 服务器配置(表空时触发)
|
||||
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
||||
log::info!("MCP table empty, importing from live configurations...");
|
||||
@@ -582,6 +560,59 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Auto-extract common config snippets from live files (when snippet is missing)
|
||||
for app_type in crate::app_config::AppType::all() {
|
||||
// Skip if snippet already exists
|
||||
if app_state
|
||||
.db
|
||||
.get_config_snippet(app_type.as_str())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to read the live config file for this app type
|
||||
let settings =
|
||||
match crate::services::provider::ProviderService::read_live_settings(
|
||||
app_type.clone(),
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue, // No live config file, skip silently
|
||||
};
|
||||
|
||||
// Extract common config (strip provider-specific fields)
|
||||
match crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
|
||||
app_type.clone(),
|
||||
&settings,
|
||||
) {
|
||||
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
||||
match app_state
|
||||
.db
|
||||
.set_config_snippet(app_type.as_str(), Some(snippet))
|
||||
{
|
||||
Ok(()) => log::info!(
|
||||
"✓ Auto-extracted common config snippet for {}",
|
||||
app_type.as_str()
|
||||
),
|
||||
Err(e) => log::warn!(
|
||||
"✗ Failed to save config snippet for {}: {e}",
|
||||
app_type.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(_) => log::debug!(
|
||||
"○ Live config for {} has no extractable common fields",
|
||||
app_type.as_str()
|
||||
),
|
||||
Err(e) => log::warn!(
|
||||
"✗ Failed to extract config snippet for {}: {e}",
|
||||
app_type.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移旧的 app_config_dir 配置到 Store
|
||||
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
|
||||
log::warn!("迁移 app_config_dir 失败: {e}");
|
||||
@@ -845,12 +876,18 @@ pub fn run() {
|
||||
commands::get_skills_migration_result,
|
||||
commands::get_app_config_path,
|
||||
commands::open_app_config_folder,
|
||||
commands::get_claude_common_config_snippet,
|
||||
commands::set_claude_common_config_snippet,
|
||||
commands::get_common_config_snippet,
|
||||
commands::set_common_config_snippet,
|
||||
commands::extract_common_config_snippet,
|
||||
commands::read_live_provider_settings,
|
||||
commands::patch_claude_live_settings,
|
||||
commands::get_settings,
|
||||
commands::save_settings,
|
||||
commands::get_rectifier_config,
|
||||
commands::set_rectifier_config,
|
||||
commands::get_optimizer_config,
|
||||
commands::set_optimizer_config,
|
||||
commands::get_log_config,
|
||||
commands::set_log_config,
|
||||
commands::restart_app,
|
||||
@@ -1059,9 +1096,18 @@ pub fn run() {
|
||||
|
||||
app.run(|app_handle, event| {
|
||||
// 处理退出请求(所有平台)
|
||||
if let RunEvent::ExitRequested { api, .. } = &event {
|
||||
log::info!("收到退出请求,开始清理...");
|
||||
// 阻止立即退出,执行清理
|
||||
if let RunEvent::ExitRequested { api, code, .. } = &event {
|
||||
// code 为 None 表示运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活窗口),
|
||||
// 此时应仅阻止退出、保持托盘后台运行;
|
||||
// code 为 Some(_) 表示用户主动调用 app.exit() 退出(如托盘菜单"退出"),
|
||||
// 此时执行清理后退出。
|
||||
if code.is_none() {
|
||||
log::info!("运行时触发退出请求(无存活窗口),阻止退出以保持托盘后台运行");
|
||||
api.prevent_exit();
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("收到用户主动退出请求 (code={code:?}),开始清理...");
|
||||
api.prevent_exit();
|
||||
|
||||
let app_handle = app_handle.clone();
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Cache 断点注入器
|
||||
//!
|
||||
//! 在请求转发前自动注入 cache_control 标记,启用 Bedrock Prompt Caching
|
||||
|
||||
use super::types::OptimizerConfig;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 在请求体关键位置注入 cache_control 断点
|
||||
pub fn inject(body: &mut Value, config: &OptimizerConfig) {
|
||||
if !config.cache_injection {
|
||||
return;
|
||||
}
|
||||
|
||||
let existing = count_existing(body);
|
||||
|
||||
// 升级已有断点的 TTL
|
||||
upgrade_existing_ttl(body, &config.cache_ttl);
|
||||
|
||||
let mut budget = 4_usize.saturating_sub(existing);
|
||||
if budget == 0 {
|
||||
if existing > 0 {
|
||||
log::info!(
|
||||
"[OPT] cache: ttl-upgrade({existing}->{},existing={existing})",
|
||||
config.cache_ttl
|
||||
);
|
||||
} else {
|
||||
log::info!("[OPT] cache: no-op(existing={existing})");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut injected = Vec::new();
|
||||
|
||||
// (a) tools 末尾
|
||||
if budget > 0 {
|
||||
if let Some(tools) = body.get_mut("tools").and_then(|t| t.as_array_mut()) {
|
||||
if let Some(last) = tools.last_mut() {
|
||||
if last.get("cache_control").is_none() {
|
||||
if let Some(o) = last.as_object_mut() {
|
||||
o.insert(
|
||||
"cache_control".to_string(),
|
||||
make_cache_control(&config.cache_ttl),
|
||||
);
|
||||
}
|
||||
budget -= 1;
|
||||
injected.push("tools");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (b) system 末尾
|
||||
if budget > 0 {
|
||||
// 字符串 system → 转为数组
|
||||
if body.get("system").and_then(|s| s.as_str()).is_some() {
|
||||
let text = body["system"].as_str().unwrap().to_string();
|
||||
body["system"] = json!([{"type": "text", "text": text}]);
|
||||
}
|
||||
|
||||
if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) {
|
||||
if let Some(last) = system.last_mut() {
|
||||
if last.get("cache_control").is_none() {
|
||||
if let Some(o) = last.as_object_mut() {
|
||||
o.insert(
|
||||
"cache_control".to_string(),
|
||||
make_cache_control(&config.cache_ttl),
|
||||
);
|
||||
}
|
||||
budget -= 1;
|
||||
injected.push("system");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (c) 最后一条 assistant 消息的最后一个非 thinking block
|
||||
if budget > 0 {
|
||||
if let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
||||
if let Some(assistant_msg) = messages
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
|
||||
{
|
||||
if let Some(content) = assistant_msg
|
||||
.get_mut("content")
|
||||
.and_then(|c| c.as_array_mut())
|
||||
{
|
||||
// 逆序找最后一个非 thinking/redacted_thinking block
|
||||
if let Some(block) = content.iter_mut().rev().find(|b| {
|
||||
let bt = b.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
bt != "thinking" && bt != "redacted_thinking"
|
||||
}) {
|
||||
if block.get("cache_control").is_none() {
|
||||
if let Some(o) = block.as_object_mut() {
|
||||
o.insert(
|
||||
"cache_control".to_string(),
|
||||
make_cache_control(&config.cache_ttl),
|
||||
);
|
||||
}
|
||||
injected.push("msgs");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[OPT] cache: {}bp({},{},pre={existing})",
|
||||
injected.len(),
|
||||
injected.join("+"),
|
||||
config.cache_ttl,
|
||||
);
|
||||
}
|
||||
|
||||
fn make_cache_control(ttl: &str) -> Value {
|
||||
if ttl == "5m" {
|
||||
json!({"type": "ephemeral"})
|
||||
} else {
|
||||
json!({"type": "ephemeral", "ttl": ttl})
|
||||
}
|
||||
}
|
||||
|
||||
fn count_existing(body: &Value) -> usize {
|
||||
let mut count = 0;
|
||||
|
||||
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
|
||||
count += tools
|
||||
.iter()
|
||||
.filter(|t| t.get("cache_control").is_some())
|
||||
.count();
|
||||
}
|
||||
|
||||
if let Some(system) = body.get("system").and_then(|s| s.as_array()) {
|
||||
count += system
|
||||
.iter()
|
||||
.filter(|b| b.get("cache_control").is_some())
|
||||
.count();
|
||||
}
|
||||
|
||||
if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) {
|
||||
for msg in messages {
|
||||
if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
|
||||
count += content
|
||||
.iter()
|
||||
.filter(|b| b.get("cache_control").is_some())
|
||||
.count();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
fn upgrade_existing_ttl(body: &mut Value, ttl: &str) {
|
||||
let upgrade = |val: &mut Value| {
|
||||
if let Some(cc) = val.get_mut("cache_control").and_then(|c| c.as_object_mut()) {
|
||||
if ttl == "5m" {
|
||||
cc.remove("ttl");
|
||||
} else {
|
||||
cc.insert("ttl".to_string(), json!(ttl));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(tools) = body.get_mut("tools").and_then(|t| t.as_array_mut()) {
|
||||
for tool in tools.iter_mut() {
|
||||
upgrade(tool);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) {
|
||||
for block in system.iter_mut() {
|
||||
upgrade(block);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
||||
for msg in messages.iter_mut() {
|
||||
if let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) {
|
||||
for block in content.iter_mut() {
|
||||
upgrade(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn default_config() -> OptimizerConfig {
|
||||
OptimizerConfig {
|
||||
enabled: true,
|
||||
thinking_optimizer: true,
|
||||
cache_injection: true,
|
||||
cache_ttl: "1h".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_body_no_injection() {
|
||||
let mut body = json!({"model": "test", "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]});
|
||||
let original = body.clone();
|
||||
inject(&mut body, &default_config());
|
||||
// No tools, no system, no assistant → no injection
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_three_breakpoints() {
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"tools": [{"name": "tool1"}, {"name": "tool2"}],
|
||||
"system": [{"type": "text", "text": "sys prompt"}],
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "text", "text": "hello"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
|
||||
inject(&mut body, &default_config());
|
||||
|
||||
// tools last element
|
||||
assert!(body["tools"][1].get("cache_control").is_some());
|
||||
assert_eq!(body["tools"][1]["cache_control"]["ttl"], "1h");
|
||||
// system last element
|
||||
assert!(body["system"][0].get("cache_control").is_some());
|
||||
// assistant last non-thinking block
|
||||
assert!(body["messages"][1]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_existing_four_breakpoints_only_upgrades_ttl() {
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"tools": [
|
||||
{"name": "t1", "cache_control": {"type": "ephemeral", "ttl": "5m"}},
|
||||
{"name": "t2", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
],
|
||||
"system": [
|
||||
{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
],
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "text", "text": "ok", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
]}
|
||||
]
|
||||
});
|
||||
|
||||
inject(&mut body, &default_config());
|
||||
|
||||
// All TTLs upgraded to 1h, no new breakpoints
|
||||
assert_eq!(body["tools"][0]["cache_control"]["ttl"], "1h");
|
||||
assert_eq!(body["tools"][1]["cache_control"]["ttl"], "1h");
|
||||
assert_eq!(body["system"][0]["cache_control"]["ttl"], "1h");
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][0]["cache_control"]["ttl"],
|
||||
"1h"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_existing_two_injects_two_more() {
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"tools": [
|
||||
{"name": "t1", "cache_control": {"type": "ephemeral"}},
|
||||
{"name": "t2", "cache_control": {"type": "ephemeral"}}
|
||||
],
|
||||
"system": [{"type": "text", "text": "sys"}],
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "ok"}]}
|
||||
]
|
||||
});
|
||||
|
||||
inject(&mut body, &default_config());
|
||||
|
||||
// budget = 4 - 2 = 2, inject system + msgs
|
||||
assert!(body["system"][0].get("cache_control").is_some());
|
||||
assert!(body["messages"][0]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_string_converted_to_array() {
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"system": "You are a helpful assistant",
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
|
||||
});
|
||||
|
||||
inject(&mut body, &default_config());
|
||||
|
||||
assert!(body["system"].is_array());
|
||||
let sys = body["system"].as_array().unwrap();
|
||||
assert_eq!(sys.len(), 1);
|
||||
assert_eq!(sys[0]["type"], "text");
|
||||
assert_eq!(sys[0]["text"], "You are a helpful assistant");
|
||||
assert!(sys[0].get("cache_control").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ttl_5m_no_ttl_field() {
|
||||
let config = OptimizerConfig {
|
||||
cache_ttl: "5m".to_string(),
|
||||
..default_config()
|
||||
};
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"tools": [{"name": "tool1"}],
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
|
||||
});
|
||||
|
||||
inject(&mut body, &config);
|
||||
|
||||
let cc = &body["tools"][0]["cache_control"];
|
||||
assert_eq!(cc["type"], "ephemeral");
|
||||
assert!(cc.get("ttl").is_none() || cc["ttl"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_no_change() {
|
||||
let config = OptimizerConfig {
|
||||
cache_injection: false,
|
||||
..default_config()
|
||||
};
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"tools": [{"name": "tool1"}],
|
||||
"system": [{"type": "text", "text": "sys"}],
|
||||
"messages": [{"role": "assistant", "content": [{"type": "text", "text": "ok"}]}]
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
inject(&mut body, &config);
|
||||
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skip_thinking_blocks_in_assistant() {
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "thinking", "thinking": "hmm"},
|
||||
{"type": "text", "text": "result"},
|
||||
{"type": "redacted_thinking", "data": "xxx"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
|
||||
inject(&mut body, &default_config());
|
||||
|
||||
// Should inject on "text" block (last non-thinking), not on thinking/redacted_thinking
|
||||
assert!(body["messages"][0]["content"][1]
|
||||
.get("cache_control")
|
||||
.is_some());
|
||||
assert!(body["messages"][0]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_none());
|
||||
assert!(body["messages"][0]["content"][2]
|
||||
.get("cache_control")
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use super::{
|
||||
thinking_rectifier::{
|
||||
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
|
||||
},
|
||||
types::{ProxyStatus, RectifierConfig},
|
||||
types::{OptimizerConfig, ProxyStatus, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use crate::{app_config::AppType, provider::Provider};
|
||||
@@ -97,6 +97,8 @@ pub struct RequestForwarder {
|
||||
current_provider_id_at_start: String,
|
||||
/// 整流器配置
|
||||
rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
optimizer_config: OptimizerConfig,
|
||||
/// 非流式请求超时(秒)
|
||||
non_streaming_timeout: std::time::Duration,
|
||||
}
|
||||
@@ -114,6 +116,7 @@ impl RequestForwarder {
|
||||
_streaming_first_byte_timeout: u64,
|
||||
_streaming_idle_timeout: u64,
|
||||
rectifier_config: RectifierConfig,
|
||||
optimizer_config: OptimizerConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
router,
|
||||
@@ -123,6 +126,7 @@ impl RequestForwarder {
|
||||
app_handle,
|
||||
current_provider_id_at_start,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||
}
|
||||
}
|
||||
@@ -139,7 +143,7 @@ impl RequestForwarder {
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
endpoint: &str,
|
||||
mut body: Value,
|
||||
body: Value,
|
||||
headers: axum::http::HeaderMap,
|
||||
providers: Vec<Provider>,
|
||||
) -> Result<ForwardResult, ForwardError> {
|
||||
@@ -183,6 +187,22 @@ impl RequestForwarder {
|
||||
continue;
|
||||
}
|
||||
|
||||
// PRE-SEND 优化器:每个 provider 独立决定是否优化
|
||||
// clone body 以避免 Bedrock 优化字段泄漏到非 Bedrock provider(failover 场景)
|
||||
let mut provider_body =
|
||||
if self.optimizer_config.enabled && is_bedrock_provider(provider) {
|
||||
let mut b = body.clone();
|
||||
if self.optimizer_config.thinking_optimizer {
|
||||
super::thinking_optimizer::optimize(&mut b, &self.optimizer_config);
|
||||
}
|
||||
if self.optimizer_config.cache_injection {
|
||||
super::cache_injector::inject(&mut b, &self.optimizer_config);
|
||||
}
|
||||
b
|
||||
} else {
|
||||
body.clone()
|
||||
};
|
||||
|
||||
attempted_providers += 1;
|
||||
|
||||
// 更新状态中的当前Provider信息
|
||||
@@ -196,7 +216,13 @@ impl RequestForwarder {
|
||||
|
||||
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
|
||||
match self
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.forward(
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
&headers,
|
||||
adapter.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
@@ -296,7 +322,7 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
// 首次触发:整流请求体
|
||||
let rectified = rectify_anthropic_request(&mut body);
|
||||
let rectified = rectify_anthropic_request(&mut provider_body);
|
||||
|
||||
// 整流未生效:继续尝试 budget 整流路径,避免误判后短路
|
||||
if !rectified.applied {
|
||||
@@ -318,7 +344,13 @@ impl RequestForwarder {
|
||||
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.forward(
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
&headers,
|
||||
adapter.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
@@ -472,7 +504,7 @@ impl RequestForwarder {
|
||||
});
|
||||
}
|
||||
|
||||
let budget_rectified = rectify_thinking_budget(&mut body);
|
||||
let budget_rectified = rectify_thinking_budget(&mut provider_body);
|
||||
if !budget_rectified.applied {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容,不做无意义重试"
|
||||
@@ -509,7 +541,13 @@ impl RequestForwarder {
|
||||
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.forward(
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
&headers,
|
||||
adapter.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
@@ -967,3 +1005,14 @@ fn extract_error_message(error: &ProxyError) -> Option<String> {
|
||||
_ => Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测 Provider 是否为 Bedrock(通过 CLAUDE_CODE_USE_BEDROCK 环境变量判断)
|
||||
fn is_bedrock_provider(provider: &Provider) -> bool {
|
||||
provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|e| e.get("CLAUDE_CODE_USE_BEDROCK"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::proxy::{
|
||||
extract_session_id,
|
||||
forwarder::RequestForwarder,
|
||||
server::ProxyState,
|
||||
types::{AppProxyConfig, RectifierConfig},
|
||||
types::{AppProxyConfig, OptimizerConfig, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use axum::http::HeaderMap;
|
||||
@@ -59,6 +59,8 @@ pub struct RequestContext {
|
||||
pub session_id: String,
|
||||
/// 整流器配置
|
||||
pub rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
pub optimizer_config: OptimizerConfig,
|
||||
}
|
||||
|
||||
impl RequestContext {
|
||||
@@ -93,6 +95,7 @@ impl RequestContext {
|
||||
|
||||
// 从数据库读取整流器配置
|
||||
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
|
||||
let optimizer_config = state.db.get_optimizer_config().unwrap_or_default();
|
||||
|
||||
let current_provider_id =
|
||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||
@@ -156,6 +159,7 @@ impl RequestContext {
|
||||
app_type,
|
||||
session_id,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -216,6 +220,7 @@ impl RequestContext {
|
||||
first_byte_timeout,
|
||||
idle_timeout,
|
||||
self.rectifier_config.clone(),
|
||||
self.optimizer_config.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -355,6 +355,47 @@ pub async fn handle_responses(
|
||||
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
/// 处理 /v1/responses/compact 请求(OpenAI Responses Compact API - Codex CLI 透传)
|
||||
pub async fn handle_responses_compact(
|
||||
State(state): State<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/responses/compact",
|
||||
body,
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(mut err) => {
|
||||
if let Some(provider) = err.provider.take() {
|
||||
ctx.provider = provider;
|
||||
}
|
||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||
return Err(err.error);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Gemini API 处理器
|
||||
// ============================================================================
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
|
||||
|
||||
pub mod body_filter;
|
||||
pub mod cache_injector;
|
||||
pub mod circuit_breaker;
|
||||
pub mod error;
|
||||
pub mod error_mapper;
|
||||
@@ -22,6 +23,7 @@ pub mod response_processor;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub mod thinking_budget_rectifier;
|
||||
pub mod thinking_optimizer;
|
||||
pub mod thinking_rectifier;
|
||||
pub(crate) mod types;
|
||||
pub mod usage;
|
||||
|
||||
@@ -237,6 +237,23 @@ impl ProxyServer {
|
||||
.route("/v1/responses", post(handlers::handle_responses))
|
||||
.route("/v1/v1/responses", post(handlers::handle_responses))
|
||||
.route("/codex/v1/responses", post(handlers::handle_responses))
|
||||
// OpenAI Responses Compact API (Codex CLI 远程压缩,透传)
|
||||
.route(
|
||||
"/responses/compact",
|
||||
post(handlers::handle_responses_compact),
|
||||
)
|
||||
.route(
|
||||
"/v1/responses/compact",
|
||||
post(handlers::handle_responses_compact),
|
||||
)
|
||||
.route(
|
||||
"/v1/v1/responses/compact",
|
||||
post(handlers::handle_responses_compact),
|
||||
)
|
||||
.route(
|
||||
"/codex/v1/responses/compact",
|
||||
post(handlers::handle_responses_compact),
|
||||
)
|
||||
// Gemini API (支持带前缀和不带前缀)
|
||||
.route("/v1beta/*path", post(handlers::handle_gemini))
|
||||
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Thinking 优化器
|
||||
|
||||
use super::types::OptimizerConfig;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 根据模型类型自动优化 thinking 配置
|
||||
///
|
||||
/// 三路径分发:
|
||||
/// - skip: haiku 模型直接跳过
|
||||
/// - adaptive: opus-4-6 / sonnet-4-6 使用 adaptive thinking
|
||||
/// - legacy: 其他模型注入 enabled thinking + budget_tokens
|
||||
pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
|
||||
if !config.thinking_optimizer {
|
||||
return;
|
||||
}
|
||||
|
||||
let model = match body.get("model").and_then(|m| m.as_str()) {
|
||||
Some(m) => m.to_lowercase(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
if model.contains("haiku") {
|
||||
log::info!("[OPT] thinking: skip(haiku)");
|
||||
return;
|
||||
}
|
||||
|
||||
if model.contains("opus-4-6") || model.contains("sonnet-4-6") {
|
||||
log::info!("[OPT] thinking: adaptive({})", model);
|
||||
body["thinking"] = json!({"type": "adaptive"});
|
||||
body["output_config"] = json!({"effort": "max"});
|
||||
append_beta(body, "context-1m-2025-08-07");
|
||||
return;
|
||||
}
|
||||
|
||||
// legacy path
|
||||
log::info!("[OPT] thinking: legacy({})", model);
|
||||
|
||||
let max_tokens = body
|
||||
.get("max_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(16384);
|
||||
|
||||
let budget_target = max_tokens.saturating_sub(1);
|
||||
|
||||
let thinking_type = body
|
||||
.get("thinking")
|
||||
.and_then(|t| t.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
match thinking_type.as_deref() {
|
||||
None | Some("disabled") => {
|
||||
body["thinking"] = json!({
|
||||
"type": "enabled",
|
||||
"budget_tokens": budget_target
|
||||
});
|
||||
append_beta(body, "interleaved-thinking-2025-05-14");
|
||||
}
|
||||
Some("enabled") => {
|
||||
let current_budget = body
|
||||
.get("thinking")
|
||||
.and_then(|t| t.get("budget_tokens"))
|
||||
.and_then(|b| b.as_u64())
|
||||
.unwrap_or(0);
|
||||
if current_budget < budget_target {
|
||||
body["thinking"]["budget_tokens"] = json!(budget_target);
|
||||
}
|
||||
append_beta(body, "interleaved-thinking-2025-05-14");
|
||||
}
|
||||
_ => {
|
||||
append_beta(body, "interleaved-thinking-2025-05-14");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加 beta 标识到 anthropic_beta 数组(去重)
|
||||
fn append_beta(body: &mut Value, beta: &str) {
|
||||
match body.get("anthropic_beta") {
|
||||
Some(Value::Array(arr)) => {
|
||||
if arr.iter().any(|v| v.as_str() == Some(beta)) {
|
||||
return;
|
||||
}
|
||||
body["anthropic_beta"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!(beta));
|
||||
}
|
||||
Some(Value::Null) | None => {
|
||||
body["anthropic_beta"] = json!([beta]);
|
||||
}
|
||||
_ => {
|
||||
body["anthropic_beta"] = json!([beta]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn enabled_config() -> OptimizerConfig {
|
||||
OptimizerConfig {
|
||||
enabled: true,
|
||||
thinking_optimizer: true,
|
||||
cache_injection: true,
|
||||
cache_ttl: "1h".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled_config() -> OptimizerConfig {
|
||||
OptimizerConfig {
|
||||
enabled: true,
|
||||
thinking_optimizer: false,
|
||||
cache_injection: true,
|
||||
cache_ttl: "1h".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adaptive_opus_4_6() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-opus-4-6-20250514-v1:0",
|
||||
"max_tokens": 16384,
|
||||
"thinking": {"type": "enabled", "budget_tokens": 8000},
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert!(body["thinking"].get("budget_tokens").is_none());
|
||||
assert_eq!(body["output_config"]["effort"], "max");
|
||||
let betas = body["anthropic_beta"].as_array().unwrap();
|
||||
assert!(betas.iter().any(|v| v == "context-1m-2025-08-07"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adaptive_sonnet_4_6() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-sonnet-4-6-20250514-v1:0",
|
||||
"max_tokens": 16384,
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert!(body["thinking"].get("budget_tokens").is_none());
|
||||
assert_eq!(body["output_config"]["effort"], "max");
|
||||
let betas = body["anthropic_beta"].as_array().unwrap();
|
||||
assert!(betas.iter().any(|v| v == "context-1m-2025-08-07"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_sonnet_4_5_thinking_null() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-sonnet-4-5-20250514-v1:0",
|
||||
"max_tokens": 16384,
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 16383);
|
||||
let betas = body["anthropic_beta"].as_array().unwrap();
|
||||
assert!(betas.iter().any(|v| v == "interleaved-thinking-2025-05-14"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_budget_too_small_upgraded() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-sonnet-4-5-20250514-v1:0",
|
||||
"max_tokens": 16384,
|
||||
"thinking": {"type": "enabled", "budget_tokens": 1024},
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 16383);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skip_haiku() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-haiku-4-5-20250514-v1:0",
|
||||
"max_tokens": 8192,
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_optimizer_disabled() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-opus-4-6-20250514-v1:0",
|
||||
"max_tokens": 16384,
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
optimize(&mut body, &disabled_config());
|
||||
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adaptive_dedup_beta() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-opus-4-6-20250514-v1:0",
|
||||
"max_tokens": 16384,
|
||||
"anthropic_beta": ["context-1m-2025-08-07"],
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
let betas = body["anthropic_beta"].as_array().unwrap();
|
||||
let count = betas
|
||||
.iter()
|
||||
.filter(|v| v == &&json!("context-1m-2025-08-07"))
|
||||
.count();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_disabled_thinking_injected() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-sonnet-4-5-20250514-v1:0",
|
||||
"max_tokens": 8192,
|
||||
"thinking": {"type": "disabled"},
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 8191);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_default_max_tokens() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-sonnet-4-5-20250514-v1:0",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 16383);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_beta_null_field() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-opus-4-6-20250514-v1:0",
|
||||
"anthropic_beta": null,
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
let betas = body["anthropic_beta"].as_array().unwrap();
|
||||
assert!(betas.iter().any(|v| v == "context-1m-2025-08-07"));
|
||||
}
|
||||
}
|
||||
@@ -233,6 +233,42 @@ impl Default for RectifierConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求优化器配置
|
||||
///
|
||||
/// 存储在 settings 表中,key = "optimizer_config"
|
||||
/// 仅对 Bedrock provider 生效(CLAUDE_CODE_USE_BEDROCK = "1")
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OptimizerConfig {
|
||||
/// 总开关(默认关闭,用户需手动启用)
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Thinking 优化子开关(总开关开启后默认生效)
|
||||
#[serde(default = "default_true")]
|
||||
pub thinking_optimizer: bool,
|
||||
/// Cache 注入子开关(总开关开启后默认生效)
|
||||
#[serde(default = "default_true")]
|
||||
pub cache_injection: bool,
|
||||
/// Cache TTL: "5m" | "1h"(默认 "1h")
|
||||
#[serde(default = "default_cache_ttl")]
|
||||
pub cache_ttl: String,
|
||||
}
|
||||
|
||||
fn default_cache_ttl() -> String {
|
||||
"1h".to_string()
|
||||
}
|
||||
|
||||
impl Default for OptimizerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
thinking_optimizer: true,
|
||||
cache_injection: true,
|
||||
cache_ttl: "1h".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志配置
|
||||
///
|
||||
/// 存储在 settings 表的 log_config 字段中(JSON 格式)
|
||||
|
||||
@@ -109,6 +109,23 @@ impl TokenUsage {
|
||||
usage.input_tokens = input as u32;
|
||||
}
|
||||
}
|
||||
// 从 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())
|
||||
{
|
||||
usage.cache_read_tokens = cache_read as u32;
|
||||
}
|
||||
}
|
||||
// 从 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())
|
||||
{
|
||||
usage.cache_creation_tokens = cache_creation as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use super::provider::{sanitize_claude_settings_for_live, ProviderService};
|
||||
use crate::app_config::{AppType, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -78,4 +82,150 @@ impl ConfigService {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 同步当前供应商到对应的 live 配置。
|
||||
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
|
||||
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
|
||||
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
|
||||
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_current_provider_for_app(
|
||||
config: &mut MultiAppConfig,
|
||||
app_type: &AppType,
|
||||
) -> Result<(), AppError> {
|
||||
let (current_id, provider) = {
|
||||
let manager = match config.get_manager(app_type) {
|
||||
Some(manager) => manager,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if manager.current.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_id = manager.current.clone();
|
||||
let provider = match manager.providers.get(¤t_id) {
|
||||
Some(provider) => provider.clone(),
|
||||
None => {
|
||||
log::warn!(
|
||||
"当前应用 {app_type:?} 的供应商 {current_id} 不存在,跳过 live 同步"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
(current_id, provider)
|
||||
};
|
||||
|
||||
match app_type {
|
||||
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
||||
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode, no live sync needed
|
||||
// OpenCode providers are managed directly in the config file
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses additive mode, no live sync needed
|
||||
// OpenClaw providers are managed directly in the config file
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_codex_live(
|
||||
config: &mut MultiAppConfig,
|
||||
provider_id: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||||
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
|
||||
})?;
|
||||
let auth = settings.get("auth").ok_or_else(|| {
|
||||
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段"))
|
||||
})?;
|
||||
if !auth.is_object() {
|
||||
return Err(AppError::Config(format!(
|
||||
"供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象"
|
||||
)));
|
||||
}
|
||||
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||
|
||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
|
||||
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
|
||||
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
|
||||
|
||||
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
|
||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||
if let Some(obj) = target.settings_config.as_object_mut() {
|
||||
obj.insert(
|
||||
"config".to_string(),
|
||||
serde_json::Value::String(cfg_text_after),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_claude_live(
|
||||
config: &mut MultiAppConfig,
|
||||
provider_id: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
use crate::config::{read_json_file, write_json_file};
|
||||
|
||||
let settings_path = crate::config::get_claude_settings_path();
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
|
||||
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
|
||||
write_json_file(&settings_path, &settings)?;
|
||||
|
||||
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
|
||||
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
|
||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||
target.settings_config = live_after;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_gemini_live(
|
||||
config: &mut MultiAppConfig,
|
||||
provider_id: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
use crate::gemini_config::{env_to_json, read_gemini_env};
|
||||
|
||||
ProviderService::write_gemini_live(provider)?;
|
||||
|
||||
// 读回实际写入的内容并更新到配置中(包含 settings.json)
|
||||
let live_after_env = read_gemini_env()?;
|
||||
let settings_path = crate::gemini_config::get_gemini_settings_path();
|
||||
let live_after_config = if settings_path.exists() {
|
||||
crate::config::read_json_file(&settings_path)?
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
let mut live_after = env_to_json(&live_after_env);
|
||||
if let Some(obj) = live_after.as_object_mut() {
|
||||
obj.insert("config".to_string(), live_after_config);
|
||||
}
|
||||
|
||||
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
|
||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||
target.settings_config = live_after;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,439 +237,6 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Key fields definitions for partial merge
|
||||
// ============================================================================
|
||||
|
||||
/// Claude env-level key fields that belong to the provider.
|
||||
/// When adding a new field here, also update backfill_claude_key_fields().
|
||||
const CLAUDE_KEY_ENV_FIELDS: &[&str] = &[
|
||||
// --- API auth & endpoint ---
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
// --- Model selection ---
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_REASONING_MODEL",
|
||||
"ANTHROPIC_SMALL_FAST_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||||
// --- AWS Bedrock ---
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_SESSION_TOKEN",
|
||||
"AWS_REGION",
|
||||
"AWS_PROFILE",
|
||||
"ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION",
|
||||
// --- Google Vertex AI ---
|
||||
"CLAUDE_CODE_USE_VERTEX",
|
||||
"ANTHROPIC_VERTEX_PROJECT_ID",
|
||||
"CLOUD_ML_REGION",
|
||||
// --- Microsoft Foundry ---
|
||||
"CLAUDE_CODE_USE_FOUNDRY",
|
||||
// --- Provider behavior ---
|
||||
"CLAUDE_CODE_MAX_OUTPUT_TOKENS",
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
|
||||
"API_TIMEOUT_MS",
|
||||
"DISABLE_PROMPT_CACHING",
|
||||
];
|
||||
|
||||
/// Claude top-level key fields (legacy + modern format).
|
||||
/// When adding a new field here, also update backfill_claude_key_fields().
|
||||
const CLAUDE_KEY_TOP_LEVEL: &[&str] = &[
|
||||
"apiBaseUrl", // legacy
|
||||
"primaryModel", // legacy
|
||||
"smallFastModel", // legacy
|
||||
"model", // modern
|
||||
"apiKey", // Bedrock API Key auth
|
||||
];
|
||||
|
||||
/// Codex TOML key fields.
|
||||
/// When adding a new field here, also update backfill_codex_key_fields().
|
||||
const CODEX_KEY_TOP_LEVEL: &[&str] = &[
|
||||
"model_provider",
|
||||
"model",
|
||||
"model_reasoning_effort",
|
||||
"review_model",
|
||||
"plan_mode_reasoning_effort",
|
||||
];
|
||||
|
||||
/// Gemini env-level key fields.
|
||||
/// When adding a new field here, also update backfill_gemini_key_fields().
|
||||
const GEMINI_KEY_ENV_FIELDS: &[&str] = &[
|
||||
"GOOGLE_GEMINI_BASE_URL",
|
||||
"GEMINI_API_KEY",
|
||||
"GEMINI_MODEL",
|
||||
"GOOGLE_API_KEY",
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Partial merge: write only key fields to live config
|
||||
// ============================================================================
|
||||
|
||||
/// Write only provider-specific key fields to live configuration,
|
||||
/// preserving all other user settings in the live file.
|
||||
///
|
||||
/// Used for switch-mode apps (Claude, Codex, Gemini) during:
|
||||
/// - `switch_normal()` — switching providers
|
||||
/// - `sync_current_to_live()` — startup sync
|
||||
/// - `add()` / `update()` when the provider is current
|
||||
pub(crate) fn write_live_partial(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => write_claude_live_partial(provider),
|
||||
AppType::Codex => write_codex_live_partial(provider),
|
||||
AppType::Gemini => write_gemini_live_partial(provider),
|
||||
// Additive mode apps still use full snapshot
|
||||
AppType::OpenCode | AppType::OpenClaw => write_live_snapshot(app_type, provider),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a JSON merge patch (RFC 7396) directly to Claude live settings.json.
|
||||
/// Used for user-level preferences (attribution, thinking, etc.) that are
|
||||
/// independent of the active provider.
|
||||
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
|
||||
let path = get_claude_settings_path();
|
||||
let mut live = if path.exists() {
|
||||
read_json_file(&path).unwrap_or_else(|_| json!({}))
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
json_merge_patch(&mut live, &patch);
|
||||
let settings = sanitize_claude_settings_for_live(&live);
|
||||
write_json_file(&path, &settings)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RFC 7396 JSON Merge Patch: null deletes, objects merge recursively, rest overwrites.
|
||||
fn json_merge_patch(target: &mut Value, patch: &Value) {
|
||||
if let Some(patch_obj) = patch.as_object() {
|
||||
if !target.is_object() {
|
||||
*target = json!({});
|
||||
}
|
||||
let target_obj = target.as_object_mut().unwrap();
|
||||
for (key, value) in patch_obj {
|
||||
if value.is_null() {
|
||||
target_obj.remove(key);
|
||||
} else if value.is_object() {
|
||||
let entry = target_obj.entry(key.clone()).or_insert(json!({}));
|
||||
json_merge_patch(entry, value);
|
||||
// Clean up empty container objects
|
||||
if entry.as_object().is_some_and(|o| o.is_empty()) {
|
||||
target_obj.remove(key);
|
||||
}
|
||||
} else {
|
||||
target_obj.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude: merge only key env and top-level fields into live settings.json
|
||||
fn write_claude_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||
let path = get_claude_settings_path();
|
||||
|
||||
// 1. Read existing live config (start from empty if file doesn't exist)
|
||||
let mut live = if path.exists() {
|
||||
read_json_file(&path).unwrap_or_else(|_| json!({}))
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
|
||||
// 2. Ensure live.env exists as an object
|
||||
if !live.get("env").is_some_and(|v| v.is_object()) {
|
||||
live.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("env".into(), json!({}));
|
||||
}
|
||||
|
||||
// 3. Clear key env fields from live, then write from provider
|
||||
let live_env = live.get_mut("env").unwrap().as_object_mut().unwrap();
|
||||
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||
live_env.remove(*key);
|
||||
}
|
||||
|
||||
if let Some(provider_env) = provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
{
|
||||
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||
if let Some(value) = provider_env.get(*key) {
|
||||
live_env.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Handle top-level legacy key fields
|
||||
let live_obj = live.as_object_mut().unwrap();
|
||||
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||
live_obj.remove(*key);
|
||||
}
|
||||
if let Some(provider_obj) = provider.settings_config.as_object() {
|
||||
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||
if let Some(value) = provider_obj.get(*key) {
|
||||
live_obj.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Sanitize and write
|
||||
let settings = sanitize_claude_settings_for_live(&live);
|
||||
write_json_file(&path, &settings)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Codex: replace auth.json entirely, partially merge config.toml key fields
|
||||
fn write_codex_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||
let obj = provider
|
||||
.settings_config
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
|
||||
|
||||
// auth.json is entirely provider-specific, replace it wholesale
|
||||
let auth = obj
|
||||
.get("auth")
|
||||
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
|
||||
|
||||
let provider_config_str = obj.get("config").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
// Read existing config.toml (or start from empty)
|
||||
let config_path = get_codex_config_path();
|
||||
let existing_toml = if config_path.exists() {
|
||||
std::fs::read_to_string(&config_path).unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Parse both existing and provider TOML
|
||||
let mut live_doc = existing_toml
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.unwrap_or_else(|_| toml_edit::DocumentMut::new());
|
||||
|
||||
// Remove key fields from live doc
|
||||
let live_root = live_doc.as_table_mut();
|
||||
for key in CODEX_KEY_TOP_LEVEL {
|
||||
live_root.remove(key);
|
||||
}
|
||||
live_root.remove("model_providers");
|
||||
|
||||
// Parse provider TOML and extract key fields
|
||||
if !provider_config_str.is_empty() {
|
||||
if let Ok(provider_doc) = provider_config_str.parse::<toml_edit::DocumentMut>() {
|
||||
let provider_root = provider_doc.as_table();
|
||||
|
||||
// Copy key top-level fields from provider
|
||||
for key in CODEX_KEY_TOP_LEVEL {
|
||||
if let Some(item) = provider_root.get(key) {
|
||||
live_root.insert(key, item.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Copy model_providers table from provider
|
||||
if let Some(mp) = provider_root.get("model_providers") {
|
||||
live_root.insert("model_providers", mp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write using atomic write
|
||||
crate::codex_config::write_codex_live_atomic(auth, Some(&live_doc.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gemini: merge only key env fields, preserve settings.json (MCP etc.)
|
||||
fn write_gemini_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, write_gemini_env_atomic};
|
||||
|
||||
let auth_type = detect_gemini_auth_type(provider);
|
||||
|
||||
// 1. Read existing env from live .env file
|
||||
let mut env_map = if get_gemini_env_path().exists() {
|
||||
read_gemini_env().unwrap_or_default()
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
// 2. Remove key fields from existing env
|
||||
for key in GEMINI_KEY_ENV_FIELDS {
|
||||
env_map.remove(*key);
|
||||
}
|
||||
|
||||
// 3. Extract key fields from provider and merge
|
||||
if let Some(provider_env) = provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
{
|
||||
for key in GEMINI_KEY_ENV_FIELDS {
|
||||
if let Some(value) = provider_env.get(*key).and_then(|v| v.as_str()) {
|
||||
if !value.is_empty() {
|
||||
env_map.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Handle auth type specific behavior
|
||||
match auth_type {
|
||||
GeminiAuthType::GoogleOfficial => {
|
||||
// Google official uses OAuth, clear all env
|
||||
env_map.clear();
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
|
||||
// Validate and write env
|
||||
crate::gemini_config::validate_gemini_settings_strict(&provider.settings_config)?;
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Handle settings.json (same as write_gemini_live — preserve existing MCP etc.)
|
||||
use crate::gemini_config::get_gemini_settings_path;
|
||||
let settings_path = get_gemini_settings_path();
|
||||
|
||||
if let Some(config_value) = provider.settings_config.get("config") {
|
||||
if config_value.is_object() {
|
||||
let mut merged = if settings_path.exists() {
|
||||
read_json_file::<Value>(&settings_path).unwrap_or_else(|_| json!({}))
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
if let (Some(merged_obj), Some(config_obj)) =
|
||||
(merged.as_object_mut(), config_value.as_object())
|
||||
{
|
||||
for (k, v) in config_obj {
|
||||
merged_obj.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
write_json_file(&settings_path, &merged)?;
|
||||
} else if !config_value.is_null() {
|
||||
return Err(AppError::localized(
|
||||
"gemini.validation.invalid_config",
|
||||
"Gemini 配置格式错误: config 必须是对象或 null",
|
||||
"Gemini config invalid: config must be an object or null",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Set security flag based on auth type
|
||||
match auth_type {
|
||||
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
|
||||
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
|
||||
crate::gemini_config::write_packycode_settings()?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Backfill: extract only key fields from live config
|
||||
// ============================================================================
|
||||
|
||||
/// Extract only provider-specific key fields from a live config value.
|
||||
///
|
||||
/// Used during backfill to ensure the provider's `settings_config` converges
|
||||
/// to containing only key fields over time.
|
||||
pub(crate) fn backfill_key_fields(app_type: &AppType, live_config: &Value) -> Value {
|
||||
match app_type {
|
||||
AppType::Claude => backfill_claude_key_fields(live_config),
|
||||
AppType::Codex => backfill_codex_key_fields(live_config),
|
||||
AppType::Gemini => backfill_gemini_key_fields(live_config),
|
||||
// Additive mode: return full config (no backfill needed)
|
||||
_ => live_config.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn backfill_claude_key_fields(live: &Value) -> Value {
|
||||
let mut result = json!({});
|
||||
let result_obj = result.as_object_mut().unwrap();
|
||||
|
||||
// Extract key env fields
|
||||
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
|
||||
let mut env_obj = serde_json::Map::new();
|
||||
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||
if let Some(value) = live_env.get(*key) {
|
||||
env_obj.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if !env_obj.is_empty() {
|
||||
result_obj.insert("env".to_string(), Value::Object(env_obj));
|
||||
}
|
||||
}
|
||||
|
||||
// Extract key top-level fields
|
||||
if let Some(live_obj) = live.as_object() {
|
||||
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||
if let Some(value) = live_obj.get(*key) {
|
||||
result_obj.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn backfill_codex_key_fields(live: &Value) -> Value {
|
||||
let mut result = json!({});
|
||||
let result_obj = result.as_object_mut().unwrap();
|
||||
|
||||
// auth is entirely provider-specific — keep it as-is
|
||||
if let Some(auth) = live.get("auth") {
|
||||
result_obj.insert("auth".to_string(), auth.clone());
|
||||
}
|
||||
|
||||
// Extract key TOML fields from config string
|
||||
if let Some(config_str) = live.get("config").and_then(|v| v.as_str()) {
|
||||
if let Ok(doc) = config_str.parse::<toml_edit::DocumentMut>() {
|
||||
let mut new_doc = toml_edit::DocumentMut::new();
|
||||
let new_root = new_doc.as_table_mut();
|
||||
|
||||
// Copy key top-level fields
|
||||
for key in CODEX_KEY_TOP_LEVEL {
|
||||
if let Some(item) = doc.as_table().get(key) {
|
||||
new_root.insert(key, item.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Copy model_providers table
|
||||
if let Some(mp) = doc.as_table().get("model_providers") {
|
||||
new_root.insert("model_providers", mp.clone());
|
||||
}
|
||||
|
||||
let toml_str = new_doc.to_string();
|
||||
if !toml_str.trim().is_empty() {
|
||||
result_obj.insert("config".to_string(), Value::String(toml_str));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn backfill_gemini_key_fields(live: &Value) -> Value {
|
||||
let mut result = json!({});
|
||||
let result_obj = result.as_object_mut().unwrap();
|
||||
|
||||
// Extract key env fields
|
||||
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
|
||||
let mut env_obj = serde_json::Map::new();
|
||||
for key in GEMINI_KEY_ENV_FIELDS {
|
||||
if let Some(value) = live_env.get(*key) {
|
||||
env_obj.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if !env_obj.is_empty() {
|
||||
result_obj.insert("env".to_string(), Value::Object(env_obj));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Sync all providers to live configuration (for additive mode apps)
|
||||
///
|
||||
/// Writes all providers from the database to the live configuration file.
|
||||
@@ -719,7 +286,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_partial(&app_type, provider)?;
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
}
|
||||
// Note: get_effective_current_provider already validates existence,
|
||||
// so providers.get() should always succeed here
|
||||
|
||||
@@ -27,12 +27,11 @@ pub use live::{
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
pub(crate) use live::sanitize_claude_settings_for_live;
|
||||
pub(crate) use live::write_live_partial;
|
||||
pub(crate) use live::write_live_snapshot;
|
||||
|
||||
// Internal re-exports
|
||||
use live::{
|
||||
backfill_key_fields, remove_openclaw_provider_from_live, remove_opencode_provider_from_live,
|
||||
write_live_snapshot,
|
||||
remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live,
|
||||
};
|
||||
use usage::validate_usage_script;
|
||||
|
||||
@@ -85,6 +84,47 @@ mod tests {
|
||||
assert_eq!(api_key, "token");
|
||||
assert_eq!(base_url, "https://claude.example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_codex_common_config_preserves_mcp_servers_base_url() {
|
||||
let config_toml = r#"model_provider = "azure"
|
||||
model = "gpt-4"
|
||||
disable_response_storage = true
|
||||
|
||||
[model_providers.azure]
|
||||
name = "Azure OpenAI"
|
||||
base_url = "https://azure.example/v1"
|
||||
wire_api = "responses"
|
||||
|
||||
[mcp_servers.my_server]
|
||||
base_url = "http://localhost:8080"
|
||||
"#;
|
||||
|
||||
let settings = json!({ "config": config_toml });
|
||||
let extracted = ProviderService::extract_codex_common_config(&settings)
|
||||
.expect("extract_codex_common_config should succeed");
|
||||
|
||||
assert!(
|
||||
!extracted
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with("model_provider")),
|
||||
"should remove top-level model_provider"
|
||||
);
|
||||
assert!(
|
||||
!extracted
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with("model =")),
|
||||
"should remove top-level model"
|
||||
);
|
||||
assert!(
|
||||
!extracted.contains("[model_providers"),
|
||||
"should remove entire model_providers table"
|
||||
);
|
||||
assert!(
|
||||
extracted.contains("http://localhost:8080"),
|
||||
"should keep mcp_servers.* base_url"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderService {
|
||||
@@ -152,7 +192,7 @@ impl ProviderService {
|
||||
state
|
||||
.db
|
||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||
write_live_partial(&app_type, &provider)?;
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
@@ -233,7 +273,7 @@ impl ProviderService {
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||
} else {
|
||||
write_live_partial(&app_type, &provider)?;
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
}
|
||||
@@ -520,9 +560,7 @@ 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() {
|
||||
// Only extract key fields from live config for backfill
|
||||
current_provider.settings_config =
|
||||
backfill_key_fields(&app_type, &live_config);
|
||||
current_provider.settings_config = live_config;
|
||||
if let Err(e) =
|
||||
state.db.save_provider(app_type.as_str(), ¤t_provider)
|
||||
{
|
||||
@@ -546,8 +584,11 @@ impl ProviderService {
|
||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||
}
|
||||
|
||||
// Sync to live (partial merge: only key fields, preserving user settings)
|
||||
write_live_partial(&app_type, provider)?;
|
||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -557,6 +598,222 @@ impl ProviderService {
|
||||
sync_current_to_live(state)
|
||||
}
|
||||
|
||||
/// Extract common config snippet from current provider
|
||||
///
|
||||
/// Extracts the current provider's configuration and removes provider-specific fields
|
||||
/// (API keys, model settings, endpoints) to create a reusable common config snippet.
|
||||
pub fn extract_common_config_snippet(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
) -> Result<String, AppError> {
|
||||
// Get current provider
|
||||
let current_id = Self::current(state, app_type.clone())?;
|
||||
if current_id.is_empty() {
|
||||
return Err(AppError::Message("No current provider".to_string()));
|
||||
}
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let provider = providers
|
||||
.get(¤t_id)
|
||||
.ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?;
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract common config snippet from a config value (e.g. editor content).
|
||||
pub fn extract_common_config_snippet_from_settings(
|
||||
app_type: AppType,
|
||||
settings_config: &Value,
|
||||
) -> Result<String, AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract common config for Claude (JSON format)
|
||||
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
let mut config = settings.clone();
|
||||
|
||||
// Fields to exclude from common config
|
||||
const ENV_EXCLUDES: &[&str] = &[
|
||||
// Auth
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
// Models (5 fields)
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_REASONING_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
// Endpoint
|
||||
"ANTHROPIC_BASE_URL",
|
||||
];
|
||||
|
||||
const TOP_LEVEL_EXCLUDES: &[&str] = &[
|
||||
"apiBaseUrl",
|
||||
// Legacy model fields
|
||||
"primaryModel",
|
||||
"smallFastModel",
|
||||
];
|
||||
|
||||
// Remove env fields
|
||||
if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
for key in ENV_EXCLUDES {
|
||||
env.remove(*key);
|
||||
}
|
||||
// If env is empty after removal, remove the env object itself
|
||||
if env.is_empty() {
|
||||
config.as_object_mut().map(|obj| obj.remove("env"));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove top-level fields
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
for key in TOP_LEVEL_EXCLUDES {
|
||||
obj.remove(*key);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if result is empty
|
||||
if config.as_object().is_none_or(|obj| obj.is_empty()) {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Extract common config for Codex (TOML format)
|
||||
fn extract_codex_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// Codex config is stored as { "auth": {...}, "config": "toml string" }
|
||||
let config_toml = settings
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if config_toml.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let mut doc = config_toml
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?;
|
||||
|
||||
// Remove provider-specific fields.
|
||||
let root = doc.as_table_mut();
|
||||
root.remove("model");
|
||||
root.remove("model_provider");
|
||||
// Legacy/alt formats might use a top-level base_url.
|
||||
root.remove("base_url");
|
||||
|
||||
// Remove entire model_providers table (provider-specific configuration)
|
||||
root.remove("model_providers");
|
||||
|
||||
// Clean up multiple empty lines (keep at most one blank line).
|
||||
let mut cleaned = String::new();
|
||||
let mut blank_run = 0usize;
|
||||
for line in doc.to_string().lines() {
|
||||
if line.trim().is_empty() {
|
||||
blank_run += 1;
|
||||
if blank_run <= 1 {
|
||||
cleaned.push('\n');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
blank_run = 0;
|
||||
cleaned.push_str(line);
|
||||
cleaned.push('\n');
|
||||
}
|
||||
|
||||
Ok(cleaned.trim().to_string())
|
||||
}
|
||||
|
||||
/// Extract common config for Gemini (JSON format)
|
||||
///
|
||||
/// Extracts `.env` values while excluding provider-specific credentials:
|
||||
/// - GOOGLE_GEMINI_BASE_URL
|
||||
/// - GEMINI_API_KEY
|
||||
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
let env = settings.get("env").and_then(|v| v.as_object());
|
||||
|
||||
let mut snippet = serde_json::Map::new();
|
||||
if let Some(env) = env {
|
||||
for (key, value) in env {
|
||||
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
||||
continue;
|
||||
}
|
||||
let Value::String(v) = value else {
|
||||
continue;
|
||||
};
|
||||
let trimmed = v.trim();
|
||||
if !trimmed.is_empty() {
|
||||
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if snippet.is_empty() {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&Value::Object(snippet))
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Extract common config for OpenCode (JSON format)
|
||||
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// OpenCode uses a different config structure with npm, options, models
|
||||
// For common config, we exclude provider-specific fields like apiKey
|
||||
let mut config = settings.clone();
|
||||
|
||||
// Remove provider-specific fields
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
|
||||
options.remove("apiKey");
|
||||
options.remove("baseURL");
|
||||
}
|
||||
// Keep npm and models as they might be common
|
||||
}
|
||||
|
||||
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Extract common config for OpenClaw (JSON format)
|
||||
fn extract_openclaw_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// OpenClaw uses a different config structure with baseUrl, apiKey, api, models
|
||||
// For common config, we exclude provider-specific fields like apiKey
|
||||
let mut config = settings.clone();
|
||||
|
||||
// Remove provider-specific fields
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
obj.remove("apiKey");
|
||||
obj.remove("baseUrl");
|
||||
// Keep api and models as they might be common
|
||||
}
|
||||
|
||||
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Import default configuration from live files (re-export)
|
||||
///
|
||||
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
||||
@@ -569,11 +826,6 @@ impl ProviderService {
|
||||
read_live_settings(app_type)
|
||||
}
|
||||
|
||||
/// Patch Claude live settings directly (user-level preferences)
|
||||
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
|
||||
live::patch_claude_live(patch)
|
||||
}
|
||||
|
||||
/// Get custom endpoints list (re-export)
|
||||
pub fn get_custom_endpoints(
|
||||
state: &AppState,
|
||||
@@ -669,6 +921,10 @@ impl ProviderService {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||
write_gemini_live(provider)
|
||||
}
|
||||
|
||||
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
|
||||
@@ -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_partial;
|
||||
use crate::services::provider::write_live_snapshot;
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -1266,7 +1266,7 @@ impl ProxyService {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
write_live_partial(app_type, provider)
|
||||
write_live_snapshot(app_type, provider)
|
||||
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
|
||||
@@ -207,6 +207,7 @@ impl StreamCheckService {
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
provider,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -283,7 +284,9 @@ impl StreamCheckService {
|
||||
|
||||
/// Claude 流式检查
|
||||
///
|
||||
/// 严格按照 Claude CLI 真实请求格式构建请求
|
||||
/// 根据供应商的 api_format 选择请求格式:
|
||||
/// - "anthropic" (默认): Anthropic Messages API (/v1/messages)
|
||||
/// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions)
|
||||
async fn check_claude_stream(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
@@ -291,15 +294,53 @@ impl StreamCheckService {
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
provider: &Provider,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
// 健康检查不注入 ?beta=true,依赖 anthropic-beta header 传递身份验证信息
|
||||
let url = if base.ends_with("/v1") {
|
||||
format!("{base}/messages")
|
||||
|
||||
// Detect api_format: meta.api_format > settings_config.api_format > default "anthropic"
|
||||
let api_format = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.api_format.as_deref())
|
||||
.or_else(|| {
|
||||
provider
|
||||
.settings_config
|
||||
.get("api_format")
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
.unwrap_or("anthropic");
|
||||
|
||||
let is_openai_chat = api_format == "openai_chat";
|
||||
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.is_full_url)
|
||||
.unwrap_or(false);
|
||||
|
||||
// URL rules:
|
||||
// - full URL mode: use base_url as-is
|
||||
// - openai_chat: /v1/chat/completions
|
||||
// - anthropic: /v1/messages?beta=true
|
||||
let url = if is_full_url {
|
||||
base_url.to_string()
|
||||
} else if is_openai_chat {
|
||||
if base.ends_with("/v1") {
|
||||
format!("{base}/chat/completions")
|
||||
} else {
|
||||
format!("{base}/v1/chat/completions")
|
||||
}
|
||||
} else {
|
||||
format!("{base}/v1/messages")
|
||||
// ?beta=true is required by some relay services to verify request origin
|
||||
if base.ends_with("/v1") {
|
||||
format!("{base}/messages?beta=true")
|
||||
} else {
|
||||
format!("{base}/v1/messages?beta=true")
|
||||
}
|
||||
};
|
||||
|
||||
// Body: identical structure for minimal test (both APIs accept messages array)
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"max_tokens": 1,
|
||||
@@ -307,49 +348,58 @@ impl StreamCheckService {
|
||||
"stream": true
|
||||
});
|
||||
|
||||
// 获取本地系统信息
|
||||
let os_name = Self::get_os_name();
|
||||
let arch_name = Self::get_arch_name();
|
||||
let mut request_builder = client.post(&url);
|
||||
|
||||
// 根据 auth.strategy 构建认证 headers
|
||||
let mut request_builder = client
|
||||
.post(&url)
|
||||
.header("authorization", format!("Bearer {}", auth.api_key));
|
||||
if is_openai_chat {
|
||||
// OpenAI-compatible: Bearer auth + standard headers only
|
||||
request_builder = request_builder
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json");
|
||||
} else {
|
||||
// Anthropic native: full Claude CLI headers
|
||||
let os_name = Self::get_os_name();
|
||||
let arch_name = Self::get_arch_name();
|
||||
|
||||
// 只有 Anthropic 官方策略才添加 x-api-key
|
||||
if auth.strategy == AuthStrategy::Anthropic {
|
||||
request_builder = request_builder.header("x-api-key", &auth.api_key);
|
||||
request_builder =
|
||||
request_builder.header("authorization", format!("Bearer {}", auth.api_key));
|
||||
|
||||
// Only Anthropic official strategy adds x-api-key
|
||||
if auth.strategy == AuthStrategy::Anthropic {
|
||||
request_builder = request_builder.header("x-api-key", &auth.api_key);
|
||||
}
|
||||
|
||||
request_builder = request_builder
|
||||
// Anthropic required headers
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header(
|
||||
"anthropic-beta",
|
||||
"claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||
)
|
||||
.header("anthropic-dangerous-direct-browser-access", "true")
|
||||
// Content type headers
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json")
|
||||
.header("accept-encoding", "identity")
|
||||
.header("accept-language", "*")
|
||||
// Client identification headers
|
||||
.header("user-agent", "claude-cli/2.1.2 (external, cli)")
|
||||
.header("x-app", "cli")
|
||||
// x-stainless SDK headers (dynamic local system info)
|
||||
.header("x-stainless-lang", "js")
|
||||
.header("x-stainless-package-version", "0.70.0")
|
||||
.header("x-stainless-os", os_name)
|
||||
.header("x-stainless-arch", arch_name)
|
||||
.header("x-stainless-runtime", "node")
|
||||
.header("x-stainless-runtime-version", "v22.20.0")
|
||||
.header("x-stainless-retry-count", "0")
|
||||
.header("x-stainless-timeout", "600")
|
||||
// Other headers
|
||||
.header("sec-fetch-mode", "cors")
|
||||
.header("connection", "keep-alive");
|
||||
}
|
||||
|
||||
// 严格按照 Claude CLI 请求格式设置其他 headers
|
||||
let response = request_builder
|
||||
// Anthropic 必需 headers
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header(
|
||||
"anthropic-beta",
|
||||
"claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||
)
|
||||
.header("anthropic-dangerous-direct-browser-access", "true")
|
||||
// 内容类型 headers
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json")
|
||||
.header("accept-encoding", "identity")
|
||||
.header("accept-language", "*")
|
||||
// 客户端标识 headers
|
||||
.header("user-agent", "claude-cli/2.1.2 (external, cli)")
|
||||
.header("x-app", "cli")
|
||||
// x-stainless SDK headers(动态获取本地系统信息)
|
||||
.header("x-stainless-lang", "js")
|
||||
.header("x-stainless-package-version", "0.70.0")
|
||||
.header("x-stainless-os", os_name)
|
||||
.header("x-stainless-arch", arch_name)
|
||||
.header("x-stainless-runtime", "node")
|
||||
.header("x-stainless-runtime-version", "v22.20.0")
|
||||
.header("x-stainless-retry-count", "0")
|
||||
.header("x-stainless-timeout", "600")
|
||||
// 其他 headers
|
||||
.header("sec-fetch-mode", "cors")
|
||||
.header("connection", "keep-alive")
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
.send()
|
||||
|
||||
@@ -196,6 +196,9 @@ pub struct AppSettings {
|
||||
/// User has confirmed the usage query first-run notice
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub usage_confirmed: Option<bool>,
|
||||
/// User has confirmed the stream check first-run notice
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stream_check_confirmed: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
|
||||
@@ -282,6 +285,7 @@ impl Default for AppSettings {
|
||||
enable_local_proxy: false,
|
||||
proxy_confirmed: None,
|
||||
usage_confirmed: None,
|
||||
stream_check_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.0",
|
||||
"version": "3.11.1",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -2,7 +2,10 @@ use serde_json::json;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cc_switch_lib::{AppError, AppType, ConfigService, MultiAppConfig, Provider};
|
||||
use cc_switch_lib::{
|
||||
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig,
|
||||
Provider, ProviderMeta,
|
||||
};
|
||||
|
||||
#[path = "support.rs"]
|
||||
mod support;
|
||||
@@ -10,6 +13,132 @@ use support::{
|
||||
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn sync_claude_provider_writes_live_settings() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
let provider_config = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "test-key",
|
||||
"ANTHROPIC_BASE_URL": "https://api.test"
|
||||
},
|
||||
"ui": {
|
||||
"displayName": "Test Provider"
|
||||
}
|
||||
});
|
||||
|
||||
let provider = Provider::with_id(
|
||||
"prov-1".to_string(),
|
||||
"Test Claude".to_string(),
|
||||
provider_config.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Claude)
|
||||
.expect("claude manager");
|
||||
manager.providers.insert("prov-1".to_string(), provider);
|
||||
manager.current = "prov-1".to_string();
|
||||
|
||||
ConfigService::sync_current_providers_to_live(&mut config).expect("sync live settings");
|
||||
|
||||
let settings_path = get_claude_settings_path();
|
||||
assert!(
|
||||
settings_path.exists(),
|
||||
"live settings should be written to {}",
|
||||
settings_path.display()
|
||||
);
|
||||
|
||||
let live_value: serde_json::Value = read_json_file(&settings_path).expect("read live file");
|
||||
assert_eq!(live_value, provider_config);
|
||||
|
||||
// 确认 SSOT 中的供应商也同步了最新内容
|
||||
let updated = config
|
||||
.get_manager(&AppType::Claude)
|
||||
.and_then(|m| m.providers.get("prov-1"))
|
||||
.expect("provider in config");
|
||||
assert_eq!(updated.settings_config, provider_config);
|
||||
|
||||
// 额外确认写入位置位于测试 HOME 下
|
||||
assert!(
|
||||
settings_path.starts_with(home),
|
||||
"settings path {settings_path:?} should reside under test HOME {home:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_codex_provider_writes_auth_and_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
|
||||
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发
|
||||
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入
|
||||
|
||||
let provider_config = json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "codex-key"
|
||||
},
|
||||
"config": r#"base_url = "https://codex.test""#
|
||||
});
|
||||
|
||||
let provider = Provider::with_id(
|
||||
"codex-1".to_string(),
|
||||
"Codex Test".to_string(),
|
||||
provider_config.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Codex)
|
||||
.expect("codex manager");
|
||||
manager.providers.insert("codex-1".to_string(), provider);
|
||||
manager.current = "codex-1".to_string();
|
||||
|
||||
ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live");
|
||||
|
||||
let auth_path = cc_switch_lib::get_codex_auth_path();
|
||||
let config_path = cc_switch_lib::get_codex_config_path();
|
||||
|
||||
assert!(
|
||||
auth_path.exists(),
|
||||
"auth.json should exist at {}",
|
||||
auth_path.display()
|
||||
);
|
||||
assert!(
|
||||
config_path.exists(),
|
||||
"config.toml should exist at {}",
|
||||
config_path.display()
|
||||
);
|
||||
|
||||
let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth");
|
||||
assert_eq!(
|
||||
auth_value,
|
||||
provider_config.get("auth").cloned().expect("auth object")
|
||||
);
|
||||
|
||||
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
|
||||
// 验证基础配置正确写入
|
||||
assert!(
|
||||
toml_text.contains("base_url"),
|
||||
"config.toml should contain base_url from provider config"
|
||||
);
|
||||
|
||||
// 当前供应商应同步最新 config 文本
|
||||
let manager = config.get_manager(&AppType::Codex).expect("codex manager");
|
||||
let synced = manager.providers.get("codex-1").expect("codex provider");
|
||||
let synced_cfg = synced
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("config string");
|
||||
assert_eq!(synced_cfg, toml_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_enabled_to_codex_writes_enabled_servers() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
@@ -209,6 +338,46 @@ fn sync_enabled_to_codex_returns_error_on_invalid_toml() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_codex_provider_missing_auth_returns_error() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
let provider = Provider::with_id(
|
||||
"codex-missing-auth".to_string(),
|
||||
"No Auth".to_string(),
|
||||
json!({
|
||||
"config": "model = \"test\""
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Codex)
|
||||
.expect("codex manager");
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
manager.current = "codex-missing-auth".to_string();
|
||||
|
||||
let err = ConfigService::sync_current_providers_to_live(&mut config)
|
||||
.expect_err("sync should fail when auth missing");
|
||||
match err {
|
||||
cc_switch_lib::AppError::Config(msg) => {
|
||||
assert!(msg.contains("auth"), "error message should mention auth");
|
||||
}
|
||||
other => panic!("unexpected error variant: {other:?}"),
|
||||
}
|
||||
|
||||
// 确认未产生任何 live 配置文件
|
||||
assert!(
|
||||
!cc_switch_lib::get_codex_auth_path().exists(),
|
||||
"auth.json should not be created on failure"
|
||||
);
|
||||
assert!(
|
||||
!cc_switch_lib::get_codex_config_path().exists(),
|
||||
"config.toml should not be created on failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_codex_live_atomic_persists_auth_and_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
@@ -647,6 +816,107 @@ fn create_backup_retains_only_latest_entries() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_gemini_packycode_sets_security_selected_type() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Gemini)
|
||||
.expect("gemini manager");
|
||||
manager.current = "packy-1".to_string();
|
||||
manager.providers.insert(
|
||||
"packy-1".to_string(),
|
||||
Provider::with_id(
|
||||
"packy-1".to_string(),
|
||||
"PackyCode".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"GEMINI_API_KEY": "pk-key",
|
||||
"GOOGLE_GEMINI_BASE_URL": "https://api-slb.packyapi.com"
|
||||
}
|
||||
}),
|
||||
Some("https://www.packyapi.com".to_string()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ConfigService::sync_current_providers_to_live(&mut config)
|
||||
.expect("syncing gemini live should succeed");
|
||||
|
||||
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||
assert!(
|
||||
gemini_settings.exists(),
|
||||
"Gemini settings.json should exist at {}",
|
||||
gemini_settings.display()
|
||||
);
|
||||
|
||||
let raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings.json");
|
||||
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse gemini settings.json");
|
||||
assert_eq!(
|
||||
value
|
||||
.pointer("/security/auth/selectedType")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("gemini-api-key"),
|
||||
"syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_gemini_google_official_sets_oauth_security() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Gemini)
|
||||
.expect("gemini manager");
|
||||
manager.current = "google-official".to_string();
|
||||
let mut provider = Provider::with_id(
|
||||
"google-official".to_string(),
|
||||
"Google".to_string(),
|
||||
json!({
|
||||
"env": {}
|
||||
}),
|
||||
Some("https://ai.google.dev".to_string()),
|
||||
);
|
||||
provider.meta = Some(ProviderMeta {
|
||||
partner_promotion_key: Some("google-official".to_string()),
|
||||
..ProviderMeta::default()
|
||||
});
|
||||
manager
|
||||
.providers
|
||||
.insert("google-official".to_string(), provider);
|
||||
}
|
||||
|
||||
ConfigService::sync_current_providers_to_live(&mut config)
|
||||
.expect("syncing google official gemini should succeed");
|
||||
|
||||
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||
assert!(
|
||||
gemini_settings.exists(),
|
||||
"Gemini settings should exist at {}",
|
||||
gemini_settings.display()
|
||||
);
|
||||
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
|
||||
let gemini_value: serde_json::Value =
|
||||
serde_json::from_str(&gemini_raw).expect("parse gemini settings json");
|
||||
assert_eq!(
|
||||
gemini_value
|
||||
.pointer("/security/auth/selectedType")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("oauth-personal"),
|
||||
"Gemini settings should record oauth-personal for Google Official"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_sql_writes_to_target_path() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
|
||||
@@ -100,12 +100,9 @@ command = "say"
|
||||
);
|
||||
|
||||
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
|
||||
// With partial merge, only key fields (model, provider, model_providers) are
|
||||
// merged into config.toml. The existing MCP section should be preserved.
|
||||
// MCP sync from DB is handled separately (at startup or explicit sync).
|
||||
assert!(
|
||||
config_text.contains("mcp_servers.legacy"),
|
||||
"config.toml should preserve existing MCP servers after partial merge"
|
||||
config_text.contains("mcp_servers.echo-server"),
|
||||
"config.toml should contain synced MCP servers"
|
||||
);
|
||||
|
||||
let current_id = app_state
|
||||
@@ -129,9 +126,12 @@ command = "say"
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
// With partial merge, only key fields (model_provider, model, model_providers)
|
||||
// are written to the live file. MCP servers are synced separately.
|
||||
// The provider's stored config should still contain mcp_servers.latest.
|
||||
// 供应商配置应该包含在 live 文件中
|
||||
// 注意:live 文件还会包含 MCP 同步后的内容
|
||||
assert!(
|
||||
config_text.contains("mcp_servers.latest"),
|
||||
"live file should contain provider's original config"
|
||||
);
|
||||
assert!(
|
||||
new_config_text.contains("mcp_servers.latest"),
|
||||
"provider snapshot should contain provider's original config"
|
||||
@@ -268,22 +268,11 @@ fn switch_provider_updates_claude_live_and_state() {
|
||||
let legacy_provider = providers
|
||||
.get("old-provider")
|
||||
.expect("legacy provider still exists");
|
||||
// Backfill mechanism: before switching, the live config's key fields are
|
||||
// backfilled to the current provider. With partial merge, only key fields
|
||||
// (auth, model, endpoint) are extracted — non-key fields like workspace
|
||||
// are NOT included in the backfill.
|
||||
// 回填机制:切换前会将 live 配置回填到当前供应商
|
||||
// 这保护了用户在 live 文件中的手动修改
|
||||
assert_eq!(
|
||||
legacy_provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|key| key.as_str()),
|
||||
Some("legacy-key"),
|
||||
"previous provider should be backfilled with live auth key"
|
||||
);
|
||||
assert!(
|
||||
legacy_provider.settings_config.get("workspace").is_none(),
|
||||
"backfill should NOT include non-key fields like workspace"
|
||||
legacy_provider.settings_config, legacy_live,
|
||||
"previous provider should be backfilled with live config"
|
||||
);
|
||||
|
||||
let new_provider = providers.get("new-provider").expect("new provider exists");
|
||||
|
||||
@@ -112,12 +112,9 @@ command = "say"
|
||||
|
||||
let config_text =
|
||||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
||||
// With partial merge, only key fields (model, provider, model_providers) are
|
||||
// merged into config.toml. The existing MCP section should be preserved.
|
||||
// MCP sync from DB is handled separately (at startup or explicit sync).
|
||||
assert!(
|
||||
config_text.contains("mcp_servers.legacy"),
|
||||
"config.toml should preserve existing MCP servers after partial merge"
|
||||
config_text.contains("mcp_servers.echo-server"),
|
||||
"config.toml should contain synced MCP servers"
|
||||
);
|
||||
|
||||
let current_id = state
|
||||
@@ -146,6 +143,11 @@ command = "say"
|
||||
new_config_text.contains("mcp_servers.latest"),
|
||||
"provider config should contain original MCP servers"
|
||||
);
|
||||
// live 文件额外包含同步的 MCP 服务器
|
||||
assert!(
|
||||
config_text.contains("mcp_servers.echo-server"),
|
||||
"live config should include synced MCP servers"
|
||||
);
|
||||
|
||||
let legacy = providers
|
||||
.get("old-provider")
|
||||
@@ -412,19 +414,9 @@ fn provider_service_switch_claude_updates_live_and_state() {
|
||||
let legacy_provider = providers
|
||||
.get("old-provider")
|
||||
.expect("legacy provider still exists");
|
||||
// With partial merge backfill, only key fields are extracted from live config
|
||||
assert_eq!(
|
||||
legacy_provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|key| key.as_str()),
|
||||
Some("legacy-key"),
|
||||
"previous provider should receive backfilled auth key"
|
||||
);
|
||||
assert!(
|
||||
legacy_provider.settings_config.get("workspace").is_none(),
|
||||
"backfill should NOT include non-key fields like workspace"
|
||||
legacy_provider.settings_config, legacy_live,
|
||||
"previous provider should receive backfilled live config"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user