Merge branch 'main' into codex/issue-1888-interception-matrix

This commit is contained in:
YoVinchen
2026-05-12 22:34:03 +08:00
248 changed files with 23923 additions and 2295 deletions
+17 -1
View File
@@ -735,7 +735,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.13.0"
version = "3.14.1"
dependencies = [
"anyhow",
"arboard",
@@ -785,6 +785,7 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -5687,6 +5688,21 @@ dependencies = [
"zip 4.6.1",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
dependencies = [
"bitflags 2.11.0",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-runtime"
version = "2.10.1"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.13.0"
version = "3.14.1"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
@@ -35,6 +35,7 @@ tauri-plugin-updater = "2"
tauri-plugin-dialog = "2"
tauri-plugin-store = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-window-state = "2"
dirs = "5.0"
toml = "0.8"
toml_edit = "0.22"
+101 -7
View File
@@ -15,6 +15,8 @@ pub struct McpApps {
pub gemini: bool,
#[serde(default)]
pub opencode: bool,
#[serde(default)]
pub hermes: bool,
}
impl McpApps {
@@ -26,6 +28,8 @@ impl McpApps {
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
AppType::Hermes => self.hermes,
AppType::ClaudeDesktop => false,
}
}
@@ -37,6 +41,8 @@ impl McpApps {
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
AppType::Hermes => self.hermes = enabled,
AppType::ClaudeDesktop => {} // Claude Desktop 3P provider config doesn't support MCP here
}
}
@@ -55,12 +61,15 @@ impl McpApps {
if self.opencode {
apps.push(AppType::OpenCode);
}
if self.hermes {
apps.push(AppType::Hermes);
}
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
}
}
@@ -75,6 +84,8 @@ pub struct SkillApps {
pub gemini: bool,
#[serde(default)]
pub opencode: bool,
#[serde(default)]
pub hermes: bool,
}
impl SkillApps {
@@ -85,7 +96,9 @@ impl SkillApps {
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::Hermes => self.hermes,
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
AppType::ClaudeDesktop => false,
}
}
@@ -96,7 +109,9 @@ impl SkillApps {
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::Hermes => self.hermes = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
AppType::ClaudeDesktop => {} // Claude Desktop 3P profiles don't use CC Switch skill sync
}
}
@@ -115,12 +130,15 @@ impl SkillApps {
if self.opencode {
apps.push(AppType::OpenCode);
}
if self.hermes {
apps.push(AppType::Hermes);
}
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
}
/// 仅启用指定应用(其他应用设为禁用)
@@ -241,6 +259,14 @@ pub struct McpRoot {
/// 旧的分应用存储(v3.6.x 及以前,保留用于迁移)
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub claude: McpConfig,
#[serde(
rename = "claude-desktop",
alias = "claudeDesktop",
alias = "claude_desktop",
default,
skip_serializing_if = "McpConfig::is_empty"
)]
pub claude_desktop: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub codex: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
@@ -251,6 +277,9 @@ pub struct McpRoot {
/// OpenClaw MCP 配置(v4.1.0+,实际使用 openclaw.json
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub openclaw: McpConfig,
/// Hermes MCP 配置(实际使用 config.yaml
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub hermes: McpConfig,
}
impl Default for McpRoot {
@@ -260,10 +289,12 @@ impl Default for McpRoot {
servers: Some(HashMap::new()),
// 旧结构保持空,仅用于反序列化旧配置时的迁移
claude: McpConfig::default(),
claude_desktop: McpConfig::default(),
codex: McpConfig::default(),
gemini: McpConfig::default(),
opencode: McpConfig::default(),
openclaw: McpConfig::default(),
hermes: McpConfig::default(),
}
}
}
@@ -280,6 +311,13 @@ pub struct PromptConfig {
pub struct PromptRoot {
#[serde(default)]
pub claude: PromptConfig,
#[serde(
rename = "claude-desktop",
alias = "claudeDesktop",
alias = "claude_desktop",
default
)]
pub claude_desktop: PromptConfig,
#[serde(default)]
pub codex: PromptConfig,
#[serde(default)]
@@ -288,6 +326,8 @@ pub struct PromptRoot {
pub opencode: PromptConfig,
#[serde(default)]
pub openclaw: PromptConfig,
#[serde(default)]
pub hermes: PromptConfig,
}
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
@@ -296,43 +336,57 @@ use crate::prompt_files::prompt_file_path;
use crate::provider::ProviderManager;
/// 应用类型
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AppType {
Claude,
#[serde(
rename = "claude-desktop",
alias = "claude_desktop",
alias = "claudeDesktop"
)]
ClaudeDesktop,
Codex,
Gemini,
OpenCode,
OpenClaw,
Hermes,
}
impl AppType {
pub fn as_str(&self) -> &str {
match self {
AppType::Claude => "claude",
AppType::ClaudeDesktop => "claude-desktop",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
AppType::OpenClaw => "openclaw",
AppType::Hermes => "hermes",
}
}
/// Check if this app uses additive mode
///
/// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini)
/// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw)
/// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw, Hermes)
pub fn is_additive_mode(&self) -> bool {
matches!(self, AppType::OpenCode | AppType::OpenClaw)
matches!(
self,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes
)
}
/// Return an iterator over all app types
pub fn all() -> impl Iterator<Item = AppType> {
[
AppType::Claude,
AppType::ClaudeDesktop,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
]
.into_iter()
}
@@ -345,14 +399,16 @@ impl FromStr for AppType {
let normalized = s.trim().to_lowercase();
match normalized.as_str() {
"claude" => Ok(AppType::Claude),
"claude-desktop" | "claude_desktop" | "claudedesktop" => Ok(AppType::ClaudeDesktop),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
"openclaw" => Ok(AppType::OpenClaw),
"hermes" => Ok(AppType::Hermes),
other => Err(AppError::localized(
"unsupported_app",
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw."),
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes"),
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes."),
)),
}
}
@@ -375,6 +431,9 @@ pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openclaw: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hermes: Option<String>,
}
impl CommonConfigSnippets {
@@ -382,10 +441,12 @@ impl CommonConfigSnippets {
pub fn get(&self, app: &AppType) -> Option<&String> {
match app {
AppType::Claude => self.claude.as_ref(),
AppType::ClaudeDesktop => None,
AppType::Codex => self.codex.as_ref(),
AppType::Gemini => self.gemini.as_ref(),
AppType::OpenCode => self.opencode.as_ref(),
AppType::OpenClaw => self.openclaw.as_ref(),
AppType::Hermes => self.hermes.as_ref(),
}
}
@@ -393,10 +454,12 @@ impl CommonConfigSnippets {
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
match app {
AppType::Claude => self.claude = snippet,
AppType::ClaudeDesktop => {}
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
AppType::OpenCode => self.opencode = snippet,
AppType::OpenClaw => self.openclaw = snippet,
AppType::Hermes => self.hermes = snippet,
}
}
}
@@ -434,10 +497,12 @@ impl Default for MultiAppConfig {
fn default() -> Self {
let mut apps = HashMap::new();
apps.insert("claude".to_string(), ProviderManager::default());
apps.insert("claude-desktop".to_string(), ProviderManager::default());
apps.insert("codex".to_string(), ProviderManager::default());
apps.insert("gemini".to_string(), ProviderManager::default());
apps.insert("opencode".to_string(), ProviderManager::default());
apps.insert("openclaw".to_string(), ProviderManager::default());
apps.insert("hermes".to_string(), ProviderManager::default());
Self {
version: 2,
@@ -594,10 +659,12 @@ impl MultiAppConfig {
pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
match app {
AppType::Claude => &self.mcp.claude,
AppType::ClaudeDesktop => &self.mcp.claude_desktop,
AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini,
AppType::OpenCode => &self.mcp.opencode,
AppType::OpenClaw => &self.mcp.openclaw,
AppType::Hermes => &self.mcp.hermes,
}
}
@@ -605,10 +672,12 @@ impl MultiAppConfig {
pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
match app {
AppType::Claude => &mut self.mcp.claude,
AppType::ClaudeDesktop => &mut self.mcp.claude_desktop,
AppType::Codex => &mut self.mcp.codex,
AppType::Gemini => &mut self.mcp.gemini,
AppType::OpenCode => &mut self.mcp.opencode,
AppType::OpenClaw => &mut self.mcp.openclaw,
AppType::Hermes => &mut self.mcp.hermes,
}
}
@@ -624,6 +693,7 @@ impl MultiAppConfig {
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?;
Ok(config)
}
@@ -641,10 +711,12 @@ impl MultiAppConfig {
fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {
// 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入
if !self.prompts.claude.prompts.is_empty()
|| !self.prompts.claude_desktop.prompts.is_empty()
|| !self.prompts.codex.prompts.is_empty()
|| !self.prompts.gemini.prompts.is_empty()
|| !self.prompts.opencode.prompts.is_empty()
|| !self.prompts.openclaw.prompts.is_empty()
|| !self.prompts.hermes.prompts.is_empty()
{
return Ok(false);
}
@@ -658,6 +730,7 @@ impl MultiAppConfig {
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
@@ -725,10 +798,12 @@ impl MultiAppConfig {
// 插入到对应的应用配置中
let prompts = match app {
AppType::Claude => &mut config.prompts.claude.prompts,
AppType::ClaudeDesktop => &mut config.prompts.claude_desktop.prompts,
AppType::Codex => &mut config.prompts.codex.prompts,
AppType::Gemini => &mut config.prompts.gemini.prompts,
AppType::OpenCode => &mut config.prompts.opencode.prompts,
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
AppType::Hermes => &mut config.prompts.hermes.prompts,
};
prompts.insert(id, prompt);
@@ -765,10 +840,12 @@ impl MultiAppConfig {
] {
let old_servers = match app {
AppType::Claude => &self.mcp.claude.servers,
AppType::ClaudeDesktop => continue, // Claude Desktop 3P profiles don't use MCP here
AppType::Codex => &self.mcp.codex.servers,
AppType::Gemini => &self.mcp.gemini.servers,
AppType::OpenCode => &self.mcp.opencode.servers,
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
AppType::Hermes => continue, // Hermes didn't exist in v3.6.x, skip
};
for (id, entry) in old_servers {
@@ -884,6 +961,23 @@ mod tests {
use std::fs;
use tempfile::TempDir;
#[test]
fn app_type_parses_claude_desktop_aliases() {
assert_eq!(
"claude-desktop".parse::<AppType>().unwrap(),
AppType::ClaudeDesktop
);
assert_eq!(
"claude_desktop".parse::<AppType>().unwrap(),
AppType::ClaudeDesktop
);
assert_eq!(
"claudeDesktop".parse::<AppType>().unwrap(),
AppType::ClaudeDesktop
);
assert_eq!(AppType::ClaudeDesktop.as_str(), "claude-desktop");
}
struct TempHome {
#[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期
dir: TempDir,
File diff suppressed because it is too large Load Diff
+511
View File
@@ -11,6 +11,20 @@ use std::fs;
use std::path::Path;
use toml_edit::DocumentMut;
pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider
/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy
/// removed provider aliases.
const CODEX_RESERVED_MODEL_PROVIDER_IDS: &[&str] = &[
"amazon-bedrock",
"openai",
"ollama",
"lmstudio",
"oss",
"ollama-chat",
];
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
@@ -137,6 +151,268 @@ pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
Ok(s)
}
fn active_codex_model_provider_id(doc: &DocumentMut) -> Option<String> {
doc.get("model_provider")
.and_then(|item| item.as_str())
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_string)
}
fn is_custom_codex_model_provider_id(id: &str) -> bool {
let id = id.trim();
!id.is_empty()
&& !CODEX_RESERVED_MODEL_PROVIDER_IDS
.iter()
.any(|reserved| reserved.eq_ignore_ascii_case(id))
}
fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option<String> {
let doc = config_text.parse::<DocumentMut>().ok()?;
let provider_id = active_codex_model_provider_id(&doc)?;
if is_custom_codex_model_provider_id(&provider_id) {
Some(provider_id)
} else {
None
}
}
fn codex_model_provider_id_with_table_from_config(
config_text: &str,
) -> Result<Option<String>, AppError> {
if config_text.trim().is_empty() {
return Ok(None);
}
let doc = config_text
.parse::<DocumentMut>()
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
let Some(provider_id) = active_codex_model_provider_id(&doc) else {
return Ok(None);
};
let has_provider_table = doc
.get("model_providers")
.and_then(|item| item.as_table())
.and_then(|table| table.get(provider_id.as_str()))
.is_some();
Ok(has_provider_table.then_some(provider_id))
}
fn normalize_codex_live_config_model_provider_with_anchors<'a>(
config_text: &str,
anchor_config_texts: impl IntoIterator<Item = &'a str>,
) -> Result<String, AppError> {
if config_text.trim().is_empty() {
return Ok(config_text.to_string());
}
let mut doc = config_text
.parse::<DocumentMut>()
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
let Some(source_provider_id) = active_codex_model_provider_id(&doc) else {
return Ok(config_text.to_string());
};
let has_source_provider_table = doc
.get("model_providers")
.and_then(|item| item.as_table())
.and_then(|table| table.get(source_provider_id.as_str()))
.is_some();
if !has_source_provider_table {
return Ok(config_text.to_string());
}
let stable_provider_id = anchor_config_texts
.into_iter()
.find_map(stable_codex_model_provider_id_from_config)
.or_else(|| {
is_custom_codex_model_provider_id(&source_provider_id)
.then(|| source_provider_id.clone())
})
.unwrap_or_else(|| CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
if stable_provider_id == source_provider_id {
return Ok(config_text.to_string());
}
if let Some(model_providers) = doc
.get_mut("model_providers")
.and_then(|item| item.as_table_mut())
{
let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
return Ok(config_text.to_string());
};
model_providers[stable_provider_id.as_str()] = provider_table;
}
rewrite_codex_profile_model_provider_refs(&mut doc, &source_provider_id, &stable_provider_id);
doc["model_provider"] = toml_edit::value(stable_provider_id.as_str());
Ok(doc.to_string())
}
fn rewrite_codex_profile_model_provider_refs(
doc: &mut DocumentMut,
source_provider_id: &str,
stable_provider_id: &str,
) {
let Some(profiles) = doc
.get_mut("profiles")
.and_then(|item| item.as_table_like_mut())
else {
return;
};
let profile_keys: Vec<String> = profiles.iter().map(|(key, _)| key.to_string()).collect();
for profile_key in profile_keys {
let Some(profile_table) = profiles
.get_mut(&profile_key)
.and_then(|item| item.as_table_like_mut())
else {
continue;
};
let references_source = profile_table
.get("model_provider")
.and_then(|item| item.as_str())
== Some(source_provider_id);
if references_source {
profile_table.insert("model_provider", toml_edit::value(stable_provider_id));
}
}
}
/// Keep Codex's active `model_provider` stable across CC Switch provider changes.
///
/// Codex stores and filters resume history by `model_provider`, so switching between
/// provider-specific ids like `rightcode` and `aihubmix` makes history appear to move.
/// We preserve an existing custom provider id when possible and only rewrite the
/// live config text that Codex sees at provider-driven write boundaries.
pub fn normalize_codex_settings_config_model_provider(
settings: &mut Value,
anchor_config_text: Option<&str>,
) -> Result<(), AppError> {
let Some(config_text) = settings
.get("config")
.and_then(|value| value.as_str())
.map(str::to_string)
else {
return Ok(());
};
let current_config_text = read_codex_config_text().ok();
let anchors = anchor_config_text
.into_iter()
.chain(current_config_text.as_deref());
let normalized =
normalize_codex_live_config_model_provider_with_anchors(&config_text, anchors)?;
if let Some(obj) = settings.as_object_mut() {
obj.insert("config".to_string(), Value::String(normalized));
}
Ok(())
}
fn restore_codex_backfill_model_provider_id(
config_text: &str,
template_config_text: &str,
) -> Result<String, AppError> {
let Some(template_provider_id) =
codex_model_provider_id_with_table_from_config(template_config_text)?
else {
return Ok(config_text.to_string());
};
if config_text.trim().is_empty() {
return Ok(config_text.to_string());
}
let mut doc = config_text
.parse::<DocumentMut>()
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
let Some(live_provider_id) = active_codex_model_provider_id(&doc) else {
return Ok(config_text.to_string());
};
if live_provider_id == template_provider_id {
return Ok(config_text.to_string());
}
if let Some(model_providers) = doc
.get_mut("model_providers")
.and_then(|item| item.as_table_mut())
{
let Some(provider_table) = model_providers.remove(live_provider_id.as_str()) else {
return Ok(config_text.to_string());
};
model_providers[template_provider_id.as_str()] = provider_table;
} else {
return Ok(config_text.to_string());
}
rewrite_codex_profile_model_provider_refs(&mut doc, &live_provider_id, &template_provider_id);
doc["model_provider"] = toml_edit::value(template_provider_id.as_str());
Ok(doc.to_string())
}
/// Convert a Codex live config that was normalized for history stability back
/// to the provider-specific id used by the stored provider template.
pub fn restore_codex_settings_config_model_provider_for_backfill(
settings: &mut Value,
template_settings: &Value,
) -> Result<(), AppError> {
let Some(config_text) = settings
.get("config")
.and_then(|value| value.as_str())
.map(str::to_string)
else {
return Ok(());
};
let Some(template_config_text) = template_settings
.get("config")
.and_then(|value| value.as_str())
else {
return Ok(());
};
let restored = restore_codex_backfill_model_provider_id(&config_text, template_config_text)?;
if let Some(obj) = settings.as_object_mut() {
obj.insert("config".to_string(), Value::String(restored));
}
Ok(())
}
/// Atomically write Codex live config after normalizing provider-specific ids.
///
/// Use this for provider-driven live writes. Keep `write_codex_live_atomic` available
/// for exact restore/backup paths that must preserve the config text byte-for-byte.
pub fn write_codex_live_atomic_with_stable_provider(
auth: &Value,
config_text_opt: Option<&str>,
) -> Result<(), AppError> {
match config_text_opt {
Some(config_text) => {
let mut settings = serde_json::Map::new();
settings.insert("config".to_string(), Value::String(config_text.to_string()));
let mut settings = Value::Object(settings);
normalize_codex_settings_config_model_provider(&mut settings, None)?;
let config_text = settings
.get("config")
.and_then(|value| value.as_str())
.unwrap_or(config_text);
write_codex_live_atomic(auth, Some(config_text))
}
None => write_codex_live_atomic(auth, None),
}
}
/// Update a field in Codex config.toml using toml_edit (syntax-preserving).
///
/// Supported fields:
@@ -254,6 +530,241 @@ pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) ->
mod tests {
use super::*;
#[test]
fn normalize_live_config_preserves_current_custom_model_provider_id() {
let current = r#"model_provider = "rightcode"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
"#;
let target = r#"model_provider = "aihubmix"
model = "gpt-5.4"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
[mcp_servers.context7]
command = "npx"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("rightcode")
);
let model_providers = parsed
.get("model_providers")
.and_then(|v| v.as_table())
.expect("model_providers should exist");
assert!(
model_providers.get("aihubmix").is_none(),
"source provider id should not remain in live config"
);
let stable_provider = model_providers
.get("rightcode")
.expect("stable provider table should exist");
assert_eq!(
stable_provider.get("base_url").and_then(|v| v.as_str()),
Some("https://aihubmix.example/v1")
);
assert!(
parsed.get("mcp_servers").is_some(),
"unrelated config should be preserved"
);
}
#[test]
fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() {
let current = r#"model_provider = "openai""#;
let target = r#"model_provider = "aihubmix"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("aihubmix")
);
assert!(
parsed
.get("model_providers")
.and_then(|v| v.get("aihubmix"))
.is_some(),
"target provider id should be kept when there is no reusable live custom id"
);
}
#[test]
fn normalize_live_config_leaves_official_empty_config_unchanged() {
let current = r#"model_provider = "rightcode"
[model_providers.rightcode]
base_url = "https://rightcode.example/v1"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors("", Some(current)).unwrap();
assert_eq!(result, "");
}
#[test]
fn normalize_live_config_rewrites_matching_profile_model_provider_refs() {
let current = r#"model_provider = "session_anchor"
[model_providers.session_anchor]
name = "Session Anchor"
base_url = "https://anchor.example/v1"
wire_api = "responses"
"#;
let target = r#"model_provider = "vendor_alpha"
model = "gpt-5.4"
profile = "work"
[model_providers.vendor_alpha]
name = "Vendor Alpha"
base_url = "https://alpha.example/v1"
wire_api = "responses"
[profiles.work]
model_provider = "vendor_alpha"
model = "gpt-5.4"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("session_anchor")
);
assert_eq!(
parsed
.get("profiles")
.and_then(|v| v.get("work"))
.and_then(|v| v.get("model_provider"))
.and_then(|v| v.as_str()),
Some("session_anchor"),
"profile override matching the rewritten provider should stay valid"
);
}
#[test]
fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() {
let current = r#"model_provider = "session_anchor"
[model_providers.session_anchor]
name = "Session Anchor"
base_url = "https://anchor.example/v1"
wire_api = "responses"
"#;
let target = r#"model_provider = "vendor_alpha"
model = "gpt-5.4"
[model_providers.vendor_alpha]
name = "Vendor Alpha"
base_url = "https://alpha.example/v1"
wire_api = "responses"
[model_providers.local_profile]
name = "Local Profile"
base_url = "http://localhost:11434/v1"
wire_api = "responses"
[profiles.local]
model_provider = "local_profile"
model = "local-model"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed
.get("profiles")
.and_then(|v| v.get("local"))
.and_then(|v| v.get("model_provider"))
.and_then(|v| v.as_str()),
Some("local_profile"),
"unrelated profile provider references should be preserved"
);
assert!(
parsed
.get("model_providers")
.and_then(|v| v.get("local_profile"))
.is_some(),
"unrelated provider tables should also remain available"
);
}
#[test]
fn normalize_live_config_keeps_stable_provider_across_repeated_switches() {
let anchor = r#"model_provider = "session_anchor"
[model_providers.session_anchor]
name = "Session Anchor"
base_url = "https://anchor.example/v1"
wire_api = "responses"
"#;
let first_target = r#"model_provider = "vendor_alpha"
[model_providers.vendor_alpha]
name = "Vendor Alpha"
base_url = "https://alpha.example/v1"
wire_api = "responses"
"#;
let second_target = r#"model_provider = "vendor_beta"
[model_providers.vendor_beta]
name = "Vendor Beta"
base_url = "https://beta.example/v1"
wire_api = "responses"
"#;
let first =
normalize_codex_live_config_model_provider_with_anchors(first_target, Some(anchor))
.unwrap();
let second = normalize_codex_live_config_model_provider_with_anchors(
second_target,
Some(first.as_str()),
)
.unwrap();
let parsed: toml::Value = toml::from_str(&second).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("session_anchor"),
"stable provider id should not drift across repeated switches"
);
assert_eq!(
parsed
.get("model_providers")
.and_then(|v| v.get("session_anchor"))
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str()),
Some("https://beta.example/v1")
);
}
#[test]
fn base_url_writes_into_correct_model_provider_section() {
let input = r#"model_provider = "any"
+34 -2
View File
@@ -1,6 +1,6 @@
#![allow(non_snake_case)]
use tauri::AppHandle;
use tauri::{AppHandle, State};
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
@@ -8,6 +8,7 @@ use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::settings;
use crate::store::AppState;
#[tauri::command]
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
@@ -62,9 +63,23 @@ fn validate_common_config_snippet(app_type: &str, snippet: &str) -> Result<(), S
}
#[tauri::command]
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
pub async fn get_config_status(
state: State<'_, AppState>,
app: String,
) -> Result<ConfigStatus, String> {
match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => Ok(config::get_claude_config_status()),
AppType::ClaudeDesktop => {
let status = crate::claude_desktop_config::get_status(
state.db.as_ref(),
state.proxy_service.is_running().await,
)
.map_err(|e| e.to_string())?;
Ok(ConfigStatus {
exists: status.configured,
path: status.config_library_path.unwrap_or_default(),
})
}
AppType::Codex => {
let auth_path = codex_config::get_codex_auth_path();
let exists = auth_path.exists();
@@ -101,6 +116,15 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
Ok(ConfigStatus { exists, path })
}
AppType::Hermes => {
let config_path = crate::hermes_config::get_hermes_config_path();
let exists = config_path.exists();
let path = crate::hermes_config::get_hermes_dir()
.to_string_lossy()
.to_string();
Ok(ConfigStatus { exists, path })
}
}
}
@@ -113,10 +137,14 @@ pub async fn get_claude_code_config_path() -> Result<String, String> {
pub async fn get_config_dir(app: String) -> Result<String, String> {
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => config::get_claude_config_dir(),
AppType::ClaudeDesktop => {
crate::claude_desktop_config::get_config_library_path().map_err(|e| e.to_string())?
}
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
};
Ok(dir.to_string_lossy().to_string())
@@ -126,10 +154,14 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => config::get_claude_config_dir(),
AppType::ClaudeDesktop => {
crate::claude_desktop_config::get_config_library_path().map_err(|e| e.to_string())?
}
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
};
if !config_dir.exists() {
+1 -1
View File
@@ -162,7 +162,7 @@ pub async fn set_auto_failover_enabled(
// 刷新托盘菜单,确保状态同步
if let Ok(new_menu) = crate::tray::create_tray_menu(&app, &state) {
if let Some(tray) = app.tray_by_id("main") {
if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) {
let _ = tray.set_menu(Some(new_menu));
}
}
+143
View File
@@ -0,0 +1,143 @@
use std::time::Duration;
use tauri::{AppHandle, State};
use tauri_plugin_opener::OpenerExt;
use crate::hermes_config;
use crate::store::AppState;
/// Error string returned when `open_hermes_web_ui` cannot reach the Hermes
/// FastAPI server. Kept in sync with the `HERMES_WEB_OFFLINE_ERROR` constant
/// in `src/hooks/useHermes.ts` so the frontend can branch on it.
const HERMES_WEB_OFFLINE_ERROR: &str = "hermes_web_offline";
// ============================================================================
// Hermes Provider Commands
// ============================================================================
/// Import providers from Hermes live config to database.
///
/// Hermes uses additive mode — users may already have providers
/// configured in config.yaml.
#[tauri::command]
pub fn import_hermes_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_hermes_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
/// Get provider names in the Hermes live config.
#[tauri::command]
pub fn get_hermes_live_provider_ids() -> Result<Vec<String>, String> {
hermes_config::get_providers()
.map(|providers| providers.keys().cloned().collect())
.map_err(|e| e.to_string())
}
/// Get a single Hermes provider fragment from live config.
#[tauri::command]
pub fn get_hermes_live_provider(
#[allow(non_snake_case)] providerId: String,
) -> Result<Option<serde_json::Value>, String> {
hermes_config::get_provider(&providerId).map_err(|e| e.to_string())
}
// ============================================================================
// Model Configuration Commands
// ============================================================================
/// Get Hermes model config (model section of config.yaml). Read-only — writes
/// happen implicitly through `apply_switch_defaults` when switching providers.
#[tauri::command]
pub fn get_hermes_model_config() -> Result<Option<hermes_config::HermesModelConfig>, String> {
hermes_config::get_model_config().map_err(|e| e.to_string())
}
// ============================================================================
// Memory Files Commands
// ============================================================================
#[tauri::command]
pub fn get_hermes_memory(kind: hermes_config::MemoryKind) -> Result<String, String> {
hermes_config::read_memory(kind).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_hermes_memory(kind: hermes_config::MemoryKind, content: String) -> Result<(), String> {
hermes_config::write_memory(kind, &content).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_hermes_memory_limits() -> Result<hermes_config::HermesMemoryLimits, String> {
hermes_config::read_memory_limits().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_hermes_memory_enabled(
kind: hermes_config::MemoryKind,
enabled: bool,
) -> Result<hermes_config::HermesWriteOutcome, String> {
hermes_config::set_memory_enabled(kind, enabled).map_err(|e| e.to_string())
}
// ============================================================================
// Hermes Web UI launcher
// ============================================================================
/// Probe the local Hermes Web UI (FastAPI) and open it in the system browser.
///
/// Port discovery priority:
/// 1. `HERMES_WEB_PORT` environment variable
/// 2. Default 9119
///
/// Hermes wraps all `/api/*` routes in a Bearer-token middleware, so a GET
/// against `/api/status` returning **either 200 or 401** confirms the server
/// is live. The session token lives only in the Hermes process memory and is
/// injected into the returned HTML via `window.__HERMES_SESSION_TOKEN__`, so
/// there is no need (and no way) for CC Switch to inject it — we just open
/// the URL and let Hermes handle auth.
#[tauri::command]
pub async fn open_hermes_web_ui(app: AppHandle, path: Option<String>) -> Result<(), String> {
let port = std::env::var("HERMES_WEB_PORT")
.ok()
.and_then(|raw| raw.trim().parse::<u16>().ok())
.unwrap_or(9119);
let base = format!("http://127.0.0.1:{port}");
// Probe /api/status with a short timeout. Hermes returns 200 when open or
// 401 when the session token is required — either way the server is live.
// Only a connection error / timeout means the server isn't running.
let probe_url = format!("{base}/api/status");
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(1200))
.no_proxy()
.build()
.map_err(|e| format!("failed to build probe client: {e}"))?;
match client.get(&probe_url).send().await {
Ok(_) => {}
Err(_) => return Err(HERMES_WEB_OFFLINE_ERROR.to_string()),
}
let target = match path.as_deref() {
Some(p) if p.starts_with('/') => format!("{base}{p}"),
Some(p) if !p.is_empty() => format!("{base}/{p}"),
_ => format!("{base}/"),
};
app.opener()
.open_url(&target, None::<String>)
.map_err(|e| format!("failed to open Hermes Web UI: {e}"))
}
/// Open the preferred terminal and run `hermes dashboard`. Non-blocking —
/// callers should reinvoke `open_hermes_web_ui` once the server is ready,
/// since Hermes startup can take several seconds and may fail outright if
/// the `hermes-agent[web]` extras are missing.
#[tauri::command]
pub async fn launch_hermes_dashboard() -> Result<(), String> {
tokio::task::spawn_blocking(|| {
crate::commands::misc::launch_terminal_running("hermes dashboard", "hermes_dashboard")
})
.await
.map_err(|e| format!("launch task join error: {e}"))?
}
+1
View File
@@ -202,5 +202,6 @@ pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, S
total += McpService::import_from_codex(&state).unwrap_or(0);
total += McpService::import_from_gemini(&state).unwrap_or(0);
total += McpService::import_from_opencode(&state).unwrap_or(0);
total += McpService::import_from_hermes(&state).unwrap_or(0);
Ok(total)
}
+320 -20
View File
@@ -300,8 +300,13 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
#[cfg(not(target_os = "windows"))]
let output = {
Command::new("sh")
.arg("-c")
let shell = std::env::var("SHELL")
.ok()
.filter(|s| is_valid_shell(s))
.unwrap_or_else(|| "sh".to_string());
let flag = default_flag_for_shell(&shell);
Command::new(shell)
.arg(flag)
.arg(format!("{tool} --version"))
.output()
};
@@ -345,7 +350,6 @@ fn is_valid_wsl_distro_name(name: &str) -> bool {
}
/// Validate that the given shell name is one of the allowed shells.
#[cfg(target_os = "windows")]
fn is_valid_shell(shell: &str) -> bool {
matches!(
shell.rsplit('/').next().unwrap_or(shell),
@@ -360,7 +364,6 @@ fn is_valid_shell_flag(flag: &str) -> bool {
}
/// Return the default invocation flag for the given shell.
#[cfg(target_os = "windows")]
fn default_flag_for_shell(shell: &str) -> &'static str {
match shell.rsplit('/').next().unwrap_or(shell) {
"dash" | "sh" => "-c",
@@ -781,7 +784,7 @@ fn extract_env_vars_from_config(
// 处理 base_url: 根据应用类型添加对应的环境变量
let base_url_key = match app_type {
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
AppType::Claude | AppType::ClaudeDesktop => Some("ANTHROPIC_BASE_URL"),
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
_ => None,
};
@@ -944,6 +947,7 @@ exec bash --norc --noprofile
// Note: Kitty doesn't need the -e flag, others do
let result = match terminal {
"iterm2" => launch_macos_iterm2(&script_file),
"warp" => launch_macos_warp(&script_file),
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
"kitty" => launch_macos_open_app("kitty", &script_file, false),
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
@@ -998,21 +1002,46 @@ end tell"#,
/// macOS: iTerm2
#[cfg(target_os = "macos")]
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
let applescript = format!(
r#"tell application "iTerm"
activate
tell current window
create tab with default profile
tell current session
write text "bash '{}'"
end tell
fn build_macos_iterm2_applescript(script_file: &std::path::Path) -> String {
format!(
r#"set launcher_script to "bash '{}'"
set was_running to application "iTerm" is running
tell application "iTerm"
if was_running then
activate
if (count of windows) = 0 then
create window with default profile
else
tell current window
create tab with default profile
end tell
end if
else
activate
set waited to 0
repeat while (count of windows) = 0
delay 0.1
set waited to waited + 1
if waited >= 30 then exit repeat
end repeat
if (count of windows) = 0 then
create window with default profile
end if
end if
tell current session of current window
write text launcher_script
end tell
end tell"#,
script_file.display()
);
)
}
/// macOS: iTerm2
#[cfg(target_os = "macos")]
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
let applescript = build_macos_iterm2_applescript(script_file);
let output = Command::new("osascript")
.arg("-e")
@@ -1066,6 +1095,57 @@ fn launch_macos_open_app(
Ok(())
}
#[cfg(target_os = "macos")]
fn launch_macos_warp(script_file: &std::path::Path) -> Result<(), String> {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let mut cmd = Command::new("open");
cmd.arg("-a").arg("Warp");
// Warp URI scheme cannot work well with script_file, because:
//
// 1. script_file's name ends up with .sh, so Warp would open the file rather than execute it
// 2. script_file has no execution permission, so we need to add one more indirection
let mut second_script_file = tempfile::Builder::new()
.disable_cleanup(true)
.permissions(std::fs::Permissions::from_mode(0o755))
.tempfile()
.map_err(|e| format!("Failed to create temporary script file: {e}"))?;
writeln!(
&mut second_script_file,
r#"#!/usr/bin/env sh
rm -- "$0"
exec bash {}
"#,
script_file.display(),
)
.map_err(|e| format!("Failed to write to temporary script file for Warp: {e}"))?;
let mut warp_url = url::Url::parse("warp://action/new_tab").unwrap();
warp_url
.query_pairs_mut()
.append_pair("path", &second_script_file.path().to_string_lossy());
let warp_url = warp_url.to_string();
cmd.arg(warp_url);
let output = cmd.output().map_err(|e| format!("启动 Warp 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"Warp 启动失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
}
Ok(())
}
/// Linux: 根据用户首选终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
@@ -1310,6 +1390,189 @@ fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), S
Ok(())
}
/// 打开用户首选终端并在其中执行一条命令行。脚本尾部 `read -n 1` / `pause`
/// 是刻意设计的——让命令退出后窗口不要瞬间关闭,用户才看得到 `command
/// not found` / `ModuleNotFoundError` 这类诊断信息。
///
/// **Security**`command_line` 会被原样拼进 shell/batch 脚本,调用方必须
/// 保证它是可信字符串(当前只由后端硬编码调用)。
pub(crate) fn launch_terminal_running(command_line: &str, label: &str) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let pid = std::process::id();
#[cfg(any(target_os = "macos", target_os = "linux"))]
let (script_file, script_content) = {
let file = temp_dir.join(format!("cc_switch_{}_{}.sh", label, pid));
let content = format!(
r#"#!/bin/bash
trap 'rm -f "{script_path}"' EXIT
echo "[cc-switch] Starting: {cmd}"
echo ""
{cmd}
echo ""
echo "[cc-switch] Command exited. Press any key to close."
read -n 1 -s
"#,
script_path = file.display(),
cmd = command_line,
);
(file, content)
};
#[cfg(target_os = "macos")]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&script_file, &script_content)
.map_err(|e| format!("写入启动脚本失败: {e}"))?;
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("terminal");
let result = match terminal {
"iterm2" => launch_macos_iterm2(&script_file),
"warp" => launch_macos_warp(&script_file),
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
"kitty" => launch_macos_open_app("kitty", &script_file, false),
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
"kaku" => launch_macos_open_app("Kaku", &script_file, true),
_ => launch_macos_terminal_app(&script_file),
};
if result.is_err() && terminal != "terminal" {
log::warn!(
"首选终端 {} 启动失败,回退到 Terminal.app: {:?}",
terminal,
result.as_ref().err()
);
return launch_macos_terminal_app(&script_file);
}
result
}
#[cfg(target_os = "linux")]
{
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
std::fs::write(&script_file, &script_content)
.map_err(|e| format!("写入启动脚本失败: {e}"))?;
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
let preferred = crate::settings::get_preferred_terminal();
let default_terminals = [
("gnome-terminal", vec!["--"]),
("konsole", vec!["-e"]),
("xfce4-terminal", vec!["-e"]),
("mate-terminal", vec!["--"]),
("lxterminal", vec!["-e"]),
("alacritty", vec!["-e"]),
("kitty", vec!["-e"]),
("ghostty", vec!["-e"]),
];
let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred {
let pref_args = default_terminals
.iter()
.find(|(name, _)| *name == pref.as_str())
.map(|(_, args)| args.to_vec())
.unwrap_or_else(|| vec!["-e"]);
let mut list = vec![(pref.as_str(), pref_args)];
for (name, args) in &default_terminals {
if *name != pref.as_str() {
list.push((*name, args.to_vec()));
}
}
list
} else {
default_terminals
.iter()
.map(|(name, args)| (*name, args.to_vec()))
.collect()
};
let mut last_error = String::from("未找到可用的终端");
for (terminal, args) in terminals_to_try {
let terminal_exists = which_command(terminal)
|| ["/usr/bin", "/bin", "/usr/local/bin"]
.iter()
.any(|dir| std::path::Path::new(&format!("{}/{}", dir, terminal)).exists());
if terminal_exists {
let spawn_result = Command::new(terminal)
.args(&args)
.arg("bash")
.arg(script_file.to_string_lossy().as_ref())
.spawn();
match spawn_result {
Ok(_) => return Ok(()),
Err(e) => {
last_error = format!("执行 {} 失败: {}", terminal, e);
}
}
}
}
let _ = std::fs::remove_file(&script_file);
Err(last_error)
}
#[cfg(target_os = "windows")]
{
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("cmd");
let bat_file = temp_dir.join(format!("cc_switch_{}_{}.bat", label, pid));
let content = format!(
"@echo off\r\necho [cc-switch] Starting: {cmd}\r\necho.\r\n{cmd}\r\necho.\r\necho [cc-switch] Command exited. Press any key to close.\r\npause >nul\r\ndel \"%~f0\" >nul 2>&1\r\n",
cmd = command_line,
);
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
let bat_path = bat_file.to_string_lossy();
let ps_cmd = format!("& '{}'", bat_path);
let result = match terminal {
"powershell" => run_windows_start_command(
&["powershell", "-NoExit", "-Command", &ps_cmd],
"PowerShell",
),
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"),
};
let final_result = if result.is_err() && terminal != "cmd" {
log::warn!(
"首选终端 {} 启动失败,回退到 cmd: {:?}",
terminal,
result.as_ref().err()
);
run_windows_start_command(&["cmd", "/K", &bat_path], "cmd")
} else {
result
};
// The .bat self-deletes (`del "%~f0"`) after it runs, but that only
// fires if *some* terminal actually launched it. If every attempt
// failed, sweep the temp file ourselves to avoid pollution.
if final_result.is_err() {
let _ = std::fs::remove_file(&bat_file);
}
final_result
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
let _ = (temp_dir, pid, command_line, label);
Err("不支持的操作系统".to_string())
}
}
/// 设置窗口主题(Windows/macOS 标题栏颜色)
/// theme: "dark" | "light" | "system"
#[tauri::command]
@@ -1328,7 +1591,7 @@ pub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<()
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
#[test]
fn test_extract_version() {
@@ -1416,7 +1679,7 @@ mod tests {
let count = paths
.iter()
.filter(|path| **path == PathBuf::from("/same/path"))
.filter(|path| path.as_path() == Path::new("/same/path"))
.count();
assert_eq!(count, 1);
}
@@ -1428,7 +1691,7 @@ mod tests {
let count = paths
.iter()
.filter(|path| **path == PathBuf::from("/home/tester/.bun/bin"))
.filter(|path| path.as_path() == Path::new("/home/tester/.bun/bin"))
.count();
assert_eq!(count, 1);
}
@@ -1489,6 +1752,43 @@ mod tests {
assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n");
}
#[cfg(target_os = "macos")]
#[test]
fn iterm2_applescript_cold_start_avoids_current_window_before_one_exists() {
let script = build_macos_iterm2_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
let cold_start_branch = script
.split("else\n activate")
.nth(1)
.expect("cold start branch should be present")
.split(" end if\n tell current session")
.next()
.expect("cold start branch should end before writing command");
assert!(cold_start_branch.contains("repeat while (count of windows) = 0"));
assert!(cold_start_branch.contains("create window with default profile"));
assert!(!cold_start_branch.contains("tell current window"));
assert!(!cold_start_branch.contains("create tab with default profile"));
}
#[cfg(target_os = "macos")]
#[test]
fn iterm2_applescript_keeps_new_tab_behavior_for_existing_windows() {
let script = build_macos_iterm2_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
let running_branch = script
.split("if was_running then")
.nth(1)
.expect("already-running branch should be present")
.split("else\n activate")
.next()
.expect("already-running branch should end before cold start branch");
assert!(running_branch.contains("if (count of windows) = 0 then"));
assert!(running_branch.contains("create window with default profile"));
assert!(running_branch.contains("create tab with default profile"));
}
#[test]
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
let command = build_windows_cwd_command_str(r"C:\work\repo");
+2
View File
@@ -10,6 +10,7 @@ mod deeplink;
mod env;
mod failover;
mod global_proxy;
mod hermes;
mod import_export;
mod mcp;
mod misc;
@@ -42,6 +43,7 @@ pub use deeplink::*;
pub use env::*;
pub use failover::*;
pub use global_proxy::*;
pub use hermes::*;
pub use import_export::*;
pub use mcp::*;
pub use misc::*;
+10 -3
View File
@@ -6,13 +6,20 @@ use crate::services::model_fetch::{self, FetchedModel};
/// 获取供应商的可用模型列表
///
/// 使用 OpenAI 兼容的 GET /v1/models 端点。
/// 主要面向第三方聚合站(硅基流动、OpenRouter 等)
/// 使用 OpenAI 兼容的 GET /v1/models 端点。优先使用 `models_url` 精确覆写;
/// 否则对 baseURL 生成候选列表(含「剥离 Anthropic 兼容子路径」兜底),按序尝试
#[tauri::command(rename_all = "camelCase")]
pub async fn fetch_models_for_config(
base_url: String,
api_key: String,
is_full_url: Option<bool>,
models_url: Option<String>,
) -> Result<Vec<FetchedModel>, String> {
model_fetch::fetch_models(&base_url, &api_key, is_full_url.unwrap_or(false)).await
model_fetch::fetch_models(
&base_url,
&api_key,
is_full_url.unwrap_or(false),
models_url.as_deref(),
)
.await
}
+189 -5
View File
@@ -1,10 +1,10 @@
use indexmap::IndexMap;
use tauri::State;
use tauri::{Emitter, State};
use crate::app_config::AppType;
use crate::commands::copilot::CopilotAuthState;
use crate::error::AppError;
use crate::provider::Provider;
use crate::provider::{ClaudeDesktopMode, Provider};
use crate::services::{
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
};
@@ -150,22 +150,206 @@ pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<
import_default_config_internal(&state, app_type).map_err(Into::into)
}
#[tauri::command]
pub async fn get_claude_desktop_status(
state: State<'_, AppState>,
) -> Result<crate::claude_desktop_config::ClaudeDesktopStatus, String> {
let proxy_running = state.proxy_service.is_running().await;
crate::claude_desktop_config::get_status(state.db.as_ref(), proxy_running)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_claude_desktop_default_routes(
) -> Vec<crate::claude_desktop_config::ClaudeDesktopDefaultRoute> {
crate::claude_desktop_config::default_proxy_routes()
}
#[tauri::command]
pub fn import_claude_desktop_providers_from_claude(
state: State<'_, AppState>,
) -> Result<usize, String> {
let claude_providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.map_err(|e| e.to_string())?;
let existing_ids = state
.db
.get_provider_ids(AppType::ClaudeDesktop.as_str())
.map_err(|e| e.to_string())?;
let mut imported = 0usize;
for provider in claude_providers.values() {
if existing_ids.contains(&provider.id) {
continue;
}
if matches!(
provider
.meta
.as_ref()
.and_then(|meta| meta.provider_type.as_deref()),
Some("github_copilot") | Some("codex_oauth")
) {
continue;
}
let mut desktop_provider = provider.clone();
desktop_provider.in_failover_queue = false;
let meta = desktop_provider.meta.get_or_insert_with(Default::default);
if crate::claude_desktop_config::is_compatible_direct_provider(provider)
&& claude_provider_models_are_claude_safe(provider)
{
meta.claude_desktop_mode = Some(ClaudeDesktopMode::Direct);
} else if let Some(routes) = suggested_claude_desktop_routes(provider) {
meta.claude_desktop_mode = Some(ClaudeDesktopMode::Proxy);
meta.claude_desktop_model_routes = routes;
} else {
continue;
}
state
.db
.save_provider(AppType::ClaudeDesktop.as_str(), &desktop_provider)
.map_err(|e| e.to_string())?;
imported += 1;
}
Ok(imported)
}
fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool {
let Some(env) = provider
.settings_config
.get("env")
.and_then(|value| value.as_object())
else {
return true;
};
[
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
]
.into_iter()
.filter_map(|key| env.get(key).and_then(|value| value.as_str()))
.map(str::trim)
.filter(|value| !value.is_empty())
.all(crate::claude_desktop_config::is_claude_safe_model_id)
}
fn suggested_claude_desktop_routes(
provider: &Provider,
) -> Option<std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>> {
let env = provider
.settings_config
.get("env")
.and_then(|value| value.as_object())?;
let mut routes = std::collections::HashMap::new();
fn add_route(
routes: &mut std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>,
env: &serde_json::Map<String, serde_json::Value>,
route_id: &str,
env_key: &str,
display_name: &str,
) {
if let Some(model) = env
.get(env_key)
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
{
routes.insert(
route_id.to_string(),
crate::provider::ClaudeDesktopModelRoute {
model: model.to_string(),
display_name: Some(display_name.to_string()),
supports_1m: Some(true),
},
);
}
}
for spec in crate::claude_desktop_config::DEFAULT_PROXY_ROUTES {
add_route(
&mut routes,
env,
spec.route_id,
spec.env_key,
spec.display_name,
);
}
let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0];
if !routes.contains_key(primary_route.route_id) {
add_route(
&mut routes,
env,
primary_route.route_id,
"ANTHROPIC_MODEL",
primary_route.display_name,
);
}
(!routes.is_empty()).then_some(routes)
}
#[allow(non_snake_case)]
#[tauri::command]
pub async fn queryProviderUsage(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
copilot_state: State<'_, CopilotAuthState>,
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
app: String,
) -> Result<crate::provider::UsageResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
// inner 可能以两种形式失败:
// 1) 返回 Ok(UsageResult { success: false, .. }) —— 业务失败(401、脚本报错等)
// 2) 返回 Err(String) —— RPC/DB/Copilot fetch_usage 等 transport 层失败
// 两种都要把"失败"写进 UsageCache 并刷新托盘,让 format_script_summary 的
// success 守卫生效、suffix 自然消失,避免旧 success 快照长期滞留。
// 同时保持原始 Err 返回给前端 React Query 的 onError 回调,不吞错误。
let inner =
query_provider_usage_inner(&state, &copilot_state, app_type.clone(), &providerId).await;
let snapshot = match &inner {
Ok(r) => r.clone(),
Err(err_msg) => crate::provider::UsageResult {
success: false,
data: None,
error: Some(err_msg.clone()),
},
};
let payload = serde_json::json!({
"kind": "script",
"appType": app_type.as_str(),
"providerId": &providerId,
"data": &snapshot,
});
if let Err(e) = app_handle.emit("usage-cache-updated", payload) {
log::error!("emit usage-cache-updated (script) 失败: {e}");
}
state.usage_cache.put_script(app_type, providerId, snapshot);
crate::tray::schedule_tray_refresh(&app_handle);
inner
}
async fn query_provider_usage_inner(
state: &AppState,
copilot_state: &CopilotAuthState,
app_type: AppType,
provider_id: &str,
) -> Result<crate::provider::UsageResult, String> {
// 从数据库读取供应商信息,检查特殊模板类型
let providers = state
.db
.get_all_providers(app_type.as_str())
.map_err(|e| format!("Failed to get providers: {e}"))?;
let provider = providers.get(&providerId);
let provider = providers.get(provider_id);
let usage_script = provider
.and_then(|p| p.meta.as_ref())
.and_then(|m| m.usage_script.as_ref());
@@ -294,7 +478,7 @@ pub async fn queryProviderUsage(
}
// ── 通用 JS 脚本路径 ──
ProviderService::query_usage(state.inner(), app_type, &providerId)
ProviderService::query_usage(state, app_type, provider_id)
.await
.map_err(|e| e.to_string())
}
@@ -406,7 +590,7 @@ pub fn update_providers_sort_order(
use crate::provider::UniversalProvider;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
use tauri::AppHandle;
#[derive(Clone, serde::Serialize)]
pub struct UniversalProviderSyncedEvent {
+18
View File
@@ -15,6 +15,24 @@ pub async fn start_proxy_server(
state.proxy_service.start().await
}
/// 停止代理服务器(仅停止服务,不恢复/清理 Live 接管状态)
#[tauri::command]
pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), String> {
let takeover = state.proxy_service.get_takeover_status().await?;
if takeover.claude
|| takeover.codex
|| takeover.gemini
|| takeover.opencode
|| takeover.openclaw
{
return Err(
"仍有应用处于代理接管状态,请先在设置中关闭对应应用接管后再停止本地路由。".to_string(),
);
}
state.proxy_service.stop().await
}
/// 停止代理服务器(恢复 Live 配置)
#[tauri::command]
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
+65 -49
View File
@@ -42,6 +42,8 @@ pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<boo
/// 重启应用程序(当 app_config_dir 变更后使用)
#[tauri::command]
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
crate::save_window_state_before_exit(&app);
// 在后台延迟重启,让函数有时间返回响应
tauri::async_runtime::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
@@ -85,13 +87,15 @@ mod tests {
#[test]
fn save_settings_should_preserve_existing_webdav_when_payload_omits_it() {
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "secret".to_string(),
..WebDavSyncSettings::default()
});
let existing = AppSettings {
webdav_sync: Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "secret".to_string(),
..WebDavSyncSettings::default()
}),
..AppSettings::default()
};
let incoming = AppSettings::default();
let merged = merge_settings_for_save(incoming, &existing);
@@ -105,21 +109,25 @@ mod tests {
#[test]
fn save_settings_should_keep_incoming_webdav_when_present() {
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.old.example.com".to_string(),
username: "old".to_string(),
password: "old-pass".to_string(),
..WebDavSyncSettings::default()
});
let existing = AppSettings {
webdav_sync: Some(WebDavSyncSettings {
base_url: "https://dav.old.example.com".to_string(),
username: "old".to_string(),
password: "old-pass".to_string(),
..WebDavSyncSettings::default()
}),
..AppSettings::default()
};
let mut incoming = AppSettings::default();
incoming.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.new.example.com".to_string(),
username: "new".to_string(),
password: "new-pass".to_string(),
..WebDavSyncSettings::default()
});
let incoming = AppSettings {
webdav_sync: Some(WebDavSyncSettings {
base_url: "https://dav.new.example.com".to_string(),
username: "new".to_string(),
password: "new-pass".to_string(),
..WebDavSyncSettings::default()
}),
..AppSettings::default()
};
let merged = merge_settings_for_save(incoming, &existing);
@@ -135,22 +143,26 @@ mod tests {
/// must NOT overwrite the existing one.
#[test]
fn save_settings_should_preserve_password_when_incoming_has_empty_password() {
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "secret".to_string(),
..WebDavSyncSettings::default()
});
let existing = AppSettings {
webdav_sync: Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "secret".to_string(),
..WebDavSyncSettings::default()
}),
..AppSettings::default()
};
// Simulate frontend sending settings with cleared password
let mut incoming = AppSettings::default();
incoming.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
});
let incoming = AppSettings {
webdav_sync: Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
}),
..AppSettings::default()
};
let merged = merge_settings_for_save(incoming, &existing);
@@ -165,21 +177,25 @@ mod tests {
/// work without panicking and keep the empty state.
#[test]
fn save_settings_should_handle_both_empty_passwords() {
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
});
let existing = AppSettings {
webdav_sync: Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
}),
..AppSettings::default()
};
let mut incoming = AppSettings::default();
incoming.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
});
let incoming = AppSettings {
webdav_sync: Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
}),
..AppSettings::default()
};
let merged = merge_settings_for_save(incoming, &existing);
+2 -7
View File
@@ -12,6 +12,7 @@ use crate::services::skill::{
SkillsShSearchResult,
};
use crate::store::AppState;
use std::str::FromStr;
use std::sync::Arc;
use tauri::State;
@@ -20,13 +21,7 @@ pub struct SkillServiceState(pub Arc<SkillService>);
/// 解析 app 参数为 AppType
fn parse_app_type(app: &str) -> Result<AppType, String> {
match app.to_lowercase().as_str() {
"claude" => Ok(AppType::Claude),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
_ => Err(format!("不支持的 app 类型: {app}")),
}
AppType::from_str(app).map_err(|e| e.to_string())
}
// ========== 统一管理命令 ==========
+35 -4
View File
@@ -1,10 +1,41 @@
use crate::services::subscription::SubscriptionQuota;
use std::str::FromStr;
use tauri::{Emitter, State};
use crate::app_config::AppType;
use crate::services::subscription::{CredentialStatus, SubscriptionQuota};
use crate::store::AppState;
/// 查询官方订阅额度
///
/// 读取 CLI 工具已有的 OAuth 凭据并调用官方 API 获取使用额度。
/// 不需要 AppState(不访问数据库),直接读文件 + 发 HTTP。
/// 结果(无论业务失败还是 transport 层 Err)都会写入 `UsageCache`、通知托盘
/// 刷新,并 emit `usage-cache-updated`,让前端 React Query 与托盘共享同一份
/// 最新数据。失败快照写入后 `format_subscription_summary` 会通过 `success=false`
/// 守卫返回 `None`,托盘 suffix 自然消失,避免长期滞留旧配额数字。
/// Err 原样向前端返回,React Query 的 onError 不会被吞掉。
#[tauri::command]
pub async fn get_subscription_quota(tool: String) -> Result<SubscriptionQuota, String> {
crate::services::subscription::get_subscription_quota(&tool).await
pub async fn get_subscription_quota(
app: tauri::AppHandle,
state: State<'_, AppState>,
tool: String,
) -> Result<SubscriptionQuota, String> {
let inner = crate::services::subscription::get_subscription_quota(&tool).await;
let snapshot = match &inner {
Ok(q) => q.clone(),
// transport 层 Err —— 凭据状态不明,用 Valid 表达"凭据没问题,是通信/parse 出错"。
Err(err_msg) => SubscriptionQuota::error(&tool, CredentialStatus::Valid, err_msg.clone()),
};
if let Ok(app_type) = AppType::from_str(&tool) {
let payload = serde_json::json!({
"kind": "subscription",
"appType": app_type.as_str(),
"data": &snapshot,
});
if let Err(e) = app.emit("usage-cache-updated", payload) {
log::error!("emit usage-cache-updated (subscription) 失败: {e}");
}
state.usage_cache.put_subscription(app_type, snapshot);
crate::tray::schedule_tray_refresh(&app);
}
inner
}
+120 -3
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
@@ -159,15 +160,34 @@ pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, AppE
serde_json::from_str(&content).map_err(|e| AppError::json(path, e))
}
/// 写入 JSON 配置文件
/// 递归排序 JSON 对象的键(按字母顺序),确保序列化输出是确定性的
fn sort_json_keys(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut sorted_map = Map::new();
let mut keys: Vec<_> = map.keys().collect();
keys.sort();
for key in keys {
sorted_map.insert(key.clone(), sort_json_keys(&map[key]));
}
Value::Object(sorted_map)
}
Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()),
other => other.clone(),
}
}
/// 写入 JSON 配置文件(键按字母排序,确保确定性输出)
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {
// 确保目录存在
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let json =
serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?;
let value = serde_json::to_value(data).map_err(|e| AppError::JsonSerialize { source: e })?;
let sorted_value = sort_json_keys(&value);
let json = serde_json::to_string_pretty(&sorted_value)
.map_err(|e| AppError::JsonSerialize { source: e })?;
atomic_write(path, json.as_bytes())
}
@@ -271,6 +291,103 @@ mod tests {
let override_dir = PathBuf::from("/");
assert!(derive_mcp_path_from_override(&override_dir).is_none());
}
#[test]
fn sort_json_keys_sorts_top_level_object() {
let input = serde_json::json!({
"z": 1,
"a": 2,
"m": 3,
});
let sorted = sort_json_keys(&input);
let serialized = serde_json::to_string(&sorted).unwrap();
assert_eq!(serialized, r#"{"a":2,"m":3,"z":1}"#);
}
#[test]
fn sort_json_keys_recurses_into_nested_objects() {
let input = serde_json::json!({
"outer_b": {"z": 1, "a": 2},
"outer_a": {"y": 3, "b": 4},
});
let sorted = sort_json_keys(&input);
let serialized = serde_json::to_string(&sorted).unwrap();
assert_eq!(
serialized,
r#"{"outer_a":{"b":4,"y":3},"outer_b":{"a":2,"z":1}}"#
);
}
#[test]
fn sort_json_keys_preserves_array_order() {
let input = serde_json::json!([3, 1, 2]);
let sorted = sort_json_keys(&input);
let serialized = serde_json::to_string(&sorted).unwrap();
assert_eq!(serialized, "[3,1,2]");
}
#[test]
fn sort_json_keys_sorts_objects_inside_arrays_but_keeps_array_order() {
let input = serde_json::json!([
{"z": 1, "a": 2},
{"y": 3, "b": 4},
]);
let sorted = sort_json_keys(&input);
let serialized = serde_json::to_string(&sorted).unwrap();
assert_eq!(serialized, r#"[{"a":2,"z":1},{"b":4,"y":3}]"#);
}
#[test]
fn sort_json_keys_passes_through_primitives() {
let cases = vec![
serde_json::json!("hello"),
serde_json::json!(42),
serde_json::json!(3.5),
serde_json::json!(true),
serde_json::json!(null),
];
for value in cases {
let sorted = sort_json_keys(&value);
assert_eq!(sorted, value);
}
}
#[test]
fn sort_json_keys_handles_empty_collections() {
let empty_obj = serde_json::json!({});
assert_eq!(
serde_json::to_string(&sort_json_keys(&empty_obj)).unwrap(),
"{}"
);
let empty_arr = serde_json::json!([]);
assert_eq!(
serde_json::to_string(&sort_json_keys(&empty_arr)).unwrap(),
"[]"
);
}
#[test]
fn sort_json_keys_produces_identical_output_for_different_insertion_orders() {
// 核心保证:同一逻辑配置无论键的插入顺序如何,写出的字节序列必须一致。
let mut a = Map::new();
a.insert("env".to_string(), serde_json::json!({"PATH": "/usr/bin"}));
a.insert("model".to_string(), serde_json::json!("claude-sonnet-4-5"));
a.insert("permissions".to_string(), serde_json::json!({"allow": []}));
let mut b = Map::new();
b.insert("permissions".to_string(), serde_json::json!({"allow": []}));
b.insert("model".to_string(), serde_json::json!("claude-sonnet-4-5"));
b.insert("env".to_string(), serde_json::json!({"PATH": "/usr/bin"}));
let sorted_a = sort_json_keys(&Value::Object(a));
let sorted_b = sort_json_keys(&Value::Object(b));
assert_eq!(
serde_json::to_string(&sorted_a).unwrap(),
serde_json::to_string(&sorted_b).unwrap(),
);
}
}
/// 复制文件
+4 -2
View File
@@ -791,8 +791,10 @@ mod tests {
std::fs::create_dir_all(&test_home).expect("create test home");
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
let mut settings = AppSettings::default();
settings.backup_interval_hours = Some(0);
let settings = AppSettings {
backup_interval_hours: Some(0),
..AppSettings::default()
};
update_settings(settings).expect("disable auto backup");
let db = Database::memory()?;
+6 -3
View File
@@ -13,7 +13,7 @@ impl Database {
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
FROM mcp_servers
ORDER BY name ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -31,6 +31,7 @@ impl Database {
let enabled_codex: bool = row.get(8)?;
let enabled_gemini: bool = row.get(9)?;
let enabled_opencode: bool = row.get(10)?;
let enabled_hermes: bool = row.get(11)?;
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
@@ -46,6 +47,7 @@ impl Database {
codex: enabled_codex,
gemini: enabled_gemini,
opencode: enabled_opencode,
hermes: enabled_hermes,
},
description,
homepage,
@@ -70,8 +72,8 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO mcp_servers (
id, name, server_config, description, homepage, docs, tags,
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
server.id,
server.name,
@@ -87,6 +89,7 @@ impl Database {
server.apps.codex,
server.apps.gemini,
server.apps.opencode,
server.apps.hermes,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
+16
View File
@@ -535,6 +535,22 @@ impl Database {
Ok(ids)
}
/// 判断指定 app 下是否已存在任意 provider。
///
/// 启动阶段的 live import 需要使用这个更严格的判断:
/// 只要该 app 已经有任何 provider(包括官方 seed),就不应再自动导入 `default`。
pub fn has_any_provider_for_app(&self, app_type: &str) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let exists: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM providers WHERE app_type = ?1)",
params![app_type],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(exists)
}
/// 判断指定 app 下是否存在非官方种子的供应商。
///
/// 比 `get_all_providers` 轻量得多:只读 id 列、无 endpoint 子查询、首条命中即返回。
+29 -1
View File
@@ -10,6 +10,8 @@
use crate::app_config::AppType;
pub(crate) const CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID: &str = "claude-desktop-official";
/// 单条官方供应商种子定义。
pub(crate) struct OfficialProviderSeed {
pub id: &'static str,
@@ -22,7 +24,7 @@ pub(crate) struct OfficialProviderSeed {
pub settings_config_json: &'static str,
}
/// Claude / Codex / Gemini 三个应用的官方预设。
/// Claude / Claude Desktop / Codex / Gemini 的官方预设。
///
/// id 固定,便于幂等检查;name 直接用英文原名(与前端预设一致),不做 i18n。
pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
@@ -36,6 +38,16 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
// 空 env 让用户走 Claude CLI 默认认证流程
settings_config_json: r#"{"env":{}}"#,
},
OfficialProviderSeed {
id: CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID,
app_type: AppType::ClaudeDesktop,
name: "Claude Desktop Official",
website_url: "https://claude.ai/download",
icon: "anthropic",
icon_color: "#D4915D",
// 空 env 只是占位;切换该 provider 时会恢复 Claude Desktop 1P 模式
settings_config_json: r#"{"env":{}}"#,
},
OfficialProviderSeed {
id: "codex-official",
app_type: AppType::Codex,
@@ -64,3 +76,19 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
pub(crate) fn is_official_seed_id(id: &str) -> bool {
OFFICIAL_SEEDS.iter().any(|seed| seed.id == id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn official_seeds_include_claude_desktop() {
let seed = OFFICIAL_SEEDS
.iter()
.find(|seed| seed.id == CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID)
.expect("claude desktop official seed");
assert_eq!(seed.app_type, AppType::ClaudeDesktop);
assert!(is_official_seed_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID));
}
}
+15 -12
View File
@@ -23,7 +23,7 @@ impl Database {
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
installed_at, content_hash, updated_at
enabled_hermes, installed_at, content_hash, updated_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -44,10 +44,11 @@ impl Database {
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
hermes: row.get(12)?,
},
installed_at: row.get(12)?,
content_hash: row.get(13)?,
updated_at: row.get::<_, i64>(14).unwrap_or(0),
installed_at: row.get(13)?,
content_hash: row.get(14)?,
updated_at: row.get::<_, i64>(15).unwrap_or(0),
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -67,7 +68,7 @@ impl Database {
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
installed_at, content_hash, updated_at
enabled_hermes, installed_at, content_hash, updated_at
FROM skills WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -87,10 +88,11 @@ impl Database {
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
hermes: row.get(12)?,
},
installed_at: row.get(12)?,
content_hash: row.get(13)?,
updated_at: row.get::<_, i64>(14).unwrap_or(0),
installed_at: row.get(13)?,
content_hash: row.get(14)?,
updated_at: row.get::<_, i64>(15).unwrap_or(0),
})
});
@@ -107,9 +109,9 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes,
installed_at, content_hash, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
params![
skill.id,
skill.name,
@@ -123,6 +125,7 @@ impl Database {
skill.apps.codex,
skill.apps.gemini,
skill.apps.opencode,
skill.apps.hermes,
skill.installed_at,
skill.content_hash,
skill.updated_at,
@@ -154,8 +157,8 @@ impl Database {
let conn = lock_conn!(self.conn);
let affected = conn
.execute(
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5",
params![apps.claude, apps.codex, apps.gemini, apps.opencode, id],
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4, enabled_hermes = ?5 WHERE id = ?6",
params![apps.claude, apps.codex, apps.gemini, apps.opencode, apps.hermes, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
+84 -16
View File
@@ -4,6 +4,7 @@
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::services::usage_stats::effective_usage_log_filter;
use chrono::{Duration, Local, TimeZone};
/// Compute the rollup/prune cutoff aligned to a local-day boundary.
@@ -101,7 +102,8 @@ impl Database {
fn do_rollup_and_prune(conn: &rusqlite::Connection, cutoff: i64) -> Result<u64, AppError> {
// Aggregate old logs, merging with any pre-existing rollup rows via LEFT JOIN.
conn.execute(
let effective_filter = effective_usage_log_filter("l");
let aggregation_sql = format!(
"INSERT OR REPLACE INTO usage_daily_rollups
(date, app_type, provider_id, model,
request_count, success_count,
@@ -124,27 +126,30 @@ impl Database {
ELSE 0 END
FROM (
SELECT
date(created_at, 'unixepoch', 'localtime') as d,
app_type as a, provider_id as p, model as m,
date(l.created_at, 'unixepoch', 'localtime') as d,
l.app_type as a, l.provider_id as p, l.model as m,
COUNT(*) as new_req,
SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) as new_succ,
COALESCE(SUM(input_tokens), 0) as new_in,
COALESCE(SUM(output_tokens), 0) as new_out,
COALESCE(SUM(cache_read_tokens), 0) as new_cr,
COALESCE(SUM(cache_creation_tokens), 0) as new_cc,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as new_cost,
COALESCE(AVG(latency_ms), 0) as new_lat
FROM proxy_request_logs WHERE created_at < ?1
SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END) as new_succ,
COALESCE(SUM(l.input_tokens), 0) as new_in,
COALESCE(SUM(l.output_tokens), 0) as new_out,
COALESCE(SUM(l.cache_read_tokens), 0) as new_cr,
COALESCE(SUM(l.cache_creation_tokens), 0) as new_cc,
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as new_cost,
COALESCE(AVG(l.latency_ms), 0) as new_lat
FROM proxy_request_logs l
WHERE l.created_at < ?1 AND {effective_filter}
GROUP BY d, a, p, m
) agg
LEFT JOIN usage_daily_rollups old
ON old.date = agg.d AND old.app_type = agg.a
AND old.provider_id = agg.p AND old.model = agg.m",
[cutoff],
)
.map_err(|e| AppError::Database(format!("Rollup aggregation failed: {e}")))?;
AND old.provider_id = agg.p AND old.model = agg.m"
);
// Delete the aggregated detail rows
conn.execute(&aggregation_sql, [cutoff])
.map_err(|e| AppError::Database(format!("Rollup aggregation failed: {e}")))?;
// INSERT uses the effective-log filter to exclude duplicate session rows.
// DELETE intentionally prunes all old details so those duplicates are discarded.
let deleted = conn
.execute(
"DELETE FROM proxy_request_logs WHERE created_at < ?1",
@@ -254,6 +259,69 @@ mod tests {
Ok(())
}
#[test]
fn test_rollup_uses_effective_usage_logs() -> Result<(), AppError> {
let db = Database::memory()?;
let now = chrono::Utc::now().timestamp();
let old_ts = now - 40 * 86400;
{
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
total_cost_usd, latency_ms, status_code, created_at, data_source
) VALUES (?1, 'openai', 'codex', 'gpt-5.4', 'gpt-5.4', 100, 20, 10, 0, '0.10', 100, 200, ?2, 'proxy')",
rusqlite::params!["codex-proxy-old", old_ts],
)?;
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
total_cost_usd, latency_ms, status_code, created_at, data_source
) VALUES (?1, '_codex_session', 'codex', 'gpt-5.4', 'gpt-5.4', 100, 20, 10, 0, '0.10', 0, 200, ?2, 'codex_session')",
rusqlite::params!["codex-session-old-dup", old_ts + 60],
)?;
}
let deleted = db.rollup_and_prune(30)?;
assert_eq!(deleted, 2);
let conn = crate::database::lock_conn!(db.conn);
let mut stmt = conn.prepare(
"SELECT provider_id, request_count, input_tokens, output_tokens, cache_read_tokens
FROM usage_daily_rollups WHERE app_type = 'codex'",
)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, i64>(4)?,
))
})?
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(rows.len(), 1);
let (provider_id, request_count, input_tokens, output_tokens, cache_read_tokens) = &rows[0];
assert_eq!(provider_id, "openai");
assert_eq!(*request_count, 1);
assert_eq!(*input_tokens, 100);
assert_eq!(*output_tokens, 20);
assert_eq!(*cache_read_tokens, 10);
let remaining: i64 =
conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
row.get(0)
})?;
assert_eq!(remaining, 0);
Ok(())
}
#[test]
fn test_rollup_noop_when_no_old_data() -> Result<(), AppError> {
let db = Database::memory()?;
+2 -1
View File
@@ -32,6 +32,7 @@ mod schema;
mod tests;
// DAO 类型导出供外部使用
pub(crate) use dao::providers_seed::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID;
pub use dao::FailoverQueueItem;
use crate::config::get_app_config_dir;
@@ -44,7 +45,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 9;
pub(crate) const SCHEMA_VERSION: i32 = 10;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+115 -1
View File
@@ -65,7 +65,8 @@ impl Database {
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_hermes BOOLEAN NOT NULL DEFAULT 0
)",
[],
)
@@ -93,6 +94,7 @@ impl Database {
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0,
content_hash TEXT,
updated_at INTEGER NOT NULL DEFAULT 0
@@ -212,6 +214,7 @@ impl Database {
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Self::create_request_logs_usage_indexes_if_supported(conn)?;
// 11. Model Pricing 表
conn.execute(
@@ -423,6 +426,11 @@ impl Database {
Self::migrate_v8_to_v9(conn)?;
Self::set_user_version(conn, 9)?;
}
9 => {
log::info!("迁移数据库从 v9 到 v10(添加 Hermes Agent 支持)");
Self::migrate_v9_to_v10(conn)?;
Self::set_user_version(conn, 10)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -1100,6 +1108,7 @@ impl Database {
"data_source",
"TEXT NOT NULL DEFAULT 'proxy'",
)?;
Self::create_request_logs_usage_indexes_if_supported(conn)?;
}
// 2. 创建会话日志同步状态表
@@ -1168,11 +1177,43 @@ impl Database {
Ok(())
}
/// v9 -> v10 迁移:添加 Hermes Agent 支持
fn migrate_v9_to_v10(conn: &Connection) -> Result<(), AppError> {
Self::add_column_if_missing(
conn,
"mcp_servers",
"enabled_hermes",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
// skills table may not exist in databases migrated from very old versions
if Self::table_exists(conn, "skills")? {
Self::add_column_if_missing(
conn,
"skills",
"enabled_hermes",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
}
log::info!("v9 -> v10 迁移完成:已添加 Hermes Agent 支持");
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
let pricing_data = [
// Claude 4.7 系列
(
"claude-opus-4-7",
"Claude Opus 4.7",
"5",
"25",
"0.50",
"6.25",
),
// Claude 4.6 系列
(
"claude-opus-4-6-20260206",
@@ -1257,6 +1298,13 @@ impl Database {
"0.30",
"3.75",
),
// GPT-5.5 系列
("gpt-5.5", "GPT-5.5", "5", "30", "0.50", "0"),
("gpt-5.5-low", "GPT-5.5", "5", "30", "0.50", "0"),
("gpt-5.5-medium", "GPT-5.5", "5", "30", "0.50", "0"),
("gpt-5.5-high", "GPT-5.5", "5", "30", "0.50", "0"),
("gpt-5.5-xhigh", "GPT-5.5", "5", "30", "0.50", "0"),
("gpt-5.5-minimal", "GPT-5.5", "5", "30", "0.50", "0"),
// GPT-5.4 系列
("gpt-5.4", "GPT-5.4", "2.50", "15", "0.25", "0"),
("gpt-5.4-mini", "GPT-5.4 Mini", "0.75", "4.50", "0.075", "0"),
@@ -1581,6 +1629,23 @@ impl Database {
"0.14",
"0",
),
// DeepSeek V4 系列(官方 CNY 按 1 USD ≈ 7.14 折算)
(
"deepseek-v4-flash",
"DeepSeek V4 Flash",
"0.14",
"0.28",
"0.028",
"0",
),
(
"deepseek-v4-pro",
"DeepSeek V4 Pro",
"1.68",
"3.36",
"0.14",
"0",
),
// Kimi (月之暗面)
(
"kimi-k2-thinking",
@@ -1600,6 +1665,7 @@ impl Database {
"0",
),
("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"),
("kimi-k2.6", "Kimi K2.6", "0.95", "4.00", "0.16", "0"),
// MiniMax 系列
("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
(
@@ -1851,6 +1917,54 @@ impl Database {
Ok(())
}
fn create_request_logs_usage_indexes_if_supported(conn: &Connection) -> Result<(), AppError> {
if !Self::table_exists(conn, "proxy_request_logs")? {
return Ok(());
}
let has_app_type = Self::has_column(conn, "proxy_request_logs", "app_type")?;
let has_created_at = Self::has_column(conn, "proxy_request_logs", "created_at")?;
if has_app_type && has_created_at {
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_app_created_at
ON proxy_request_logs(app_type, created_at DESC)",
[],
)
.map_err(|e| AppError::Database(format!("创建使用量应用时间索引失败: {e}")))?;
}
let required_columns = [
"app_type",
"data_source",
"input_tokens",
"output_tokens",
"cache_read_tokens",
"created_at",
"cache_creation_tokens",
];
for column in required_columns {
if !Self::has_column(conn, "proxy_request_logs", column)? {
return Ok(());
}
}
conn.execute("DROP INDEX IF EXISTS idx_request_logs_dedup_lookup", [])
.map_err(|e| AppError::Database(format!("删除旧使用量去重索引失败: {e}")))?;
// 查询层为了兼容历史 NULL data_source 行,会使用
// COALESCE(data_source, 'proxy')。普通 data_source 索引无法匹配该表达式,
// 会让跨源去重子查询退化成大量扫描;表达式索引让 SQLite 能按同一表达式查找。
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_dedup_lookup_expr
ON proxy_request_logs(app_type, COALESCE(data_source, 'proxy'), input_tokens,
output_tokens, cache_read_tokens, created_at,
cache_creation_tokens)",
[],
)
.map_err(|e| AppError::Database(format!("创建使用量去重表达式索引失败: {e}")))?;
Ok(())
}
fn validate_identifier(s: &str, kind: &str) -> Result<(), AppError> {
if s.is_empty() {
return Err(AppError::Database(format!("{kind} 不能为空")));
+2
View File
@@ -167,6 +167,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
codex: false,
gemini: false,
opencode: false,
hermes: false,
};
for app in apps_str.split(',') {
@@ -179,6 +180,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
// OpenClaw doesn't support MCP, ignore silently
log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter");
}
"hermes" => apps.hermes = true,
other => {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': {other}"
+1 -1
View File
@@ -31,7 +31,7 @@ pub use skill::import_skill_from_deeplink;
///
/// Represents a parsed ccswitch:// URL ready for processing.
/// This struct contains all possible fields for all resource types.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeepLinkImportRequest {
/// Protocol version (e.g., "v1")
+6 -6
View File
@@ -81,10 +81,10 @@ fn parse_provider_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "opencode" | "openclaw"
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
)));
}
@@ -190,10 +190,10 @@ fn parse_prompt_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "opencode" | "openclaw"
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
)));
}
@@ -262,10 +262,10 @@ fn parse_mcp_deeplink(
let trimmed = app.trim();
if !matches!(
trimmed,
"claude" | "codex" | "gemini" | "opencode" | "openclaw"
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{trimmed}'"
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
)));
}
}
+140 -10
View File
@@ -5,7 +5,7 @@
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
use super::DeepLinkImportRequest;
use crate::error::AppError;
use crate::provider::{Provider, ProviderMeta, UsageScript};
use crate::provider::{ClaudeDesktopMode, Provider, ProviderMeta, UsageScript};
use crate::services::ProviderService;
use crate::store::AppState;
use crate::AppType;
@@ -142,15 +142,20 @@ pub(crate) fn build_provider_from_request(
request: &DeepLinkImportRequest,
) -> Result<Provider, AppError> {
let settings_config = match app_type {
AppType::Claude => build_claude_settings(request),
AppType::Claude | AppType::ClaudeDesktop => build_claude_settings(request),
AppType::Codex => build_codex_settings(request),
AppType::Gemini => build_gemini_settings(request),
AppType::OpenCode => build_opencode_settings(request),
AppType::OpenClaw => build_openclaw_settings(request),
AppType::OpenClaw => build_additive_app_settings(request),
AppType::Hermes => build_hermes_settings(request),
};
// Build usage script configuration if provided
let meta = build_provider_meta(request)?;
let mut meta = build_provider_meta(request)?;
if matches!(app_type, AppType::ClaudeDesktop) {
meta.get_or_insert_with(ProviderMeta::default)
.claude_desktop_mode = Some(ClaudeDesktopMode::Direct);
}
let provider = Provider {
id: String::new(), // Will be generated by caller
@@ -393,11 +398,11 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value
})
}
fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
/// Build settings for OpenClaw (camelCase live config).
/// Format: { baseUrl, apiKey, api, models }
fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
// Build OpenClaw provider config
// Format: { baseUrl, apiKey, api, models }
let mut config = serde_json::Map::new();
if !endpoint.is_empty() {
@@ -408,10 +413,49 @@ fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value
config.insert("apiKey".to_string(), json!(api_key));
}
// Default to OpenAI-compatible API
config.insert("api".to_string(), json!("openai-completions"));
// Build models array
if let Some(model) = &request.model {
config.insert(
"models".to_string(),
json!([{ "id": model, "name": model }]),
);
}
json!(config)
}
/// Build Hermes provider settings (snake_case YAML-native fields).
///
/// Hermes' `custom_providers:` entries use `base_url` / `api_key` / `api_mode`
/// (see `_VALID_CUSTOM_PROVIDER_FIELDS` in upstream `hermes_cli/config.py`).
/// Emitting camelCase here — as the OpenClaw path does — would poison the
/// YAML with unknown root fields the Hermes runtime ignores.
///
/// `api_mode` is always written explicitly. Deeplinks have no field to carry
/// it, so we default to `chat_completions` (the most widely compatible
/// protocol) and let the user adjust via the UI after import. We never rely
/// on Hermes' built-in URL heuristics, which only recognize a handful of
/// official endpoints.
fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
let mut config = serde_json::Map::new();
if let Some(name) = request.name.as_deref().filter(|s| !s.is_empty()) {
config.insert("name".to_string(), json!(name));
}
if !endpoint.is_empty() {
config.insert("base_url".to_string(), json!(endpoint));
}
if let Some(api_key) = &request.api_key {
config.insert("api_key".to_string(), json!(api_key));
}
config.insert("api_mode".to_string(), json!("chat_completions"));
if let Some(model) = &request.model {
config.insert(
"models".to_string(),
@@ -484,7 +528,7 @@ pub fn parse_and_merge_config(
"codex" => merge_codex_config(&mut merged, &config_value)?,
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
// Additive mode apps use JSON config directly; pass through as-is
"openclaw" | "opencode" => {
"openclaw" | "opencode" | "hermes" => {
merge_additive_config(&mut merged, &config_value)?;
}
"" => {
@@ -711,3 +755,89 @@ fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn hermes_request() -> DeepLinkImportRequest {
DeepLinkImportRequest {
resource: "provider".to_string(),
app: Some("hermes".to_string()),
name: Some("MyHermes".to_string()),
endpoint: Some("https://api.example.com/v1".to_string()),
api_key: Some("sk-test".to_string()),
model: Some("anthropic/claude-opus-4-7".to_string()),
..Default::default()
}
}
#[test]
fn build_hermes_settings_emits_snake_case() {
let settings = build_hermes_settings(&hermes_request());
let obj = settings.as_object().expect("settings must be object");
assert_eq!(obj.get("name").unwrap(), "MyHermes");
assert_eq!(obj.get("base_url").unwrap(), "https://api.example.com/v1");
assert_eq!(obj.get("api_key").unwrap(), "sk-test");
// camelCase and legacy fields must NOT be present
assert!(obj.get("baseUrl").is_none(), "no camelCase baseUrl");
assert!(obj.get("apiKey").is_none(), "no camelCase apiKey");
assert!(obj.get("api").is_none(), "no legacy 'api' field");
// models array with the deeplink model id
let models = obj.get("models").unwrap().as_array().unwrap();
assert_eq!(models.len(), 1);
assert_eq!(models[0]["id"], "anthropic/claude-opus-4-7");
}
#[test]
fn build_hermes_settings_writes_default_api_mode() {
let settings = build_hermes_settings(&hermes_request());
assert_eq!(
settings.as_object().unwrap().get("api_mode").unwrap(),
"chat_completions",
"api_mode must be written explicitly so Hermes never falls back to URL auto-detection"
);
}
#[test]
fn build_hermes_settings_skips_missing_optional_fields() {
let request = DeepLinkImportRequest {
resource: "provider".to_string(),
app: Some("hermes".to_string()),
name: Some("Minimal".to_string()),
endpoint: None,
api_key: None,
model: None,
..Default::default()
};
let settings = build_hermes_settings(&request);
let obj = settings.as_object().unwrap();
assert_eq!(obj.get("name").unwrap(), "Minimal");
assert!(obj.get("base_url").is_none());
assert!(obj.get("api_key").is_none());
assert!(obj.get("models").is_none());
assert_eq!(obj.get("api_mode").unwrap(), "chat_completions");
}
#[test]
fn openclaw_still_uses_camel_case() {
// OpenClaw's live config natively uses camelCase; guard against a
// refactor accidentally flipping it to snake_case.
let request = DeepLinkImportRequest {
resource: "provider".to_string(),
app: Some("openclaw".to_string()),
name: Some("c".to_string()),
endpoint: Some("https://api.example.com".to_string()),
api_key: Some("k".to_string()),
..Default::default()
};
let settings = build_additive_app_settings(&request);
let obj = settings.as_object().unwrap();
assert!(obj.contains_key("baseUrl"));
assert!(obj.contains_key("apiKey"));
}
}
File diff suppressed because it is too large Load Diff
+123 -47
View File
@@ -1,6 +1,7 @@
mod app_config;
mod app_store;
mod auto_launch;
mod claude_desktop_config;
mod claude_mcp;
mod claude_plugin;
mod codex_config;
@@ -11,6 +12,7 @@ mod deeplink;
mod error;
mod gemini_config;
mod gemini_mcp;
pub mod hermes_config;
mod init_status;
mod lightweight;
#[cfg(target_os = "linux")]
@@ -63,6 +65,7 @@ use tauri::image::Image;
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
use tauri::RunEvent;
use tauri::{Emitter, Manager};
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
fn redact_url_for_log(url_str: &str) -> String {
match url::Url::parse(url_str) {
@@ -168,7 +171,7 @@ async fn update_tray_menu(
) -> Result<bool, String> {
match tray::create_tray_menu(&app, state.inner()) {
Ok(new_menu) => {
if let Some(tray) = app.tray_by_id("main") {
if let Some(tray) = app.tray_by_id(tray::TRAY_ID) {
tray.set_menu(Some(new_menu))
.map_err(|e| format!("更新托盘菜单失败: {e}"))?;
return Ok(true);
@@ -263,6 +266,7 @@ pub fn run() {
tray::apply_tray_policy(window.app_handle(), false);
}
} else {
api.prevent_close();
window.app_handle().exit(0);
}
}
@@ -271,7 +275,14 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(window_state_flags())
.build(),
)
.setup(|app| {
let _ = rustls::crypto::ring::default_provider().install_default();
// 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)
app_store::refresh_app_config_dir_override(app.handle());
panic_hook::init_app_config_dir(crate::config::get_app_config_dir());
@@ -484,6 +495,19 @@ pub fn run() {
for app_type in
crate::app_config::AppType::all().filter(|t| !t.is_additive_mode())
{
if !crate::services::provider::should_import_default_config_on_startup(
&app_state,
&app_type,
)
.unwrap_or(false)
{
log::debug!(
"○ {} already has providers; live import skipped",
app_type.as_str()
);
continue;
}
match crate::services::provider::import_default_config(
&app_state,
app_type.clone(),
@@ -540,6 +564,13 @@ pub fn run() {
Ok(_) => log::debug!("○ No new OpenClaw providers to import"),
Err(e) => log::warn!("✗ Failed to import OpenClaw providers: {e}"),
}
match crate::services::provider::import_hermes_providers_from_live(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} Hermes provider(s) from live config");
}
Ok(_) => log::debug!("○ No new Hermes providers to import"),
Err(e) => log::warn!("✗ Failed to import Hermes providers: {e}"),
}
// 2. OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
{
@@ -627,6 +658,14 @@ pub fn run() {
Ok(_) => log::debug!("○ No OpenCode MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import OpenCode MCP: {e}"),
}
match crate::services::mcp::McpService::import_from_hermes(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from Hermes");
}
Ok(_) => log::debug!("○ No Hermes MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Hermes MCP: {e}"),
}
}
// 4. 导入提示词文件(表空时触发)
@@ -639,6 +678,7 @@ pub fn run() {
crate::app_config::AppType::Gemini,
crate::app_config::AppType::OpenCode,
crate::app_config::AppType::OpenClaw,
crate::app_config::AppType::Hermes,
] {
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
@@ -728,10 +768,18 @@ pub fn run() {
let menu = tray::create_tray_menu(app.handle(), &app_state)?;
// 构建托盘
let mut tray_builder = TrayIconBuilder::with_id("main")
.on_tray_icon_event(|_tray, event| match event {
// 左键点击已通过 show_menu_on_left_click(true) 打开菜单,这里不再额外处理
TrayIconEvent::Click { .. } => {}
let mut tray_builder = TrayIconBuilder::with_id(tray::TRAY_ID)
.tooltip("CC Switch") // 鼠标悬停提示
.on_tray_icon_event(|tray, event| match event {
// 鼠标悬停/点击到托盘图标时,后台异步刷新用量缓存,
// 让用户下一次(或快速打开菜单的那一刻)看到较新的数字。
// refresh_all_usage_in_tray 内部有 10 秒防抖。
TrayIconEvent::Enter { .. } | TrayIconEvent::Click { .. } => {
let app = tray.app_handle().clone();
tauri::async_runtime::spawn(async move {
crate::tray::refresh_all_usage_in_tray(&app).await;
});
}
_ => log::debug!("unhandled event {event:?}"),
})
.menu(&menu)
@@ -898,28 +946,31 @@ pub fn run() {
tauri::async_runtime::spawn(async move {
const SESSION_SYNC_INTERVAL_SECS: u64 = 60;
fn run_step<T>(name: &str, result: Result<T, crate::error::AppError>) {
if let Err(e) = result {
log::warn!("{name} failed: {e}");
}
}
let db = &db_for_session_sync;
// 首次同步
if let Err(e) =
crate::services::session_usage::sync_claude_session_logs(
&db_for_session_sync,
)
{
log::warn!("Session usage initial sync failed: {e}");
}
if let Err(e) =
crate::services::session_usage_codex::sync_codex_usage(
&db_for_session_sync,
)
{
log::warn!("Codex usage initial sync failed: {e}");
}
if let Err(e) =
crate::services::session_usage_gemini::sync_gemini_usage(
&db_for_session_sync,
)
{
log::warn!("Gemini usage initial sync failed: {e}");
}
run_step(
"Usage cost startup backfill",
db.backfill_missing_usage_costs(),
);
run_step(
"Session usage initial sync",
crate::services::session_usage::sync_claude_session_logs(db),
);
run_step(
"Codex usage initial sync",
crate::services::session_usage_codex::sync_codex_usage(db),
);
run_step(
"Gemini usage initial sync",
crate::services::session_usage_gemini::sync_gemini_usage(db),
);
// 定期同步
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
@@ -928,27 +979,18 @@ pub fn run() {
interval.tick().await; // skip immediate first tick
loop {
interval.tick().await;
if let Err(e) =
crate::services::session_usage::sync_claude_session_logs(
&db_for_session_sync,
)
{
log::warn!("Session usage periodic sync failed: {e}");
}
if let Err(e) =
crate::services::session_usage_codex::sync_codex_usage(
&db_for_session_sync,
)
{
log::warn!("Codex usage periodic sync failed: {e}");
}
if let Err(e) =
crate::services::session_usage_gemini::sync_gemini_usage(
&db_for_session_sync,
)
{
log::warn!("Gemini usage periodic sync failed: {e}");
}
run_step(
"Session usage periodic sync",
crate::services::session_usage::sync_claude_session_logs(db),
);
run_step(
"Codex usage periodic sync",
crate::services::session_usage_codex::sync_codex_usage(db),
);
run_step(
"Gemini usage periodic sync",
crate::services::session_usage_gemini::sync_gemini_usage(db),
);
}
});
});
@@ -1010,6 +1052,9 @@ pub fn run() {
commands::remove_provider_from_live_config,
commands::switch_provider,
commands::import_default_config,
commands::get_claude_desktop_status,
commands::get_claude_desktop_default_routes,
commands::import_claude_desktop_providers_from_claude,
commands::get_claude_config_status,
commands::get_config_status,
commands::get_claude_code_config_path,
@@ -1151,6 +1196,7 @@ pub fn run() {
commands::get_auto_launch_status,
// Proxy server management
commands::start_proxy_server,
commands::stop_proxy_server,
commands::stop_proxy_with_restore,
commands::get_proxy_takeover_status,
commands::set_proxy_takeover_for_app,
@@ -1234,6 +1280,17 @@ pub fn run() {
commands::set_openclaw_env,
commands::get_openclaw_tools,
commands::set_openclaw_tools,
// Hermes specific
commands::import_hermes_providers_from_live,
commands::get_hermes_live_provider_ids,
commands::get_hermes_live_provider,
commands::get_hermes_model_config,
commands::open_hermes_web_ui,
commands::launch_hermes_dashboard,
commands::get_hermes_memory,
commands::set_hermes_memory,
commands::get_hermes_memory_limits,
commands::set_hermes_memory_enabled,
// Global upstream proxy
commands::get_global_proxy_url,
commands::set_global_proxy_url,
@@ -1311,6 +1368,7 @@ pub fn run() {
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
save_window_state_before_exit(&app_handle);
cleanup_before_exit(&app_handle).await;
log::info!("清理完成,退出应用");
@@ -1717,3 +1775,21 @@ fn show_database_init_error_dialog(
))
.blocking_show()
}
// ============================================================
// 在应用主动退出前显式持久化窗口状态
// ============================================================
fn window_state_flags() -> StateFlags {
StateFlags::POSITION | StateFlags::SIZE | StateFlags::MAXIMIZED
}
/// 当前应用的退出路径会拦截 `ExitRequested` 并最终直接 `std::process::exit(0)`
/// 这里需要在真正结束进程前手动落盘,避免 window-state 插件的默认退出钩子被绕过。
pub fn save_window_state_before_exit(app_handle: &tauri::AppHandle) {
if let Err(err) = app_handle.save_window_state(window_state_flags()) {
log::error!("退出前保存窗口状态失败: {err}");
} else {
log::info!("已在退出前保存窗口状态");
}
}
+3 -1
View File
@@ -17,6 +17,7 @@ pub fn enter_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
}
if let Some(window) = app.get_webview_window("main") {
crate::save_window_state_before_exit(app);
window
.destroy()
.map_err(|e| format!("销毁主窗口失败: {e}"))?;
@@ -64,11 +65,12 @@ pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
WebviewWindowBuilder::from_config(app, window_config)
.map_err(|e| format!("加载主窗口配置失败: {e}"))?
.visible(true)
.build()
.map_err(|e| format!("创建主窗口失败: {e}"))?;
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "linux")]
{
+1
View File
@@ -92,6 +92,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
codex: false,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
+1
View File
@@ -236,6 +236,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
codex: true,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
+1
View File
@@ -88,6 +88,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
codex: false,
gemini: true,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
+574
View File
@@ -0,0 +1,574 @@
//! Hermes MCP sync and import module
//!
//! Handles conversion between CC Switch unified MCP format and Hermes config.yaml format.
//!
//! ## Format mapping
//!
//! | CC Switch unified (JSON) | Hermes config.yaml (YAML) |
//! |-------------------------------------------------|---------------------------------|
//! | `{"type":"stdio","command":"npx","args":[...],"env":{}}` | `command: npx`, `args: [...]`, `env: {}` |
//! | `{"type":"sse"/"http","url":"...","headers":{}}` | `url: "..."`, `headers: {}` |
//!
//! Key differences from Claude format:
//! - Hermes has NO explicit `type` field -- it infers stdio (has `command`) vs HTTP (has `url`)
//! - Hermes has extra fields: `enabled`, `timeout`, `connect_timeout`, `tools`, `sampling`
//! - These Hermes-specific fields are preserved on merge-on-write and stripped on import
use serde_json::{json, Value};
use std::collections::HashMap;
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
use crate::error::AppError;
use crate::hermes_config;
use super::validation::validate_server_spec;
/// Hermes-specific fields preserved on merge-on-write, stripped on import.
/// Update this list when Hermes adds new per-server config fields.
///
/// `auth` ("oauth" / absent) is an OAuth-type declaration read by Hermes —
/// CC Switch has no OAuth UI, but losing the field on round-trip downgrades
/// the server to unauthenticated calls.
const HERMES_EXTRA_FIELDS: &[&str] = &[
"enabled",
"timeout",
"connect_timeout",
"tools",
"sampling",
"roots",
"auth",
];
// ============================================================================
// Helper Functions
// ============================================================================
/// Check if Hermes MCP sync should proceed
fn should_sync_hermes_mcp() -> bool {
hermes_config::get_hermes_dir().exists()
}
// ============================================================================
// Format Conversion: CC Switch -> Hermes
// ============================================================================
/// Convert CC Switch unified format to Hermes format
///
/// Conversion rules:
/// - `stdio`: output `command`, `args`, `env` (strip `type` field)
/// - `sse`/`http`: output `url`, `headers` (strip `type` field)
/// - Always add `enabled: true`
fn convert_to_hermes_format(spec: &Value) -> Result<Value, AppError> {
let obj = spec
.as_object()
.ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?;
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
let mut result = serde_json::Map::new();
match typ {
"stdio" => {
if let Some(command) = obj.get("command") {
result.insert("command".into(), command.clone());
}
if let Some(args) = obj.get("args") {
if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) {
result.insert("args".into(), args.clone());
}
}
if let Some(env) = obj.get("env") {
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
result.insert("env".into(), env.clone());
}
}
}
"sse" | "http" => {
if let Some(url) = obj.get("url") {
result.insert("url".into(), url.clone());
}
if let Some(headers) = obj.get("headers") {
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
{
result.insert("headers".into(), headers.clone());
}
}
}
_ => {
return Err(AppError::McpValidation(format!("Unknown MCP type: {typ}")));
}
}
result.insert("enabled".into(), json!(true));
Ok(Value::Object(result))
}
// ============================================================================
// Format Conversion: Hermes -> CC Switch
// ============================================================================
/// Convert Hermes format to CC Switch unified format
///
/// Conversion rules:
/// - If `command` exists: set `type: "stdio"`, extract `command`, `args`, `env`
/// - If `url` exists: set `type: "sse"`, extract `url`, `headers`
/// - Strip Hermes-specific fields: `enabled`, `timeout`, `connect_timeout`, `tools`, `sampling`
fn convert_from_hermes_format(id: &str, spec: &Value) -> Result<Value, AppError> {
let obj = spec
.as_object()
.ok_or_else(|| AppError::McpValidation("Hermes MCP spec must be a JSON object".into()))?;
let mut result = serde_json::Map::new();
if obj.contains_key("command") {
// stdio type
result.insert("type".into(), json!("stdio"));
if let Some(command) = obj.get("command") {
result.insert("command".into(), command.clone());
}
if let Some(args) = obj.get("args") {
if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) {
result.insert("args".into(), args.clone());
}
}
if let Some(env) = obj.get("env") {
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
result.insert("env".into(), env.clone());
}
}
} else if obj.contains_key("url") {
// HTTP/SSE type
result.insert("type".into(), json!("sse"));
if let Some(url) = obj.get("url") {
result.insert("url".into(), url.clone());
}
if let Some(headers) = obj.get("headers") {
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) {
result.insert("headers".into(), headers.clone());
}
}
} else {
return Err(AppError::McpValidation(format!(
"Hermes MCP server '{id}' has neither 'command' nor 'url' field"
)));
}
// Note: Hermes-specific fields (enabled, timeout, connect_timeout, tools, sampling)
// are intentionally NOT copied -- they are stripped on import.
Ok(Value::Object(result))
}
// ============================================================================
// Public API: Sync Functions
// ============================================================================
/// Sync a single MCP server to Hermes live config (merge-on-write)
///
/// Strategy:
/// 1. Read existing mcp_servers from config.yaml
/// 2. If server already exists, merge: keep Hermes-specific fields, overwrite core fields
/// 3. Set `enabled: true`
/// 4. Write back
pub fn sync_single_server_to_hermes(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
if !should_sync_hermes_mcp() {
return Ok(());
}
let hermes_spec = convert_to_hermes_format(server_spec)?;
let id_owned = id.to_string();
hermes_config::update_mcp_servers_yaml(|servers| {
let id_yaml = serde_yaml::Value::String(id_owned.clone());
let merged_json = if let Some(existing_yaml) = servers.get(&id_yaml) {
let existing_json = hermes_config::yaml_to_json(existing_yaml)?;
merge_hermes_spec(&existing_json, &hermes_spec)
} else {
hermes_spec.clone()
};
let merged_yaml_value = hermes_config::json_to_yaml(&merged_json)?;
servers.insert(id_yaml, merged_yaml_value);
Ok(())
})
}
/// Merge new spec into existing Hermes spec, preserving Hermes-specific fields.
///
/// Core fields (command, args, env, url, headers) come from `new_spec`.
/// Hermes-specific fields (enabled, tools, sampling, etc.) are kept from
/// `existing` — this prevents CC Switch from overwriting user customizations.
fn merge_hermes_spec(existing: &Value, new_spec: &Value) -> Value {
let mut result = serde_json::Map::new();
// Copy Hermes-specific fields from existing config
if let Some(existing_obj) = existing.as_object() {
for &field in HERMES_EXTRA_FIELDS {
if let Some(val) = existing_obj.get(field) {
result.insert(field.to_string(), val.clone());
}
}
}
// Overwrite with core fields from new spec; for Hermes-specific fields,
// only apply from new_spec if existing didn't already have them
if let Some(new_obj) = new_spec.as_object() {
for (key, val) in new_obj {
if HERMES_EXTRA_FIELDS.contains(&key.as_str()) && result.contains_key(key) {
continue; // Existing Hermes-specific field takes precedence
}
result.insert(key.clone(), val.clone());
}
}
Value::Object(result)
}
/// Remove a single MCP server from Hermes live config
pub fn remove_server_from_hermes(id: &str) -> Result<(), AppError> {
if !should_sync_hermes_mcp() {
return Ok(());
}
let id_owned = id.to_string();
hermes_config::update_mcp_servers_yaml(|servers| {
servers.remove(serde_yaml::Value::String(id_owned.clone()));
Ok(())
})
}
/// Import MCP servers from Hermes config to unified structure
///
/// Existing servers will have Hermes app enabled without overwriting other fields.
pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result<usize, AppError> {
let yaml_map = hermes_config::get_mcp_servers_yaml()?;
if yaml_map.is_empty() {
return Ok(0);
}
// Ensure servers map exists
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
let mut changed = 0;
let mut errors = Vec::new();
for (key, spec_yaml) in &yaml_map {
let id = match key.as_str() {
Some(s) => s.to_string(),
None => {
log::warn!("Skip Hermes MCP server with non-string key");
continue;
}
};
// Convert YAML value to JSON
let spec_json = match hermes_config::yaml_to_json(spec_yaml) {
Ok(j) => j,
Err(e) => {
log::warn!("Skip Hermes MCP server '{id}': failed to convert YAML to JSON: {e}");
errors.push(format!("{id}: {e}"));
continue;
}
};
// Convert from Hermes format to unified format
let unified_spec = match convert_from_hermes_format(&id, &spec_json) {
Ok(s) => s,
Err(e) => {
log::warn!("Skip invalid Hermes MCP server '{id}': {e}");
errors.push(format!("{id}: {e}"));
continue;
}
};
// Validate the converted spec
if let Err(e) = validate_server_spec(&unified_spec) {
log::warn!("Skip invalid MCP server '{id}' after conversion: {e}");
errors.push(format!("{id}: {e}"));
continue;
}
if let Some(existing) = servers.get_mut(&id) {
// Existing server: just enable Hermes app
if !existing.apps.hermes {
existing.apps.hermes = true;
changed += 1;
log::info!("MCP server '{id}' enabled for Hermes");
}
} else {
// New server: default to only Hermes enabled
servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name: id.clone(),
server: unified_spec,
apps: McpApps {
claude: false,
codex: false,
gemini: false,
opencode: false,
hermes: true,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
changed += 1;
log::info!("Imported new MCP server '{id}' from Hermes");
}
}
if !errors.is_empty() {
log::warn!(
"Import completed with {} failures: {:?}",
errors.len(),
errors
);
}
Ok(changed)
}
#[cfg(test)]
mod tests {
use super::*;
// ========================================================================
// convert_to_hermes_format tests
// ========================================================================
#[test]
fn test_convert_stdio_to_hermes() {
let spec = json!({
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
"env": { "HOME": "/Users/test" }
});
let result = convert_to_hermes_format(&spec).unwrap();
// No type field in Hermes format
assert!(result.get("type").is_none());
assert_eq!(result["command"], "npx");
assert_eq!(result["args"][0], "-y");
assert_eq!(result["args"][1], "@modelcontextprotocol/server-filesystem");
assert_eq!(result["env"]["HOME"], "/Users/test");
assert_eq!(result["enabled"], true);
}
#[test]
fn test_convert_http_to_hermes() {
let spec = json!({
"type": "sse",
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer xxx" }
});
let result = convert_to_hermes_format(&spec).unwrap();
assert!(result.get("type").is_none());
assert_eq!(result["url"], "https://example.com/mcp");
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
assert_eq!(result["enabled"], true);
}
#[test]
fn test_convert_http_type_to_hermes() {
let spec = json!({
"type": "http",
"url": "https://example.com/mcp"
});
let result = convert_to_hermes_format(&spec).unwrap();
assert!(result.get("type").is_none());
assert_eq!(result["url"], "https://example.com/mcp");
assert_eq!(result["enabled"], true);
}
#[test]
fn test_convert_stdio_empty_env_to_hermes() {
let spec = json!({
"type": "stdio",
"command": "node",
"args": [],
"env": {}
});
let result = convert_to_hermes_format(&spec).unwrap();
assert_eq!(result["command"], "node");
// Empty args and env should be omitted
assert!(result.get("args").is_none());
assert!(result.get("env").is_none());
assert_eq!(result["enabled"], true);
}
#[test]
fn test_convert_unknown_type_to_hermes_fails() {
let spec = json!({ "type": "grpc", "command": "foo" });
assert!(convert_to_hermes_format(&spec).is_err());
}
// ========================================================================
// convert_from_hermes_format tests
// ========================================================================
#[test]
fn test_convert_hermes_stdio_to_unified() {
let spec = json!({
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
"env": { "HOME": "/Users/test" },
"enabled": true,
"timeout": 30,
"connect_timeout": 10,
"tools": { "include": ["read_file"] },
"sampling": { "enabled": true }
});
let result = convert_from_hermes_format("filesystem", &spec).unwrap();
assert_eq!(result["type"], "stdio");
assert_eq!(result["command"], "npx");
assert_eq!(result["args"][0], "-y");
assert_eq!(result["args"][1], "@modelcontextprotocol/server-filesystem");
assert_eq!(result["env"]["HOME"], "/Users/test");
// Hermes-specific fields should be stripped
assert!(result.get("enabled").is_none());
assert!(result.get("timeout").is_none());
assert!(result.get("connect_timeout").is_none());
assert!(result.get("tools").is_none());
assert!(result.get("sampling").is_none());
}
#[test]
fn test_convert_hermes_http_to_unified() {
let spec = json!({
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer xxx" },
"enabled": true,
"timeout": 60
});
let result = convert_from_hermes_format("remote-server", &spec).unwrap();
assert_eq!(result["type"], "sse");
assert_eq!(result["url"], "https://example.com/mcp");
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
// Hermes-specific fields should be stripped
assert!(result.get("enabled").is_none());
assert!(result.get("timeout").is_none());
}
#[test]
fn test_convert_hermes_no_command_no_url_fails() {
let spec = json!({ "enabled": true, "timeout": 30 });
assert!(convert_from_hermes_format("bad-server", &spec).is_err());
}
// ========================================================================
// Merge-on-write tests
// ========================================================================
#[test]
fn test_merge_preserves_hermes_specific_fields() {
let existing = json!({
"command": "old-cmd",
"args": ["old-arg"],
"enabled": true,
"timeout": 30,
"connect_timeout": 10,
"tools": { "include": ["read_file"] },
"sampling": { "enabled": true }
});
let new_spec = json!({
"command": "new-cmd",
"args": ["new-arg"],
"env": { "KEY": "value" },
"enabled": true
});
let merged = merge_hermes_spec(&existing, &new_spec);
// Core fields should be overwritten
assert_eq!(merged["command"], "new-cmd");
assert_eq!(merged["args"][0], "new-arg");
assert_eq!(merged["env"]["KEY"], "value");
// Hermes-specific fields should be preserved from existing
assert_eq!(merged["timeout"], 30);
assert_eq!(merged["connect_timeout"], 10);
assert_eq!(merged["tools"]["include"][0], "read_file");
assert_eq!(merged["sampling"]["enabled"], true);
assert_eq!(merged["enabled"], true);
}
#[test]
fn test_merge_preserves_auth_field() {
let existing = json!({
"url": "https://mcp.example.com",
"auth": "oauth",
"enabled": true
});
let new_spec = json!({
"url": "https://mcp.example.com/updated",
"headers": { "X-Trace": "abc" },
"enabled": true
});
let merged = merge_hermes_spec(&existing, &new_spec);
assert_eq!(merged["url"], "https://mcp.example.com/updated");
assert_eq!(merged["headers"]["X-Trace"], "abc");
assert_eq!(
merged["auth"], "oauth",
"auth declaration must survive CC Switch round-trip"
);
}
#[test]
fn test_convert_hermes_strips_auth_on_import() {
let spec = json!({
"url": "https://mcp.example.com",
"auth": "oauth",
"enabled": true
});
let result = convert_from_hermes_format("remote", &spec).unwrap();
assert_eq!(result["type"], "sse");
assert_eq!(result["url"], "https://mcp.example.com");
assert!(
result.get("auth").is_none(),
"auth stays Hermes-specific; stripped from unified format"
);
}
#[test]
fn test_merge_new_server_no_existing_extra_fields() {
let existing = json!({
"command": "old-cmd"
});
let new_spec = json!({
"command": "new-cmd",
"args": ["arg1"],
"enabled": true
});
let merged = merge_hermes_spec(&existing, &new_spec);
assert_eq!(merged["command"], "new-cmd");
assert_eq!(merged["args"][0], "arg1");
assert_eq!(merged["enabled"], true);
// No extra fields to preserve
assert!(merged.get("timeout").is_none());
}
}
+3
View File
@@ -9,10 +9,12 @@
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
//! - `gemini` - Gemini MCP 同步和导入
//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换)
//! - `hermes` - Hermes MCP 同步和导入
mod claude;
mod codex;
mod gemini;
mod hermes;
mod opencode;
mod validation;
@@ -28,6 +30,7 @@ pub use gemini::{
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
sync_single_server_to_gemini,
};
pub use hermes::{import_from_hermes, remove_server_from_hermes, sync_single_server_to_hermes};
pub use opencode::{
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
};
+1
View File
@@ -259,6 +259,7 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppErr
codex: false,
gemini: false,
opencode: true,
hermes: false,
},
description: None,
homepage: None,
+5
View File
@@ -914,6 +914,7 @@ pub fn set_tools_config(tools: &OpenClawToolsConfig) -> Result<OpenClawWriteOutc
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::sync::{Mutex, OnceLock};
fn test_guard() -> std::sync::MutexGuard<'static, ()> {
@@ -966,6 +967,7 @@ mod tests {
}
#[test]
#[serial]
fn default_model_write_preserves_top_level_comments() {
let source = r#"{
// top-level comment
@@ -994,6 +996,7 @@ mod tests {
}
#[test]
#[serial]
fn default_model_noop_write_skips_backup() {
let source = r#"{
models: {
@@ -1028,6 +1031,7 @@ mod tests {
}
#[test]
#[serial]
fn save_detects_external_conflict() {
let source = r#"{
models: {
@@ -1050,6 +1054,7 @@ mod tests {
}
#[test]
#[serial]
fn remove_last_provider_writes_empty_providers_without_panic() {
let source = r#"{
models: {
+12 -2
View File
@@ -10,20 +10,30 @@ use crate::opencode_config::get_opencode_dir;
/// 返回指定应用所使用的提示词文件路径。
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
if matches!(app, AppType::ClaudeDesktop) {
return Err(AppError::localized(
"claude_desktop.prompts_unsupported",
"Claude Desktop 暂不支持 Prompts",
"Claude Desktop does not support Prompts",
));
}
let base_dir: PathBuf = match app {
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
AppType::Gemini => get_gemini_dir(),
AppType::OpenCode => get_opencode_dir(),
AppType::OpenClaw => get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
AppType::ClaudeDesktop => unreachable!("handled above"),
};
let filename = match app {
AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
AppType::OpenCode => "AGENTS.md",
AppType::OpenClaw => "AGENTS.md", // OpenClaw uses AGENTS.md for agent instructions
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
AppType::ClaudeDesktop => unreachable!("handled above"),
};
Ok(base_dir.join(filename))
+66 -3
View File
@@ -65,6 +65,25 @@ impl Provider {
in_failover_queue: false,
}
}
pub fn is_codex_oauth(&self) -> bool {
self.meta.as_ref().and_then(|m| m.provider_type.as_deref()) == Some("codex_oauth")
}
pub fn codex_fast_mode_enabled(&self) -> bool {
self.meta
.as_ref()
.map(|m| m.codex_fast_mode_enabled())
.unwrap_or(false)
}
pub fn has_usage_script_enabled(&self) -> bool {
self.meta
.as_ref()
.and_then(|m| m.usage_script.as_ref())
.map(|s| s.enabled)
.unwrap_or(false)
}
}
/// 供应商管理器
@@ -197,6 +216,28 @@ pub struct AuthBinding {
pub account_id: Option<String>,
}
/// Claude Desktop 3P 写入模式。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ClaudeDesktopMode {
Direct,
Proxy,
}
/// Claude Desktop 本地路由模式下暴露给 Desktop 的安全模型路由。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ClaudeDesktopModelRoute {
/// 真实上游模型名,只保存在 CC Switch 内部,不写入 Claude Desktop profile。
pub model: String,
/// Desktop /v1/models 中显示的名称。
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
/// Claude Desktop 3P 识别的 1M 上下文能力标记。
#[serde(rename = "supports1m", skip_serializing_if = "Option::is_none")]
pub supports_1m: Option<bool>,
}
/// 供应商元数据
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
@@ -209,6 +250,16 @@ pub struct ProviderMeta {
skip_serializing_if = "Option::is_none"
)]
pub common_config_enabled: Option<bool>,
/// Claude Desktop 3P 写入模式:direct(直连)或 proxy(预留)
#[serde(rename = "claudeDesktopMode", skip_serializing_if = "Option::is_none")]
pub claude_desktop_mode: Option<ClaudeDesktopMode>,
/// Claude Desktop proxy 模式的模型路由映射:Claude-safe route -> upstream model。
#[serde(
default,
rename = "claudeDesktopModelRoutes",
skip_serializing_if = "HashMap::is_empty"
)]
pub claude_desktop_model_routes: HashMap<String, ClaudeDesktopModelRoute>,
/// 用量查询脚本配置
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<UsageScript>,
@@ -258,9 +309,13 @@ pub struct ProviderMeta {
pub is_full_url: Option<bool>,
/// Prompt cache key for OpenAI Responses-compatible endpoints.
/// When set, injected into converted Responses requests to improve cache hit rate.
/// If not set, provider ID is used automatically during Claude -> Responses conversion.
/// If not set, Claude -> Responses conversions use a client-provided session/thread
/// identity when available; generated session IDs are not sent upstream.
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
/// Codex OAuth FAST mode: inject `service_tier = "priority"` for ChatGPT Codex requests.
#[serde(rename = "codexFastMode", skip_serializing_if = "Option::is_none")]
pub codex_fast_mode: Option<bool>,
/// 累加模式应用中,该 provider 是否已写入 live config。
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
@@ -276,6 +331,12 @@ pub struct ProviderMeta {
}
impl ProviderMeta {
/// Codex OAuth FAST mode 是否启用。默认关闭,因为 `service_tier="priority"`
/// 会按更高速率消耗 ChatGPT 订阅配额,用户需显式开启以换取更低延迟。
pub fn codex_fast_mode_enabled(&self) -> bool {
self.codex_fast_mode.unwrap_or(false)
}
/// 解析指定托管认证供应商绑定的账号 ID。
///
/// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。
@@ -699,8 +760,10 @@ mod tests {
#[test]
fn provider_meta_serializes_pricing_model_source() {
let mut meta = ProviderMeta::default();
meta.pricing_model_source = Some("response".to_string());
let meta = ProviderMeta {
pricing_model_source: Some("response".to_string()),
..ProviderMeta::default()
};
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
+49 -7
View File
@@ -6,6 +6,8 @@
//! - 以 `_` 开头的字段被视为私有参数,会被递归过滤
//! - 支持白名单机制,允许透传特定的 `_` 前缀字段
//! - 支持嵌套对象和数组的深度过滤
//! - JSON Schema 的 properties / patternProperties / definitions / $defs 名称
//! 是用户定义的字段名,不按私有参数过滤
//!
//! ## 使用场景
//! - `_internal_id`: 内部追踪 ID
@@ -65,29 +67,35 @@ pub fn filter_private_params(body: Value) -> Value {
/// ```
pub fn filter_private_params_with_whitelist(body: Value, whitelist: &[String]) -> Value {
let whitelist_set: HashSet<&str> = whitelist.iter().map(|s| s.as_str()).collect();
filter_recursive_with_whitelist(body, &mut Vec::new(), &whitelist_set)
filter_recursive_with_whitelist(body, &mut Vec::new(), &mut Vec::new(), &whitelist_set)
}
/// 递归过滤实现(支持白名单)
fn filter_recursive_with_whitelist(
value: Value,
path: &mut Vec<String>,
removed_keys: &mut Vec<String>,
whitelist: &HashSet<&str>,
) -> Value {
match value {
Value::Object(map) => {
let is_schema_name_map = path.last().is_some_and(|key| matches_schema_name_map(key));
let filtered: serde_json::Map<String, Value> = map
.into_iter()
.filter_map(|(key, val)| {
// 以 _ 开头且不在白名单中的字段被过滤
if key.starts_with('_') && !whitelist.contains(key.as_str()) {
if key.starts_with('_')
&& !whitelist.contains(key.as_str())
&& !is_schema_name_map
{
removed_keys.push(key);
None
} else {
Some((
key,
filter_recursive_with_whitelist(val, removed_keys, whitelist),
))
path.push(key.clone());
let filtered_value =
filter_recursive_with_whitelist(val, path, removed_keys, whitelist);
path.pop();
Some((key, filtered_value))
}
})
.collect();
@@ -102,13 +110,20 @@ fn filter_recursive_with_whitelist(
}
Value::Array(arr) => Value::Array(
arr.into_iter()
.map(|v| filter_recursive_with_whitelist(v, removed_keys, whitelist))
.map(|v| filter_recursive_with_whitelist(v, path, removed_keys, whitelist))
.collect(),
),
other => other,
}
}
fn matches_schema_name_map(key: &str) -> bool {
matches!(
key,
"properties" | "patternProperties" | "definitions" | "$defs"
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -282,6 +297,33 @@ mod tests {
assert!(data.get("normal").is_some());
}
#[test]
fn test_preserves_json_schema_property_names_with_underscore() {
let input = json!({
"tools": [
{
"name": "lookup",
"input_schema": {
"type": "object",
"properties": {
"_id": {"type": "string", "_internal_note": "remove"},
"_meta": {"type": "object"}
},
"_private_schema_note": "remove"
}
}
]
});
let output = filter_private_params(input);
let schema = &output["tools"][0]["input_schema"];
assert!(schema["properties"].get("_id").is_some());
assert!(schema["properties"].get("_meta").is_some());
assert!(schema["properties"]["_id"].get("_internal_note").is_none());
assert!(schema.get("_private_schema_note").is_none());
}
#[test]
fn test_empty_whitelist_same_as_default() {
let input = json!({
+169
View File
@@ -443,6 +443,41 @@ pub fn sanitize_orphan_tool_results(mut body: Value) -> Value {
body
}
/// 请求前主动剥离所有 assistant 消息里的 thinking / redacted_thinking block
///
/// Copilot 的三条目标端点(`/chat/completions`、`/v1/responses`、`/v1/chat/completions`
/// 均为 OpenAI 兼容格式,不识别 Anthropic 的 thinking block。若原样转发,上游会
/// 拒绝并返回 invalid_request_error —— 届时 `thinking_rectifier` 才做反应式清理并
/// 重试。那次已经失败的请求依旧消耗一次 premium quota,所以此处提前剥离。
///
/// 与 `thinking_rectifier::rectify_anthropic_request` 的区别:
/// - 本函数只剥 thinking / redacted_thinking 两类 block,不触碰 signature,也不
/// 移除顶层 thinking 字段——那些是错误路径上的激进整流,常规路径不需要。
/// - 保持与 `merge_tool_results` / `sanitize_orphan_tool_results` 一致的"消费 body、
/// 返回新 body"签名,便于接入 forwarder 管道。
pub fn strip_thinking_blocks(mut body: Value) -> Value {
let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) else {
return body;
};
for msg in messages.iter_mut() {
if msg.get("role").and_then(|r| r.as_str()) != Some("assistant") {
continue;
}
let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) else {
continue;
};
content.retain(|block| {
!matches!(
block.get("type").and_then(|t| t.as_str()),
Some("thinking") | Some("redacted_thinking")
)
});
}
body
}
// ─── 内部辅助 ─────────────────────────────────
/// 从请求体的 `system` 字段提取文本(处理 string/array 两种格式)。
@@ -1371,4 +1406,138 @@ mod tests {
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "text");
}
// === strip_thinking_blocks 测试 ===
#[test]
fn test_strip_thinking_removes_assistant_thinking_blocks() {
let body = serde_json::json!({
"messages": [
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
{"role": "assistant", "content": [
{"type": "thinking", "thinking": "let me ponder", "signature": "sig"},
{"type": "redacted_thinking", "data": "opaque"},
{"type": "text", "text": "hello"},
{"type": "tool_use", "id": "t1", "name": "read", "input": {}}
]}
]
});
let result = strip_thinking_blocks(body);
let content = result["messages"][1]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "tool_use");
}
#[test]
fn test_strip_thinking_leaves_user_messages_untouched() {
// 仅处理 assistantuser 的 thinking 块(极少见,但可能)不动
let body = serde_json::json!({
"messages": [
{"role": "user", "content": [
{"type": "thinking", "thinking": "x"},
{"type": "text", "text": "hi"}
]}
]
});
let result = strip_thinking_blocks(body);
let content = result["messages"][0]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
}
#[test]
fn test_strip_thinking_handles_missing_messages() {
let body = serde_json::json!({ "model": "claude-3-5-sonnet" });
let result = strip_thinking_blocks(body.clone());
assert_eq!(result, body);
}
#[test]
fn test_strip_thinking_leaves_empty_content_array() {
// 仅含 thinking 的 assistant 消息剥完后 content 为空——保留上游自处理
let body = serde_json::json!({
"messages": [
{"role": "assistant", "content": [
{"type": "thinking", "thinking": "solo"}
]}
]
});
let result = strip_thinking_blocks(body);
let content = result["messages"][0]["content"].as_array().unwrap();
assert_eq!(content.len(), 0);
}
#[test]
fn test_strip_thinking_preserves_signature_on_non_thinking_blocks() {
// signature 留给 thinking_rectifier 在错误路径处理,此处不动
let body = serde_json::json!({
"messages": [
{"role": "assistant", "content": [
{"type": "tool_use", "id": "t1", "name": "x", "input": {}, "signature": "s"}
]}
]
});
let result = strip_thinking_blocks(body);
let block = &result["messages"][0]["content"][0];
assert_eq!(block["signature"], "s");
}
#[test]
fn test_strip_thinking_multiple_assistant_turns() {
let body = serde_json::json!({
"messages": [
{"role": "user", "content": [{"type": "text", "text": "q1"}]},
{"role": "assistant", "content": [
{"type": "thinking", "thinking": "a"},
{"type": "text", "text": "r1"}
]},
{"role": "user", "content": [{"type": "text", "text": "q2"}]},
{"role": "assistant", "content": [
{"type": "redacted_thinking", "data": "x"},
{"type": "text", "text": "r2"}
]}
]
});
let result = strip_thinking_blocks(body);
let a1 = result["messages"][1]["content"].as_array().unwrap();
let a2 = result["messages"][3]["content"].as_array().unwrap();
assert_eq!(a1.len(), 1);
assert_eq!(a1[0]["text"], "r1");
assert_eq!(a2.len(), 1);
assert_eq!(a2[0]["text"], "r2");
}
#[test]
fn test_strip_thinking_ignores_string_content() {
// assistant.content 是字符串而非 block 数组 — 历史请求或极简客户端会这样
// 不应崩溃,也不应转换结构
let body = serde_json::json!({
"messages": [
{"role": "assistant", "content": "plain text response"}
]
});
let result = strip_thinking_blocks(body.clone());
assert_eq!(result, body);
}
#[test]
fn test_strip_thinking_preserves_block_order() {
let body = serde_json::json!({
"messages": [
{"role": "assistant", "content": [
{"type": "thinking", "thinking": "pre"},
{"type": "text", "text": "A"},
{"type": "tool_use", "id": "t1", "name": "x", "input": {}},
{"type": "redacted_thinking", "data": "mid"},
{"type": "text", "text": "B"}
]}
]
});
let result = strip_thinking_blocks(body);
let content = result["messages"][0]["content"].as_array().unwrap();
assert_eq!(content.len(), 3);
assert_eq!(content[0]["text"], "A");
assert_eq!(content[1]["type"], "tool_use");
assert_eq!(content[2]["text"], "B");
}
}
+1 -1
View File
@@ -111,7 +111,7 @@ impl FailoverSwitchManager {
}
if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("[Failover] 更新托盘菜单失败: {e}");
}
+497 -61
View File
@@ -7,6 +7,7 @@ use super::{
body_filter::filter_private_params_with_whitelist,
error::*,
failover_switch::FailoverSwitchManager,
json_canonical::{canonicalize_value, short_value_hash},
log_codes::fwd as log_fwd,
provider_router::ProviderRouter,
providers::{
@@ -55,6 +56,8 @@ pub struct RequestForwarder {
current_provider_id_at_start: String,
/// 代理会话 ID(用于 Gemini Native shadow replay
session_id: String,
/// Session ID 是否由客户端提供;生成值不能作为上游缓存身份。
session_client_provided: bool,
/// 整流器配置
rectifier_config: RectifierConfig,
/// 优化器配置
@@ -63,6 +66,8 @@ pub struct RequestForwarder {
copilot_optimizer_config: CopilotOptimizerConfig,
/// 非流式请求超时(秒)
non_streaming_timeout: std::time::Duration,
/// 流式请求响应头等待超时(秒)
streaming_first_byte_timeout: std::time::Duration,
}
impl RequestForwarder {
@@ -77,7 +82,8 @@ impl RequestForwarder {
app_handle: Option<tauri::AppHandle>,
current_provider_id_at_start: String,
session_id: String,
_streaming_first_byte_timeout: u64,
session_client_provided: bool,
streaming_first_byte_timeout: u64,
_streaming_idle_timeout: u64,
rectifier_config: RectifierConfig,
optimizer_config: OptimizerConfig,
@@ -92,13 +98,51 @@ impl RequestForwarder {
app_handle,
current_provider_id_at_start,
session_id,
session_client_provided,
rectifier_config,
optimizer_config,
copilot_optimizer_config,
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
streaming_first_byte_timeout: std::time::Duration::from_secs(
streaming_first_byte_timeout,
),
}
}
async fn record_success_result(
&self,
provider_id: &str,
app_type: &str,
used_half_open_permit: bool,
) {
if used_half_open_permit {
if let Err(e) = self
.router
.record_result(provider_id, app_type, true, true, None)
.await
{
log::warn!(
"[{app_type}] 记录 Provider 成功结果失败: provider_id={provider_id}, error={e}"
);
}
return;
}
let router = self.router.clone();
let provider_id = provider_id.to_string();
let app_type = app_type.to_string();
tokio::spawn(async move {
if let Err(e) = router
.record_result(&provider_id, &app_type, false, true, None)
.await
{
log::warn!(
"[{app_type}] 异步记录 Provider 成功结果失败: provider_id={provider_id}, error={e}"
);
}
});
}
/// 转发请求(带故障转移)
///
/// # Arguments
@@ -186,6 +230,7 @@ impl RequestForwarder {
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
match self
.forward(
app_type,
provider,
endpoint,
&provider_body,
@@ -196,16 +241,9 @@ impl RequestForwarder {
.await
{
Ok((response, claude_api_format)) => {
// 成功:记录成功并更新熔断器
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
// 成功:普通闭合熔断状态异步记录,避免阻塞流式首包返回;
// HalfOpen 探测仍同步等待,保证 permit 与熔断状态及时释放。
self.record_success_result(&provider.id, app_type_str, used_half_open_permit)
.await;
// 更新当前应用类型使用的 provider
@@ -316,6 +354,7 @@ impl RequestForwarder {
// 使用同一供应商重试(不计入熔断器)
match self
.forward(
app_type,
provider,
endpoint,
&provider_body,
@@ -327,17 +366,12 @@ impl RequestForwarder {
{
Ok((response, claude_api_format)) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
// 记录成功
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await;
self.record_success_result(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
// 更新当前应用类型使用的 provider
{
@@ -515,6 +549,7 @@ impl RequestForwarder {
// 使用同一供应商重试(不计入熔断器)
match self
.forward(
app_type,
provider,
endpoint,
&provider_body,
@@ -526,16 +561,12 @@ impl RequestForwarder {
{
Ok((response, claude_api_format)) => {
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await;
self.record_success_result(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
{
let mut current_providers =
@@ -752,8 +783,10 @@ impl RequestForwarder {
}
/// 转发单个请求(使用适配器)
#[allow(clippy::too_many_arguments)]
async fn forward(
&self,
app_type: &AppType,
provider: &Provider,
endpoint: &str,
body: &Value,
@@ -771,8 +804,16 @@ impl RequestForwarder {
.unwrap_or(false);
// 应用模型映射(独立于格式转换)
let (mapped_body, _original_model, _mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
// Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route
// 映射成真实上游模型名,并且未知 route 要直接报错,不能使用默认模型兜底。
let mapped_body = if matches!(app_type, AppType::ClaudeDesktop) {
crate::claude_desktop_config::map_proxy_request_model(body.clone(), provider)
.map_err(|e| ProxyError::InvalidRequest(e.to_string()))?
} else {
let (mapped_body, _original_model, _mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
mapped_body
};
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
let mut mapped_body = normalize_thinking_type(mapped_body);
@@ -786,6 +827,13 @@ impl RequestForwarder {
== Some("github_copilot")
|| base_url.contains("githubcopilot.com");
if is_copilot {
mapped_body =
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
self.apply_copilot_live_model_resolution(provider, &mut mapped_body)
.await;
}
// --- Copilot 优化器:分类 + 请求体优化(在格式转换之前执行) ---
// 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move
//
@@ -821,6 +869,12 @@ impl RequestForwarder {
mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body);
}
// 3.5. 主动剥离 thinking block — Copilot 走 OpenAI 兼容端点不识别该块
// 避免上游拒绝后由 rectifier 反应式重试(首次请求已消耗 quota)
if self.copilot_optimizer_config.strip_thinking {
mapped_body = super::copilot_optimizer::strip_thinking_blocks(mapped_body);
}
// 4. Warmup 小模型降级
if self.copilot_optimizer_config.warmup_downgrade && classification.is_warmup {
log::info!(
@@ -960,7 +1014,8 @@ impl RequestForwarder {
mapped_body,
provider,
api_format,
Some(&self.session_id),
self.session_client_provided
.then_some(self.session_id.as_str()),
Some(self.gemini_shadow.as_ref()),
)?
} else {
@@ -972,12 +1027,21 @@ impl RequestForwarder {
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
// 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
let filtered_body = prepare_upstream_request_body(request_body);
log_prompt_cache_trace(
app_type,
provider,
&effective_endpoint,
resolved_claude_api_format.as_deref(),
&filtered_body,
self.session_client_provided,
);
let force_identity_encoding = needs_transform
|| should_force_identity_encoding(&effective_endpoint, &filtered_body, headers);
// Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充)
let mut codex_oauth_account_id: Option<String> = None;
let mut should_send_codex_oauth_session_headers = false;
// 获取认证头(提前准备,用于内联替换)
let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
@@ -1059,6 +1123,7 @@ impl RequestForwarder {
match token_result {
Ok(token) => {
auth = AuthInfo::new(token, AuthStrategy::CodexOAuth);
should_send_codex_oauth_session_headers = true;
// 解析使用的 account_id(用于注入 ChatGPT-Account-Id header
codex_oauth_account_id = match account_id {
Some(id) => Some(id),
@@ -1096,6 +1161,13 @@ impl RequestForwarder {
}
}
let codex_oauth_session_headers =
if should_send_codex_oauth_session_headers && self.session_client_provided {
build_codex_oauth_session_headers(&self.session_id)
} else {
Vec::new()
};
// --- Copilot 优化器:动态 header 注入 ---
if let Some((ref classification, ref det_request_id, ref interaction_id)) =
copilot_optimization
@@ -1347,6 +1419,12 @@ impl RequestForwarder {
);
}
// Codex OAuth 反代尽量对齐官方 Codex CLI 的会话路由信号。
// 只发送客户端提供的 session_id;生成的 UUID 每次不同,反而会破坏前缀缓存。
for (name, value) in codex_oauth_session_headers {
ordered_headers.insert(name, value);
}
// 序列化请求体
let body_bytes = serde_json::to_vec(&filtered_body)
.map_err(|e| ProxyError::Internal(format!("Failed to serialize request body: {e}")))?;
@@ -1366,12 +1444,14 @@ impl RequestForwarder {
.and_then(|v| v.as_str())
.unwrap_or("<none>");
log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})");
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
log::debug!(
"[{tag}] >>> 请求体内容 ({}字节): {}",
body_str.len(),
body_str
);
if log::log_enabled!(log::Level::Debug) {
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
log::debug!(
"[{tag}] >>> 请求体内容 ({}字节): {}",
body_str.len(),
body_str
);
}
}
// 确定超时
@@ -1390,35 +1470,60 @@ impl RequestForwarder {
.map(|u| u.starts_with("socks5"))
.unwrap_or(false);
let uri: http::Uri = url
.parse()
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?;
let preserve_exact_header_case = should_preserve_exact_header_case(
adapter.name(),
provider,
resolved_claude_api_format.as_deref(),
is_copilot,
);
// 发送请求
let response = if is_socks_proxy {
// SOCKS5 代理:只能走 reqwest(不支持 header case 保留)
log::debug!("[Forwarder] Using reqwest for SOCKS5 proxy");
let response = if is_socks_proxy || !preserve_exact_header_case {
// OpenAI / Copilot / Codex 类后端不依赖原始 header 大小写;走 reqwest
// 连接池,避免 raw TCP/TLS path 每次请求都重新握手。SOCKS5 也只能走 reqwest。
log::debug!(
"[Forwarder] Using pooled reqwest client (preserve_exact_header_case={preserve_exact_header_case}, socks_proxy={is_socks_proxy})"
);
let client = super::http_client::get();
let mut request = client.post(&url);
if !self.non_streaming_timeout.is_zero() {
let request_is_streaming =
is_streaming_request(&effective_endpoint, &filtered_body, headers);
if request_is_streaming {
// reqwest 的 timeout 是整请求超时;流式请求交给 response_processor
// 的首包/静默期超时控制,避免长流被总时长误杀。
request = request.timeout(std::time::Duration::from_secs(24 * 60 * 60));
} else if !self.non_streaming_timeout.is_zero() {
request = request.timeout(self.non_streaming_timeout);
}
for (key, value) in &ordered_headers {
request = request.header(key, value);
}
let reqwest_resp = request.body(body_bytes).send().await.map_err(|e| {
if e.is_timeout() {
ProxyError::Timeout(format!("请求超时: {e}"))
} else if e.is_connect() {
ProxyError::ForwardFailed(format!("连接失败: {e}"))
let send = request.body(body_bytes).send();
let send_result = if request_is_streaming {
let header_timeout = if self.streaming_first_byte_timeout.is_zero() {
timeout
} else {
ProxyError::ForwardFailed(e.to_string())
}
})?;
self.streaming_first_byte_timeout
};
tokio::time::timeout(header_timeout, send)
.await
.map_err(|_| {
ProxyError::Timeout(format!(
"流式响应首包超时: {}s(上游未返回响应头)",
header_timeout.as_secs()
))
})?
} else {
send.await
};
let reqwest_resp = send_result.map_err(map_reqwest_send_error)?;
ProxyResponse::Reqwest(reqwest_resp)
} else {
// HTTP 代理或直连:走 hyper raw write(保持 header 大小写)
// 如果有 HTTP 代理,hyper_client 会用 CONNECT 隧道穿过代理
let uri: http::Uri = url
.parse()
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?;
super::hyper_client::send_request(
uri,
http::Method::POST,
@@ -1470,6 +1575,49 @@ impl RequestForwarder {
"openai_chat".to_string()
}
/// 用 Copilot live `/models` 列表确认 model ID 真实可用,找不到时按 family 降级。
/// 命中缓存后是同步的;首次请求或 5 min 缓存过期后会触发一次 HTTP。
async fn apply_copilot_live_model_resolution(
&self,
provider: &Provider,
body: &mut serde_json::Value,
) {
let Some(model_id) = body.get("model").and_then(|v| v.as_str()) else {
return;
};
let model_id = model_id.to_string();
let Some(app_handle) = &self.app_handle else {
return;
};
let copilot_state = app_handle.state::<CopilotAuthState>();
let copilot_auth = copilot_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|m| m.managed_account_id_for("github_copilot"));
let models_result = match account_id.as_deref() {
Some(id) => copilot_auth.fetch_models_for_account(id).await,
None => copilot_auth.fetch_models().await,
};
let models = match models_result {
Ok(m) => m,
Err(err) => {
log::debug!("[Copilot] live model list unavailable, skip resolution: {err}");
return;
}
};
if let Some(resolved) =
super::providers::copilot_model_map::resolve_against_models(&model_id, &models)
{
log::info!("[Copilot] live-model resolve: {model_id} → {resolved}");
body["model"] = serde_json::Value::String(resolved);
}
}
async fn is_copilot_openai_vendor_model(&self, provider: &Provider, model_id: &str) -> bool {
let Some(app_handle) = &self.app_handle else {
log::debug!("[Copilot] AppHandle unavailable, fallback to chat/completions");
@@ -1824,11 +1972,46 @@ fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String {
}
}
fn should_force_identity_encoding(
endpoint: &str,
body: &Value,
headers: &axum::http::HeaderMap,
fn build_codex_oauth_session_headers(
session_id: &str,
) -> Vec<(http::HeaderName, http::HeaderValue)> {
let session_id = session_id.trim();
if session_id.is_empty() {
return Vec::new();
}
let mut headers = Vec::new();
if let Ok(value) = http::HeaderValue::from_str(session_id) {
headers.push((http::HeaderName::from_static("session_id"), value.clone()));
headers.push((http::HeaderName::from_static("x-client-request-id"), value));
}
let window_id = format!("{session_id}:0");
if let Ok(value) = http::HeaderValue::from_str(&window_id) {
headers.push((http::HeaderName::from_static("x-codex-window-id"), value));
}
headers
}
fn should_preserve_exact_header_case(
adapter_name: &str,
provider: &Provider,
resolved_claude_api_format: Option<&str>,
is_copilot: bool,
) -> bool {
if matches!(adapter_name, "Codex" | "Gemini") {
return false;
}
if is_copilot || provider.is_codex_oauth() {
return false;
}
matches!(resolved_claude_api_format, None | Some("anthropic"))
}
fn is_streaming_request(endpoint: &str, body: &Value, headers: &axum::http::HeaderMap) -> bool {
if body
.get("stream")
.and_then(|value| value.as_bool())
@@ -1848,6 +2031,24 @@ fn should_force_identity_encoding(
.unwrap_or(false)
}
fn should_force_identity_encoding(
endpoint: &str,
body: &Value,
headers: &axum::http::HeaderMap,
) -> bool {
is_streaming_request(endpoint, body, headers)
}
fn map_reqwest_send_error(error: reqwest::Error) -> ProxyError {
if error.is_timeout() {
ProxyError::Timeout(format!("请求超时: {error}"))
} else if error.is_connect() {
ProxyError::ForwardFailed(format!("连接失败: {error}"))
} else {
ProxyError::ForwardFailed(error.to_string())
}
}
fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
let trimmed = normalized.trim();
@@ -1861,6 +2062,65 @@ fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
format!("{truncated}...")
}
fn prepare_upstream_request_body(request_body: Value) -> Value {
canonicalize_value(filter_private_params_with_whitelist(request_body, &[]))
}
fn log_prompt_cache_trace(
app_type: &AppType,
provider: &Provider,
endpoint: &str,
api_format: Option<&str>,
body: &Value,
session_client_provided: bool,
) {
if !log::log_enabled!(log::Level::Debug) {
return;
}
let prompt_cache_key = body
.get("prompt_cache_key")
.and_then(|value| value.as_str())
.map(|key| format!("present(len={})", key.len()))
.unwrap_or_else(|| "absent".to_string());
let store = body
.get("store")
.map(value_for_log)
.unwrap_or_else(|| "absent".to_string());
let stream = body
.get("stream")
.map(value_for_log)
.unwrap_or_else(|| "absent".to_string());
log::debug!(
"[CacheTrace] app={}, provider={}, endpoint={}, api_format={}, session_client_provided={}, prompt_cache_key={}, store={}, stream={}, instructions_hash={}, tools_hash={}, input_hash={}, include_hash={}, body_hash={}",
app_type.as_str(),
provider.id,
endpoint,
api_format.unwrap_or("native"),
session_client_provided,
prompt_cache_key,
store,
stream,
short_value_hash(body.get("instructions")),
short_value_hash(body.get("tools")),
short_value_hash(body.get("input")),
short_value_hash(body.get("include")),
short_value_hash(Some(body)),
);
}
fn value_for_log(value: &Value) -> String {
match value {
Value::Bool(value) => value.to_string(),
Value::Number(value) => value.to_string(),
Value::String(value) => value.clone(),
Value::Null => "null".to_string(),
Value::Array(values) => format!("array(len={})", values.len()),
Value::Object(values) => format!("object(len={})", values.len()),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1868,6 +2128,26 @@ mod tests {
use axum::http::HeaderMap;
use serde_json::json;
fn test_provider_with_type(provider_type: Option<&str>) -> Provider {
Provider {
id: "provider-1".to_string(),
name: "Provider 1".to_string(),
settings_config: json!({}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: provider_type.map(|value| crate::provider::ProviderMeta {
provider_type: Some(value.to_string()),
..Default::default()
}),
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn single_provider_retryable_log_uses_single_provider_code() {
let error = ProxyError::UpstreamError {
@@ -1933,6 +2213,151 @@ mod tests {
assert_eq!(summary, "line1 line2...");
}
#[test]
fn canonical_json_sorts_object_keys_for_cache_trace_hashes() {
let left = json!({
"tools": [
{
"parameters": {
"properties": {
"b": {"type": "string"},
"a": {"type": "number"}
},
"type": "object"
},
"name": "lookup"
}
]
});
let right = json!({
"tools": [
{
"name": "lookup",
"parameters": {
"type": "object",
"properties": {
"a": {"type": "number"},
"b": {"type": "string"}
}
}
}
]
});
assert_eq!(
crate::proxy::json_canonical::canonical_json_string(&left),
crate::proxy::json_canonical::canonical_json_string(&right)
);
assert_eq!(
short_value_hash(Some(&left)),
short_value_hash(Some(&right))
);
}
#[test]
fn prepare_upstream_request_body_filters_private_fields_and_canonicalizes_order() {
let body = json!({
"z": 1,
"_internal": "drop",
"tools": [
{
"name": "lookup",
"parameters": {
"type": "object",
"properties": {
"_id": {
"_private_note": "drop",
"type": "string"
},
"b": {"type": "number"},
"a": {"type": "string"}
}
}
}
],
"a": 2
});
let prepared = prepare_upstream_request_body(body);
assert!(prepared.get("_internal").is_none());
assert!(prepared["tools"][0]["parameters"]["properties"]
.get("_id")
.is_some());
assert!(prepared["tools"][0]["parameters"]["properties"]["_id"]
.get("_private_note")
.is_none());
assert_eq!(
serde_json::to_string(&prepared).unwrap(),
r#"{"a":2,"tools":[{"name":"lookup","parameters":{"properties":{"_id":{"type":"string"},"a":{"type":"string"},"b":{"type":"number"}},"type":"object"}}],"z":1}"#
);
}
#[test]
fn codex_oauth_session_headers_match_codex_cache_identity() {
let headers = build_codex_oauth_session_headers("session-123");
let mut map = HeaderMap::new();
for (name, value) in headers {
map.insert(name, value);
}
assert_eq!(
map.get("session_id"),
Some(&HeaderValue::from_static("session-123"))
);
assert_eq!(
map.get("x-client-request-id"),
Some(&HeaderValue::from_static("session-123"))
);
assert_eq!(
map.get("x-codex-window-id"),
Some(&HeaderValue::from_static("session-123:0"))
);
}
#[test]
fn exact_header_case_preserved_for_native_claude_only() {
let provider = test_provider_with_type(None);
assert!(should_preserve_exact_header_case(
"Claude",
&provider,
Some("anthropic"),
false
));
assert!(!should_preserve_exact_header_case(
"Claude",
&provider,
Some("openai_responses"),
false
));
assert!(!should_preserve_exact_header_case(
"Codex", &provider, None, false
));
assert!(!should_preserve_exact_header_case(
"Gemini", &provider, None, false
));
}
#[test]
fn exact_header_case_skipped_for_codex_oauth_and_copilot() {
let codex_oauth = test_provider_with_type(Some("codex_oauth"));
let copilot = test_provider_with_type(Some("github_copilot"));
assert!(!should_preserve_exact_header_case(
"Claude",
&codex_oauth,
Some("openai_responses"),
false
));
assert!(!should_preserve_exact_header_case(
"Claude",
&copilot,
Some("openai_chat"),
true
));
}
#[test]
fn rewrite_claude_transform_endpoint_strips_beta_for_chat_completions() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
@@ -2098,6 +2523,17 @@ mod tests {
));
}
#[test]
fn streaming_request_detects_gemini_sse_without_body_stream_flag() {
let headers = HeaderMap::new();
assert!(is_streaming_request(
"/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse",
&json!({ "model": "gemini-2.5-pro" }),
&headers
));
}
#[test]
fn force_identity_for_sse_accept_header() {
let mut headers = HeaderMap::new();
+39
View File
@@ -81,6 +81,10 @@ fn should_normalize_gemini_full_url(base_url: &str) -> bool {
let path = path.trim_end_matches('/');
let on_google_host = is_google_gemini_host(extract_host(origin));
if matches_vertex_ai_publisher_model_path(path) {
return false;
}
// Unconditional layer: only paths whose grammar is *intrinsically*
// Gemini-specific — the `/models/...:generateContent` method-call
// shape and the deep OpenAI-compat endpoints (`/openai/chat/completions`,
@@ -238,6 +242,27 @@ fn matches_structured_gemini_models_path(path: &str) -> bool {
false
}
/// Vertex AI endpoint paths include project/location/publisher routing before
/// `models/*:generateContent`; in full-URL mode that routing is user-provided
/// and must not be collapsed into the public Gemini `/v1beta/models/*` shape.
fn matches_vertex_ai_publisher_model_path(path: &str) -> bool {
let Some(projects_index) = path.find("/projects/") else {
return false;
};
let Some(publisher_models_index) = path.find("/publishers/google/models/") else {
return false;
};
if projects_index >= publisher_models_index
|| !path[projects_index..publisher_models_index].contains("/locations/")
{
return false;
}
let after_model = &path[publisher_models_index + "/publishers/google/models/".len()..];
after_model.contains(":generateContent") || after_model.contains(":streamGenerateContent")
}
fn merge_queries(base_query: Option<&str>, endpoint_query: Option<&str>) -> Option<String> {
let parts: Vec<&str> = [base_query, endpoint_query]
.into_iter()
@@ -334,6 +359,20 @@ mod tests {
assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse");
}
#[test]
fn preserves_cloudflare_vertex_ai_full_url_with_action() {
let url = resolve_gemini_native_url(
"https://gateway.ai.cloudflare.com/v1/account/gateway/google-vertex-ai/v1/projects/project/locations/us-central1/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(
url,
"https://gateway.ai.cloudflare.com/v1/account/gateway/google-vertex-ai/v1/projects/project/locations/us-central1/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent?alt=sse"
);
}
#[test]
fn preserves_opaque_full_url_containing_models_segment() {
let url = resolve_gemini_native_url(
+32
View File
@@ -14,6 +14,12 @@ pub type ResponseUsageParser = fn(&Value) -> Option<TokenUsage>;
/// 参数: (流式事件列表, 请求中的模型名称) -> 最终使用的模型名称
pub type StreamModelExtractor = fn(&[Value], &str) -> String;
/// 流式 usage 事件预过滤器类型别名。
///
/// 参数是 SSE `data:` 原始字符串。返回 false 时跳过 JSON parse,避免在
/// token/chunk 高频路径上解析与 usage 无关的事件。
pub type StreamUsageEventFilter = fn(&str) -> bool;
/// 各 API 的使用量解析配置
#[derive(Clone, Copy)]
pub struct UsageParserConfig {
@@ -23,10 +29,32 @@ pub struct UsageParserConfig {
pub response_parser: ResponseUsageParser,
/// 流式响应中的模型提取器
pub model_extractor: StreamModelExtractor,
/// 流式 usage 事件预过滤器
pub stream_event_filter: Option<StreamUsageEventFilter>,
/// 应用类型字符串(用于日志记录)
pub app_type_str: &'static str,
}
// ============================================================================
// 流式 usage 事件预过滤
// ============================================================================
pub fn claude_stream_usage_event_filter(data: &str) -> bool {
data.contains("\"message_start\"") || data.contains("\"message_delta\"")
}
fn openai_stream_usage_event_filter(data: &str) -> bool {
data.contains("\"usage\"")
}
fn codex_stream_usage_event_filter(data: &str) -> bool {
data.contains("\"response.completed\"") || data.contains("\"usage\"")
}
fn gemini_stream_usage_event_filter(data: &str) -> bool {
data.contains("\"usageMetadata\"")
}
// ============================================================================
// 模型提取器实现
// ============================================================================
@@ -104,6 +132,7 @@ pub const CLAUDE_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_claude_stream_events,
response_parser: TokenUsage::from_claude_response,
model_extractor: claude_model_extractor,
stream_event_filter: Some(claude_stream_usage_event_filter),
app_type_str: "claude",
};
@@ -112,6 +141,7 @@ pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_openai_stream_events,
response_parser: TokenUsage::from_openai_response,
model_extractor: openai_model_extractor,
stream_event_filter: Some(openai_stream_usage_event_filter),
app_type_str: "codex",
};
@@ -120,6 +150,7 @@ pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_codex_stream_events_auto,
response_parser: TokenUsage::from_codex_response_auto,
model_extractor: codex_auto_model_extractor,
stream_event_filter: Some(codex_stream_usage_event_filter),
app_type_str: "codex",
};
@@ -128,6 +159,7 @@ pub const GEMINI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_gemini_stream_chunks,
response_parser: TokenUsage::from_gemini_response,
model_extractor: gemini_model_extractor,
stream_event_filter: Some(gemini_stream_usage_event_filter),
app_type_str: "gemini",
};
+4
View File
@@ -57,6 +57,8 @@ pub struct RequestContext {
pub app_type: AppType,
/// Session ID(从客户端请求提取或新生成)
pub session_id: String,
/// Session ID 是否由客户端提供。生成的 UUID 不能作为上游缓存 key,否则每个请求都会换 key。
pub session_client_provided: bool,
/// 整流器配置
pub rectifier_config: RectifierConfig,
/// 优化器配置
@@ -161,6 +163,7 @@ impl RequestContext {
app_type_str,
app_type,
session_id,
session_client_provided: session_result.client_provided,
rectifier_config,
optimizer_config,
copilot_optimizer_config,
@@ -223,6 +226,7 @@ impl RequestContext {
state.app_handle.clone(),
self.current_provider_id.clone(),
self.session_id.clone(),
self.session_client_provided,
first_byte_timeout,
idle_timeout,
self.rectifier_config.clone(),
+330 -40
View File
@@ -10,7 +10,8 @@
use super::{
error_mapper::{get_error_message, map_proxy_error_to_status},
handler_config::{
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
claude_stream_usage_event_filter, CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG,
GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
},
handler_context::RequestContext,
providers::{
@@ -22,9 +23,10 @@ use super::{
response_processor::{
create_logged_passthrough_stream, process_response, read_decoded_body,
strip_entity_headers_for_rebuilt_body, strip_hop_by_hop_response_headers,
SseUsageCollector,
usage_logging_enabled, SseUsageCollector,
},
server::ProxyState,
sse::{strip_sse_field, take_sse_block},
types::*,
usage::parser::TokenUsage,
ProxyError,
@@ -68,6 +70,49 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
pub async fn handle_messages(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_messages_for_app(state, request, AppType::Claude, "Claude", "claude", None).await
}
pub async fn handle_claude_desktop_messages(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
validate_claude_desktop_gateway_auth(&state, request.headers())?;
handle_messages_for_app(
state,
request,
AppType::ClaudeDesktop,
"Claude Desktop",
"claude-desktop",
Some("/claude-desktop"),
)
.await
}
pub async fn handle_claude_desktop_models(
State(state): State<ProxyState>,
headers: axum::http::HeaderMap,
) -> Result<Json<Value>, ProxyError> {
validate_claude_desktop_gateway_auth(&state, &headers)?;
let providers = state
.provider_router
.select_providers("claude-desktop")
.await
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
let provider = providers.first().ok_or(ProxyError::NoAvailableProvider)?;
let response = crate::claude_desktop_config::model_list_response(provider)
.map_err(|e| ProxyError::ConfigError(e.to_string()))?;
Ok(Json(response))
}
async fn handle_messages_for_app(
state: ProxyState,
request: axum::extract::Request,
app_type: AppType,
tag: &'static str,
app_type_str: &'static str,
strip_prefix: Option<&'static str>,
) -> Result<axum::response::Response, ProxyError> {
let (parts, body) = request.into_parts();
let uri = parts.uri;
@@ -82,12 +127,15 @@ pub async fn handle_messages(
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
let endpoint = uri
let raw_endpoint = uri
.path_and_query()
.map(|path_and_query| path_and_query.as_str())
.unwrap_or(uri.path());
let endpoint = strip_prefix
.and_then(|prefix| raw_endpoint.strip_prefix(prefix))
.unwrap_or(raw_endpoint);
let is_stream = body
.get("stream")
@@ -98,7 +146,7 @@ pub async fn handle_messages(
let forwarder = ctx.create_forwarder(&state);
let result = match forwarder
.forward_with_retry(
&AppType::Claude,
&app_type,
endpoint,
body.clone(),
headers,
@@ -126,7 +174,7 @@ pub async fn handle_messages(
let response = result.response;
// 检查是否需要格式转换(OpenRouter 等中转服务)
let adapter = get_adapter(&AppType::Claude);
let adapter = get_adapter(&app_type);
let needs_transform = adapter.needs_transform(&ctx.provider);
// Claude 特有:格式转换处理
@@ -139,6 +187,33 @@ pub async fn handle_messages(
process_response(response, &ctx, &state, &CLAUDE_PARSER_CONFIG).await
}
fn validate_claude_desktop_gateway_auth(
state: &ProxyState,
headers: &axum::http::HeaderMap,
) -> Result<(), ProxyError> {
let expected = crate::claude_desktop_config::get_or_create_gateway_token(state.db.as_ref())
.map_err(|e| ProxyError::AuthError(e.to_string()))?;
let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else {
return Err(ProxyError::AuthError(
"Claude Desktop gateway 缺少 Authorization 头".to_string(),
));
};
let value = value
.to_str()
.map_err(|_| ProxyError::AuthError("Authorization 头格式无效".to_string()))?;
let token = value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
.unwrap_or("")
.trim();
if token != expected {
return Err(ProxyError::AuthError(
"Claude Desktop gateway token 无效".to_string(),
));
}
Ok(())
}
/// Claude 格式转换处理(独有逻辑)
///
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
@@ -151,10 +226,33 @@ async fn handle_claude_transform(
api_format: &str,
) -> Result<axum::response::Response, ProxyError> {
let status = response.status();
let is_codex_oauth = ctx
.provider
.meta
.as_ref()
.and_then(|meta| meta.provider_type.as_deref())
== Some("codex_oauth");
// Codex OAuth 会把 openai_responses 响应强制升级为 SSE,即使客户端发的是 stream:false。
// should_use_claude_transform_streaming 默认会把这个组合路由到流式转换器——虽然能避免
// JSON parse 报 422,但会让非流客户端收到 text/event-stream,违反 Anthropic 非流语义。
// 这里为这个特定组合打开 override:把上游 SSE 聚合成 Anthropic JSON 回给客户端,其它
// 场景(任意上游 is_sse、非 Codex OAuth 等)仍沿用原有流式兜底。
let aggregate_codex_oauth_responses_sse =
!is_stream && is_codex_oauth && api_format == "openai_responses";
let use_streaming = if aggregate_codex_oauth_responses_sse {
false
} else {
should_use_claude_transform_streaming(
is_stream,
response.is_sse(),
api_format,
is_codex_oauth,
)
};
let tool_schema_hints = transform_gemini::extract_anthropic_tool_schema_hints(original_body);
let tool_schema_hints = (!tool_schema_hints.is_empty()).then_some(tool_schema_hints);
if is_stream {
if use_streaming {
// 根据 api_format 选择流式转换器
let stream = response.bytes_stream();
let sse_stream: Box<
@@ -173,40 +271,49 @@ async fn handle_claude_transform(
Box::new(Box::pin(create_anthropic_sse_stream(stream)))
};
// 创建使用量收集器
let usage_collector = {
// 创建使用量收集器;关闭 usage logging 时不要再解析转换后的 SSE。
let usage_collector = if usage_logging_enabled(state) {
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let model = ctx.request_model.clone();
let status_code = status.as_u16();
let start_time = ctx.start_time;
let session_id = ctx.session_id.clone();
SseUsageCollector::new(start_time, move |events, first_token_ms| {
if let Some(usage) = TokenUsage::from_claude_stream_events(&events) {
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let model = model.clone();
Some(SseUsageCollector::new(
start_time,
Some(claude_stream_usage_event_filter),
move |events, first_token_ms| {
if let Some(usage) = TokenUsage::from_claude_stream_events(&events) {
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let model = model.clone();
let session_id = session_id.clone();
tokio::spawn(async move {
log_usage(
&state,
&provider_id,
"claude",
&model,
&model,
usage,
latency_ms,
first_token_ms,
true,
status_code,
)
.await;
});
} else {
log::debug!("[Claude] OpenRouter 流式响应缺少 usage 统计,跳过消费记录");
}
})
tokio::spawn(async move {
log_usage(
&state,
&provider_id,
"claude",
&model,
&model,
usage,
latency_ms,
first_token_ms,
true,
status_code,
Some(session_id),
)
.await;
});
} else {
log::debug!("[Claude] OpenRouter 流式响应缺少 usage 统计,跳过消费记录");
}
},
))
} else {
None
};
// 获取流式超时配置
@@ -215,7 +322,7 @@ async fn handle_claude_transform(
let logged_stream = create_logged_passthrough_stream(
sse_stream,
"Claude/OpenRouter",
Some(usage_collector),
usage_collector,
timeout_config,
);
@@ -245,10 +352,14 @@ async fn handle_claude_transform(
let body_str = String::from_utf8_lossy(&body_bytes);
let upstream_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}");
ProxyError::TransformError(format!("Failed to parse upstream response: {e}"))
})?;
let upstream_response: Value = if aggregate_codex_oauth_responses_sse {
responses_sse_to_response_value(&body_str)?
} else {
serde_json::from_slice(&body_bytes).map_err(|e| {
log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}");
ProxyError::TransformError(format!("Failed to parse upstream response: {e}"))
})?
};
// 根据 api_format 选择非流式转换器
let anthropic_response = if api_format == "openai_responses" {
@@ -282,6 +393,7 @@ async fn handle_claude_transform(
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let model = model.to_string();
let session_id = ctx.session_id.clone();
async move {
log_usage(
&state,
@@ -294,6 +406,7 @@ async fn handle_claude_transform(
None,
false,
status.as_u16(),
Some(session_id),
)
.await;
}
@@ -561,6 +674,87 @@ pub async fn handle_gemini(
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
}
fn should_use_claude_transform_streaming(
requested_streaming: bool,
upstream_is_sse: bool,
api_format: &str,
is_codex_oauth: bool,
) -> bool {
requested_streaming || upstream_is_sse || (is_codex_oauth && api_format == "openai_responses")
}
/// 把 OpenAI Responses SSE 流聚合成一个完整的 Responses JSON 对象,供下游转成 Anthropic
/// 非流响应。仅在 Codex OAuth 把 `stream:false` 强制升级为 SSE 的场景下调用。
///
/// 复用 `proxy::sse` 的 `take_sse_block`/`strip_sse_field``take_sse_block` 同时支持
/// `\n\n` 与 `\r\n\r\n` 两种分隔符,`strip_sse_field` 兼容带/不带空格的字段写法。
fn responses_sse_to_response_value(body: &str) -> Result<Value, ProxyError> {
let mut buffer = body.to_string();
let mut completed_response: Option<Value> = None;
let mut output_items = Vec::new();
while let Some(block) = take_sse_block(&mut buffer) {
let mut event_name = "";
let mut data_lines: Vec<&str> = Vec::new();
for line in block.lines() {
if let Some(evt) = strip_sse_field(line, "event") {
event_name = evt.trim();
} else if let Some(d) = strip_sse_field(line, "data") {
data_lines.push(d);
}
}
if data_lines.is_empty() {
continue;
}
let data_str = data_lines.join("\n");
if data_str.trim() == "[DONE]" {
continue;
}
let data: Value = serde_json::from_str(&data_str).map_err(|e| {
ProxyError::TransformError(format!("Failed to parse upstream SSE event: {e}"))
})?;
match event_name {
"response.output_item.done" => {
if let Some(item) = data.get("item") {
output_items.push(item.clone());
}
}
"response.completed" => {
completed_response = Some(data.get("response").cloned().unwrap_or(data));
}
"response.failed" => {
let message = data
.pointer("/response/error/message")
.and_then(|v| v.as_str())
.unwrap_or("response.failed event received");
return Err(ProxyError::TransformError(message.to_string()));
}
_ => {}
}
}
let mut response = completed_response.ok_or_else(|| {
ProxyError::TransformError("No response.completed event in upstream SSE".to_string())
})?;
if !output_items.is_empty() {
if let Some(obj) = response.as_object_mut() {
obj.insert("output".to_string(), Value::Array(output_items));
} else {
return Err(ProxyError::TransformError(
"response.completed payload is not an object".to_string(),
));
}
}
Ok(response)
}
// ============================================================================
// 使用量记录(保留用于 Claude 转换逻辑)
// ============================================================================
@@ -607,9 +801,14 @@ async fn log_usage(
first_token_ms: Option<u64>,
is_streaming: bool,
status_code: u16,
session_id: Option<String>,
) {
use super::usage::logger::UsageLogger;
if !usage_logging_enabled(state) {
return;
}
let logger = UsageLogger::new(&state.db);
let (multiplier, pricing_model_source) =
@@ -634,10 +833,101 @@ async fn log_usage(
latency_ms,
first_token_ms,
status_code,
None,
session_id,
None, // provider_type
is_streaming,
) {
log::warn!("[USG-001] 记录使用量失败: {e}");
}
}
#[cfg(test)]
mod tests {
use super::{responses_sse_to_response_value, should_use_claude_transform_streaming};
use crate::proxy::ProxyError;
#[test]
fn codex_oauth_responses_force_streaming_even_if_client_sent_false() {
assert!(should_use_claude_transform_streaming(
false,
false,
"openai_responses",
true,
));
}
#[test]
fn upstream_sse_response_always_uses_streaming_path() {
assert!(should_use_claude_transform_streaming(
false,
true,
"openai_chat",
false,
));
}
#[test]
fn non_streaming_response_stays_non_streaming_for_regular_openai_responses() {
assert!(!should_use_claude_transform_streaming(
false,
false,
"openai_responses",
false,
));
}
#[test]
fn responses_sse_to_response_value_collects_output_items() {
let sse = r#"event: response.output_item.done
data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}}
event: response.completed
data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":10,"output_tokens":2}}}
"#;
let response = responses_sse_to_response_value(sse).unwrap();
assert_eq!(response["id"], "resp_1");
assert_eq!(response["output"][0]["type"], "message");
assert_eq!(response["output"][0]["content"][0]["text"], "hello");
}
#[test]
fn responses_sse_to_response_value_handles_crlf_delimiters() {
// 真实 HTTP SSE 按规范使用 \r\n\r\n 分隔事件;take_sse_block 必须同时处理两种分隔符,
// 否则此路径在任何标准上游(含 Codex OAuth HTTPS 后端)下都会 TransformError。
let sse = "event: response.output_item.done\r\n\
data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"hi\"}]}}\r\n\
\r\n\
event: response.completed\r\n\
data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_crlf\",\"status\":\"completed\",\"model\":\"gpt-5.4\",\"output\":[],\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}}\r\n\
\r\n";
let response = responses_sse_to_response_value(sse).unwrap();
assert_eq!(response["id"], "resp_crlf");
assert_eq!(response["output"][0]["type"], "message");
assert_eq!(response["output"][0]["content"][0]["text"], "hi");
}
#[test]
fn responses_sse_to_response_value_returns_err_on_response_failed() {
let sse = "event: response.failed\n\
data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"message\":\"upstream blew up\"}}}\n\n";
let err = responses_sse_to_response_value(sse).unwrap_err();
match err {
ProxyError::TransformError(msg) => assert!(msg.contains("upstream blew up")),
other => panic!("expected TransformError, got {other:?}"),
}
}
#[test]
fn responses_sse_to_response_value_errors_when_no_completed_event() {
let sse = "event: response.output_item.done\n\
data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\"}}\n\n";
assert!(responses_sse_to_response_value(sse).is_err());
}
}
+102
View File
@@ -0,0 +1,102 @@
//! Stable JSON helpers for cache-sensitive request bodies.
use serde_json::Value;
use sha2::{Digest, Sha256};
pub(crate) fn canonicalize_value(value: Value) -> Value {
match value {
Value::Array(values) => Value::Array(values.into_iter().map(canonicalize_value).collect()),
Value::Object(map) => {
let mut entries = map.into_iter().collect::<Vec<_>>();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
let mut sorted = serde_json::Map::new();
for (key, value) in entries {
sorted.insert(key, canonicalize_value(value));
}
Value::Object(sorted)
}
other => other,
}
}
pub(crate) fn canonical_json_string(value: &Value) -> String {
match value {
Value::Null => "null".to_string(),
Value::Bool(value) => value.to_string(),
Value::Number(value) => value.to_string(),
Value::String(value) => serde_json::to_string(value)
.expect("serializing a JSON string for canonical output should not fail"),
Value::Array(values) => {
let parts = values.iter().map(canonical_json_string).collect::<Vec<_>>();
format!("[{}]", parts.join(","))
}
Value::Object(map) => {
let mut entries = map.iter().collect::<Vec<_>>();
entries.sort_by_key(|(left, _)| *left);
let parts = entries
.into_iter()
.map(|(key, value)| {
let key = serde_json::to_string(key).expect(
"serializing a JSON object key for canonical output should not fail",
);
format!("{key}:{}", canonical_json_string(value))
})
.collect::<Vec<_>>();
format!("{{{}}}", parts.join(","))
}
}
}
pub(crate) fn short_value_hash(value: Option<&Value>) -> String {
let Some(value) = value else {
return "absent".to_string();
};
short_sha256_hex(canonical_json_string(value).as_bytes())
}
pub(crate) fn short_sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
digest
.iter()
.take(8)
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn canonical_json_string_sorts_nested_object_keys() {
let left = json!({
"b": 2,
"a": {
"d": true,
"c": [3, {"z": 1, "y": 2}]
}
});
let right = json!({
"a": {
"c": [3, {"y": 2, "z": 1}],
"d": true
},
"b": 2
});
assert_eq!(canonical_json_string(&left), canonical_json_string(&right));
assert_eq!(
short_value_hash(Some(&left)),
short_value_hash(Some(&right))
);
}
#[test]
fn canonicalize_value_sorts_map_storage_order() {
let value = canonicalize_value(json!({"b": 2, "a": 1}));
assert_eq!(serde_json::to_string(&value).unwrap(), r#"{"a":1,"b":2}"#);
}
}
+1
View File
@@ -17,6 +17,7 @@ mod handlers;
mod health;
pub mod http_client;
pub mod hyper_client;
pub(crate) mod json_canonical;
pub mod log_codes;
pub mod model_mapper;
pub mod provider_router;
+16 -107
View File
@@ -11,7 +11,6 @@ pub struct ModelMapping {
pub sonnet_model: Option<String>,
pub opus_model: Option<String>,
pub default_model: Option<String>,
pub reasoning_model: Option<String>,
}
impl ModelMapping {
@@ -40,11 +39,6 @@ impl ModelMapping {
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
reasoning_model: env
.and_then(|e| e.get("ANTHROPIC_REASONING_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
}
}
@@ -54,21 +48,13 @@ impl ModelMapping {
|| self.sonnet_model.is_some()
|| self.opus_model.is_some()
|| self.default_model.is_some()
|| self.reasoning_model.is_some()
}
/// 根据原始模型名称获取映射后的模型
pub fn map_model(&self, original_model: &str, has_thinking: bool) -> String {
pub fn map_model(&self, original_model: &str) -> String {
let model_lower = original_model.to_lowercase();
// 1. thinking 模式优先使用推理模型
if has_thinking {
if let Some(ref m) = self.reasoning_model {
return m.clone();
}
}
// 2. 按模型类型匹配
// 1. 按模型类型匹配
if model_lower.contains("haiku") {
if let Some(ref m) = self.haiku_model {
return m.clone();
@@ -85,35 +71,16 @@ impl ModelMapping {
}
}
// 3. 默认模型
// 2. 默认模型
if let Some(ref m) = self.default_model {
return m.clone();
}
// 4. 无映射,保持原样
// 3. 无映射,保持原样
original_model.to_string()
}
}
/// 检测请求是否启用了 thinking 模式
pub fn has_thinking_enabled(body: &Value) -> bool {
match body
.get("thinking")
.and_then(|v| v.as_object())
.and_then(|o| o.get("type"))
.and_then(|t| t.as_str())
{
Some("enabled") | Some("adaptive") => true,
Some("disabled") | None => false,
Some(other) => {
log::warn!(
"[ModelMapper] 未知 thinking.type='{other}',按 disabled 处理以避免误路由 reasoning 模型"
);
false
}
}
}
/// 对请求体应用模型映射
///
/// 返回 (映射后的请求体, 原始模型名, 映射后模型名)
@@ -133,8 +100,7 @@ pub fn apply_model_mapping(
let original_model = body.get("model").and_then(|m| m.as_str()).map(String::from);
if let Some(ref original) = original_model {
let has_thinking = has_thinking_enabled(&body);
let mapped = mapping.map_model(original, has_thinking);
let mapped = mapping.map_model(original);
if mapped != *original {
log::debug!("[ModelMapper] 模型映射: {original} → {mapped}");
@@ -160,8 +126,7 @@ mod tests {
"ANTHROPIC_MODEL": "default-model",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped",
"ANTHROPIC_REASONING_MODEL": "reasoning-model"
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped"
}
}),
website_url: None,
@@ -193,27 +158,6 @@ mod tests {
}
}
fn create_provider_with_reasoning_only() -> Provider {
Provider {
id: "test".to_string(),
name: "Test".to_string(),
settings_config: json!({
"env": {
"ANTHROPIC_REASONING_MODEL": "reasoning-only-model"
}
}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_sonnet_mapping() {
let provider = create_provider_with_mapping();
@@ -243,40 +187,29 @@ mod tests {
}
#[test]
fn test_thinking_mode() {
fn test_thinking_does_not_affect_model_mapping() {
// Issue #2081: thinking 参数不应影响模型映射
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "enabled"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "reasoning-model");
assert_eq!(mapped, Some("reasoning-model".to_string()));
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
fn test_reasoning_only_mapping_in_thinking_mode() {
let provider = create_provider_with_reasoning_only();
fn test_thinking_adaptive_does_not_affect_model_mapping() {
// Issue #2081: adaptive thinking 也不应影响模型映射
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "enabled"}
"thinking": {"type": "adaptive"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "reasoning-only-model");
assert_eq!(mapped, Some("reasoning-only-model".to_string()));
}
#[test]
fn test_reasoning_only_mapping_does_not_affect_non_thinking() {
let provider = create_provider_with_reasoning_only();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "disabled"}
});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "claude-sonnet-4-5");
assert_eq!(original, Some("claude-sonnet-4-5".to_string()));
assert!(mapped.is_none());
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
@@ -310,30 +243,6 @@ mod tests {
assert!(mapped.is_none());
}
#[test]
fn test_thinking_adaptive() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "adaptive"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "reasoning-model");
assert_eq!(mapped, Some("reasoning-model".to_string()));
}
#[test]
fn test_thinking_unknown_type() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "some_future_type"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
fn test_case_insensitive() {
let provider = create_provider_with_mapping();
+512 -21
View File
@@ -82,6 +82,40 @@ pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
)
}
fn is_reasoning_content_compatible_identifier(value: &str) -> bool {
let value = value.to_ascii_lowercase();
value.contains("moonshot") || value.contains("kimi") || value.contains("deepseek")
}
fn should_preserve_reasoning_content_for_openai_chat(
provider: &Provider,
body: &serde_json::Value,
) -> bool {
if body
.get("model")
.and_then(|m| m.as_str())
.is_some_and(is_reasoning_content_compatible_identifier)
{
return true;
}
let settings = &provider.settings_config;
let base_urls = [
settings
.get("env")
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
.and_then(|v| v.as_str()),
settings.get("base_url").and_then(|v| v.as_str()),
settings.get("baseURL").and_then(|v| v.as_str()),
settings.get("apiEndpoint").and_then(|v| v.as_str()),
];
base_urls
.into_iter()
.flatten()
.any(is_reasoning_content_compatible_identifier)
}
pub fn transform_claude_request_for_api_format(
body: serde_json::Value,
provider: &Provider,
@@ -89,6 +123,8 @@ pub fn transform_claude_request_for_api_format(
session_id: Option<&str>,
shadow_store: Option<&super::gemini_shadow::GeminiShadowStore>,
) -> Result<serde_json::Value, ProxyError> {
let is_codex_oauth = provider.is_codex_oauth();
// Copilot 场景:优先从 metadata.user_id 提取 session ID 作为 cache key
// 格式: "uuid_sessionId" → 提取 "_" 后面的部分作为 session 标识
// 同一会话的请求共享 cache key,提升 Copilot 缓存命中率
@@ -119,35 +155,47 @@ pub fn transform_claude_request_for_api_format(
.map(|s| s.to_string())
})
} else {
None
session_id
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string)
};
let cache_key = session_cache_key
.as_deref()
.or_else(|| {
provider
.meta
.as_ref()
.and_then(|m| m.prompt_cache_key.as_deref())
})
.unwrap_or(&provider.id);
let explicit_cache_key = provider
.meta
.as_ref()
.and_then(|m| m.prompt_cache_key.as_deref());
let (cache_key, cache_key_source) = if let Some(key) = explicit_cache_key {
(Some(key), "explicit")
} else if let Some(key) = session_cache_key.as_deref() {
(Some(key), "session")
} else {
(None, "none")
};
match api_format {
"openai_responses" => {
log::debug!(
"[Cache] OpenAI Responses prompt_cache_key source={cache_key_source}, provider={}, codex_oauth={is_codex_oauth}, has_key={}",
provider.id,
cache_key.is_some()
);
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
// + include: ["reasoning.encrypted_content"],由 transform 层统一处理。
let is_codex_oauth = provider
.meta
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("codex_oauth");
let codex_fast_mode = provider.codex_fast_mode_enabled();
super::transform_responses::anthropic_to_responses(
body,
Some(cache_key),
cache_key,
is_codex_oauth,
codex_fast_mode,
)
}
"openai_chat" => {
let mut result = super::transform::anthropic_to_openai(body)?;
let preserve_reasoning_content =
should_preserve_reasoning_content_for_openai_chat(provider, &body);
let mut result = super::transform::anthropic_to_openai_with_reasoning_content(
body,
preserve_reasoning_content,
)?;
// Inject prompt_cache_key only if explicitly configured in meta
if let Some(key) = provider
.meta
@@ -332,6 +380,16 @@ impl ClaudeAdapter {
log::debug!("[Claude] 使用 OPENAI_API_KEY");
return Some(key.to_string());
}
// Gemini Native key
if let Some(key) = env
.get("GEMINI_API_KEY")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 GEMINI_API_KEY");
return Some(key.to_string());
}
}
// 尝试直接获取
@@ -350,6 +408,33 @@ impl ClaudeAdapter {
log::warn!("[Claude] 未找到有效的 API Key");
None
}
/// 根据 env 中填写的变量名推断 Anthropic 默认走哪种鉴权策略。
///
/// 与 Anthropic SDK 原生语义保持一致:
/// - `ANTHROPIC_AUTH_TOKEN` → `ClaudeAuth`(发送 `Authorization: Bearer`
/// - `ANTHROPIC_API_KEY` → `Anthropic` (发送 `x-api-key`
///
/// 优先级与 [`extract_key`] 一致;两者都缺时返回 `None` 由调用方决定 fallback。
fn infer_anthropic_auth_strategy(&self, provider: &Provider) -> Option<AuthStrategy> {
let env = provider.settings_config.get("env")?;
let has_value = |key: &str| -> bool {
env.get(key)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.is_some()
};
if has_value("ANTHROPIC_AUTH_TOKEN") {
return Some(AuthStrategy::ClaudeAuth);
}
if has_value("ANTHROPIC_API_KEY") {
return Some(AuthStrategy::Anthropic);
}
None
}
}
impl Default for ClaudeAdapter {
@@ -463,7 +548,16 @@ impl ProviderAdapter for ClaudeAdapter {
ProviderType::Gemini => Some(AuthInfo::new(key, AuthStrategy::Google)),
ProviderType::OpenRouter => Some(AuthInfo::new(key, AuthStrategy::Bearer)),
ProviderType::ClaudeAuth => Some(AuthInfo::new(key, AuthStrategy::ClaudeAuth)),
_ => Some(AuthInfo::new(key, AuthStrategy::Anthropic)),
_ => {
// 按 env 中的变量名推断鉴权策略,对齐 Anthropic SDK 语义:
// ANTHROPIC_AUTH_TOKEN → Authorization: Bearer
// ANTHROPIC_API_KEY → x-api-key
// 其他来源(apiKey 直填等)默认走 x-api-keyAnthropic 官方协议)。
let strategy = self
.infer_anthropic_auth_strategy(provider)
.unwrap_or(AuthStrategy::Anthropic);
Some(AuthInfo::new(key, strategy))
}
}
}
@@ -500,7 +594,13 @@ impl ProviderAdapter for ClaudeAdapter {
// 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
let bearer = format!("Bearer {}", auth.api_key);
match auth.strategy {
AuthStrategy::Anthropic | AuthStrategy::ClaudeAuth | AuthStrategy::Bearer => {
AuthStrategy::Anthropic => {
vec![(
HeaderName::from_static("x-api-key"),
HeaderValue::from_str(&auth.api_key).unwrap(),
)]
}
AuthStrategy::ClaudeAuth | AuthStrategy::Bearer => {
vec![(
HeaderName::from_static("authorization"),
HeaderValue::from_str(&bearer).unwrap(),
@@ -701,7 +801,9 @@ mod tests {
}
#[test]
fn test_extract_auth_anthropic() {
fn test_extract_auth_anthropic_auth_token_uses_claude_auth_strategy() {
// ANTHROPIC_AUTH_TOKEN 在 Anthropic SDK 里语义就是 Authorization: Bearer
// 因此走 ClaudeAuth strategy 而不是 Anthropicx-api-key)。
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
@@ -712,7 +814,7 @@ mod tests {
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-ant-test-key");
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
}
#[test]
@@ -730,6 +832,73 @@ mod tests {
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
}
#[test]
fn test_extract_auth_both_env_vars_prefer_auth_token() {
// 两个变量都填时,extract_key 选 AUTH_TOKENstrategy 推断也必须保持一致。
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-from-auth-token",
"ANTHROPIC_API_KEY": "sk-from-api-key"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-from-auth-token");
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
}
#[test]
fn test_extract_auth_apikey_field_fallback_uses_anthropic_strategy() {
// 当用户没填任一 ANTHROPIC_* env,而是直接使用 apiKey 字段时,
// 视为没有显式语义偏好,默认走 Anthropic 官方协议(x-api-key)。
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"apiKey": "sk-direct",
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-direct");
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
}
#[test]
fn test_get_auth_headers_anthropic_emits_x_api_key() {
let adapter = ClaudeAdapter::new();
let auth = AuthInfo::new("sk-ant-test".to_string(), AuthStrategy::Anthropic);
let headers = adapter.get_auth_headers(&auth);
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0.as_str(), "x-api-key");
assert_eq!(headers[0].1.to_str().unwrap(), "sk-ant-test");
}
#[test]
fn test_get_auth_headers_claude_auth_emits_authorization_bearer() {
let adapter = ClaudeAdapter::new();
let auth = AuthInfo::new("sk-relay-test".to_string(), AuthStrategy::ClaudeAuth);
let headers = adapter.get_auth_headers(&auth);
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0.as_str(), "authorization");
assert_eq!(headers[0].1.to_str().unwrap(), "Bearer sk-relay-test");
}
#[test]
fn test_get_auth_headers_bearer_emits_authorization_bearer() {
let adapter = ClaudeAdapter::new();
let auth = AuthInfo::new("sk-or-test".to_string(), AuthStrategy::Bearer);
let headers = adapter.get_auth_headers(&auth);
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0.as_str(), "authorization");
assert_eq!(headers[0].1.to_str().unwrap(), "Bearer sk-or-test");
}
#[test]
fn test_extract_auth_openrouter() {
let adapter = ClaudeAdapter::new();
@@ -745,6 +914,27 @@ mod tests {
assert_eq!(auth.strategy, AuthStrategy::Bearer);
}
#[test]
fn test_extract_auth_gemini_api_key() {
let adapter = ClaudeAdapter::new();
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com/v1beta",
"GEMINI_API_KEY": "gemini-test-key"
}
}),
ProviderMeta {
api_format: Some("gemini_native".to_string()),
..Default::default()
},
);
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "gemini-test-key");
assert_eq!(auth.strategy, AuthStrategy::Google);
}
#[test]
fn test_extract_auth_claude_auth_mode() {
let adapter = ClaudeAdapter::new();
@@ -1222,6 +1412,202 @@ mod tests {
assert!(transformed.get("max_output_tokens").is_some());
}
#[test]
fn test_transform_claude_request_for_codex_oauth_uses_session_cache_key() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
}
}),
ProviderMeta {
api_format: Some("openai_responses".to_string()),
provider_type: Some("codex_oauth".to_string()),
..ProviderMeta::default()
},
);
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128
});
let transformed = transform_claude_request_for_api_format(
body,
&provider,
"openai_responses",
Some("session-123"),
None,
)
.unwrap();
assert_eq!(transformed["prompt_cache_key"], "session-123");
}
#[test]
fn test_transform_claude_request_for_codex_oauth_without_session_omits_cache_key() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
}
}),
ProviderMeta {
api_format: Some("openai_responses".to_string()),
provider_type: Some("codex_oauth".to_string()),
..ProviderMeta::default()
},
);
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128
});
let transformed = transform_claude_request_for_api_format(
body,
&provider,
"openai_responses",
None,
None,
)
.unwrap();
assert!(transformed.get("prompt_cache_key").is_none());
}
#[test]
fn test_transform_claude_request_for_responses_uses_session_cache_key() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.openai.example.com"
}
}),
ProviderMeta {
api_format: Some("openai_responses".to_string()),
..ProviderMeta::default()
},
);
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128
});
let transformed = transform_claude_request_for_api_format(
body,
&provider,
"openai_responses",
Some("claude-session-123"),
None,
)
.unwrap();
assert_eq!(transformed["prompt_cache_key"], "claude-session-123");
}
#[test]
fn test_transform_claude_request_for_responses_without_session_omits_cache_key() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.openai.example.com"
}
}),
ProviderMeta {
api_format: Some("openai_responses".to_string()),
..ProviderMeta::default()
},
);
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128
});
let transformed = transform_claude_request_for_api_format(
body,
&provider,
"openai_responses",
None,
None,
)
.unwrap();
assert!(transformed.get("prompt_cache_key").is_none());
}
#[test]
fn test_transform_claude_request_for_codex_oauth_keeps_explicit_cache_key() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
}
}),
ProviderMeta {
api_format: Some("openai_responses".to_string()),
provider_type: Some("codex_oauth".to_string()),
prompt_cache_key: Some("explicit-cache-key".to_string()),
..ProviderMeta::default()
},
);
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128
});
let transformed = transform_claude_request_for_api_format(
body,
&provider,
"openai_responses",
Some("session-123"),
None,
)
.unwrap();
assert_eq!(transformed["prompt_cache_key"], "explicit-cache-key");
}
#[test]
fn test_transform_claude_request_for_api_format_codex_oauth_fast_mode_off() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
}
}),
ProviderMeta {
provider_type: Some("codex_oauth".to_string()),
codex_fast_mode: Some(false),
..ProviderMeta::default()
},
);
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128
});
let transformed = transform_claude_request_for_api_format(
body,
&provider,
"openai_responses",
None,
None,
)
.unwrap();
assert_eq!(transformed["store"], json!(false));
assert!(transformed.get("service_tier").is_none());
assert_eq!(
transformed["include"],
json!(["reasoning.encrypted_content"])
);
}
#[test]
fn test_transform_claude_request_for_api_format_gemini_native() {
let provider = create_provider_with_meta(
@@ -1310,4 +1696,109 @@ mod tests {
assert_eq!(transformed["prompt_cache_key"], "claude-cache-route");
}
#[test]
fn test_transform_openai_chat_skips_reasoning_content_for_generic_provider() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com",
"ANTHROPIC_API_KEY": "test-key"
}
}),
ProviderMeta {
api_format: Some("openai_chat".to_string()),
..Default::default()
},
);
let body = json!({
"model": "gpt-5.4",
"max_tokens": 64,
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "I should call the tool."},
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
]
}]
});
let transformed =
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
.unwrap();
let msg = &transformed["messages"][0];
assert!(msg.get("tool_calls").is_some());
assert!(msg.get("reasoning_content").is_none());
}
#[test]
fn test_transform_openai_chat_preserves_reasoning_content_for_kimi_provider() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.moonshot.cn/v1",
"ANTHROPIC_API_KEY": "test-key"
}
}),
ProviderMeta {
api_format: Some("openai_chat".to_string()),
..Default::default()
},
);
let body = json!({
"model": "kimi-k2.6",
"max_tokens": 64,
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "I should call the tool."},
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
]
}]
});
let transformed =
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
.unwrap();
let msg = &transformed["messages"][0];
assert_eq!(msg["reasoning_content"], "I should call the tool.");
assert!(msg.get("tool_calls").is_some());
}
#[test]
fn test_transform_openai_chat_preserves_reasoning_content_for_deepseek_provider() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/v1",
"ANTHROPIC_API_KEY": "test-key"
}
}),
ProviderMeta {
api_format: Some("openai_chat".to_string()),
..Default::default()
},
);
let body = json!({
"model": "deepseek-v4-flash",
"max_tokens": 64,
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "I should call the tool."},
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
]
}]
});
let transformed =
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
.unwrap();
let msg = &transformed["messages"][0];
assert_eq!(msg["reasoning_content"], "I should call the tool.");
assert!(msg.get("tool_calls").is_some());
}
}
@@ -0,0 +1,374 @@
//! GitHub Copilot 模型 ID 归一化与 live-list 解析
//!
//! Copilot upstream 仅接受 dot 形式的 Claude 4.x 模型 ID(如 `claude-sonnet-4.6`),
//! 而 Claude Code 客户端发出 dash 形式(如 `claude-sonnet-4-6`、`claude-sonnet-4-6[1m]`)。
//! 不归一化会触发上游 400 `model_not_supported`。
//!
//! 仅做语法归一化不够:账号订阅级别可能不开放某个具体模型。
//! `resolve_against_models` 用 `/models` live 列表做精确匹配,找不到时
//! 按 familyhaiku/sonnet/opus+ 最高版本号 fallback。
use super::copilot_auth::CopilotModel;
use serde_json::Value;
/// 归一化客户端 model ID 为 Copilot upstream 接受的形式。
/// 返回 `None` 表示无需变换(已归一化、非 Claude 4.x 系列、或空输入)。
pub(super) fn normalize_to_copilot_id(client_id: &str) -> Option<String> {
let trimmed = client_id.trim();
let bytes = trimmed.as_bytes();
if bytes.len() < 8 || !bytes[..7].eq_ignore_ascii_case(b"claude-") {
return None;
}
let has_one_m_bracket = ends_with_ascii_ci(bytes, b"[1m]");
// Fast path: 已含点 + 不带 [1m] → 已归一化(绝大多数请求走这里)
if trimmed.contains('.') && !has_one_m_bracket {
return None;
}
let (base, has_1m_suffix) = split_one_m_suffix(trimmed);
let stripped = strip_trailing_date(base);
let dotted = dashes_to_dot_in_last_version(stripped);
if dotted.is_none() && !has_1m_suffix {
return None;
}
let mut candidate = dotted.unwrap_or_else(|| stripped.to_string());
if has_1m_suffix {
candidate.push_str("-1m");
}
(candidate != trimmed).then_some(candidate)
}
/// 在请求体中应用 model ID 归一化。
pub fn apply_copilot_model_normalization(mut body: Value) -> Value {
let Some(orig) = body.get("model").and_then(|v| v.as_str()) else {
return body;
};
if let Some(normalized) = normalize_to_copilot_id(orig) {
log::debug!("[CopilotNormalizer] {orig} → {normalized}");
body["model"] = Value::String(normalized);
}
body
}
fn ends_with_ascii_ci(haystack: &[u8], needle: &[u8]) -> bool {
haystack.len() >= needle.len()
&& haystack[haystack.len() - needle.len()..].eq_ignore_ascii_case(needle)
}
fn split_one_m_suffix(id: &str) -> (&str, bool) {
let bytes = id.as_bytes();
if ends_with_ascii_ci(bytes, b"[1m]") {
return (&id[..bytes.len() - 4], true);
}
if ends_with_ascii_ci(bytes, b"-1m") {
return (&id[..bytes.len() - 3], true);
}
(id, false)
}
fn strip_trailing_date(id: &str) -> &str {
let Some(last_dash) = id.rfind('-') else {
return id;
};
let suffix = &id[last_dash + 1..];
if suffix.len() == 8 && suffix.bytes().all(|b| b.is_ascii_digit()) {
&id[..last_dash]
} else {
id
}
}
/// 把 `…-X-Y`(X、Y 都是纯数字的末两段)变成 `…-X.Y`。
/// 返回 `None` 表示模式不匹配(保守策略避免误伤 `claude-3-5-sonnet` 等历史 ID)。
fn dashes_to_dot_in_last_version(id: &str) -> Option<String> {
let last_dash = id.rfind('-')?;
let last_segment = &id[last_dash + 1..];
if last_segment.is_empty() || !last_segment.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let head = &id[..last_dash];
let prev_dash = head.rfind('-')?;
let prev_segment = &head[prev_dash + 1..];
if prev_segment.is_empty() || !prev_segment.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some(format!("{head}.{last_segment}"))
}
/// 用 Copilot live 模型列表确认/降级 model ID。
///
/// 流程:
/// 1. 先做语法归一化(dash→dot、`[1m]`→`-1m`
/// 2. 在 `models` 中精确匹配;找到则使用归一化后的 ID
/// 3. 找不到时按 familyhaiku/sonnet/opus)取最高版本号 fallback
/// (优先保留 `-1m` 标志;都没有则取 base 版)
///
/// 返回 `None` 表示无需变换或无可降级的 family 候选(保留原 ID 让上游决定,
/// 让用户拿到明确的 `model_not_supported` 而非被静默替换)。
pub fn resolve_against_models(client_id: &str, models: &[CopilotModel]) -> Option<String> {
let normalized = normalize_to_copilot_id(client_id);
let target = normalized.as_deref().unwrap_or(client_id);
if models.iter().any(|m| m.id.eq_ignore_ascii_case(target)) {
return normalized.filter(|s| s != client_id);
}
let fallback = family_fallback(target, models)?;
if fallback.eq_ignore_ascii_case(client_id) {
None
} else {
Some(fallback)
}
}
fn detect_family(id: &str) -> Option<&'static str> {
let lower = id.to_ascii_lowercase();
if lower.contains("haiku") {
Some("haiku")
} else if lower.contains("sonnet") {
Some("sonnet")
} else if lower.contains("opus") {
Some("opus")
} else {
None
}
}
/// 提取 family 后第一段 `MAJOR.MINOR` 版本号。
/// 例:`claude-sonnet-4.6` → (4, 6)`claude-sonnet-4.6-1m` → (4, 6)。
fn extract_major_minor(id: &str) -> Option<(u32, u32)> {
let lower = id.to_ascii_lowercase();
let family = detect_family(&lower)?;
let after = &lower[lower.find(family)? + family.len()..];
let after = after.strip_prefix('-')?;
let segment = after.split(['-', '[', ' ']).next()?;
let mut parts = segment.split('.');
let major: u32 = parts.next()?.parse().ok()?;
let minor: u32 = parts.next().unwrap_or("0").parse().ok()?;
Some((major, minor))
}
fn family_fallback(target: &str, models: &[CopilotModel]) -> Option<String> {
let family = detect_family(target)?;
let want_1m = target.ends_with("-1m");
let pick_best = |require_1m: bool| -> Option<String> {
models
.iter()
.filter(|m| {
let lower = m.id.to_ascii_lowercase();
lower.contains(family) && lower.ends_with("-1m") == require_1m
})
.filter_map(|m| extract_major_minor(&m.id).map(|v| (m, v)))
.max_by_key(|(_, v)| *v)
.map(|(m, _)| m.id.clone())
};
if want_1m {
pick_best(true).or_else(|| pick_best(false))
} else {
pick_best(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn dashes_to_dot_basic() {
assert_eq!(
normalize_to_copilot_id("claude-sonnet-4-6"),
Some("claude-sonnet-4.6".to_string())
);
assert_eq!(
normalize_to_copilot_id("claude-opus-4-6"),
Some("claude-opus-4.6".to_string())
);
assert_eq!(
normalize_to_copilot_id("claude-haiku-4-5"),
Some("claude-haiku-4.5".to_string())
);
}
#[test]
fn one_m_bracket_to_dash() {
assert_eq!(
normalize_to_copilot_id("claude-sonnet-4-6[1m]"),
Some("claude-sonnet-4.6-1m".to_string())
);
assert_eq!(
normalize_to_copilot_id("claude-opus-4-6[1m]"),
Some("claude-opus-4.6-1m".to_string())
);
}
#[test]
fn one_m_bracket_on_already_dotted() {
// claude-sonnet-4.6[1m] 走非 fast-path 分支(has_one_m_bracket=true),
// 应被改写为 -1m 形式
assert_eq!(
normalize_to_copilot_id("claude-sonnet-4.6[1m]"),
Some("claude-sonnet-4.6-1m".to_string())
);
}
#[test]
fn date_suffix_stripped() {
assert_eq!(
normalize_to_copilot_id("claude-haiku-4-5-20251001"),
Some("claude-haiku-4.5".to_string())
);
assert_eq!(
normalize_to_copilot_id("claude-sonnet-4-5-20250929"),
Some("claude-sonnet-4.5".to_string())
);
}
#[test]
fn already_copilot_format_returns_none() {
assert_eq!(normalize_to_copilot_id("claude-sonnet-4.6"), None);
assert_eq!(normalize_to_copilot_id("claude-opus-4.6-1m"), None);
assert_eq!(normalize_to_copilot_id("claude-haiku-4.5"), None);
}
#[test]
fn non_claude_models_untouched() {
assert_eq!(normalize_to_copilot_id("gpt-5"), None);
assert_eq!(normalize_to_copilot_id("gpt-4o-mini"), None);
assert_eq!(normalize_to_copilot_id("o3"), None);
assert_eq!(normalize_to_copilot_id(""), None);
}
#[test]
fn legacy_three_part_versions_untouched() {
assert_eq!(normalize_to_copilot_id("claude-3-5-sonnet"), None);
assert_eq!(normalize_to_copilot_id("claude-3-5-sonnet-20241022"), None);
}
#[test]
fn case_insensitive_on_prefix_and_suffix() {
assert_eq!(
normalize_to_copilot_id("Claude-Sonnet-4-6"),
Some("Claude-Sonnet-4.6".to_string())
);
assert_eq!(
normalize_to_copilot_id("claude-sonnet-4-6[1M]"),
Some("claude-sonnet-4.6-1m".to_string())
);
}
#[test]
fn bracket_one_m_with_date_combined() {
assert_eq!(
normalize_to_copilot_id("claude-haiku-4-5-20251001[1m]"),
Some("claude-haiku-4.5-1m".to_string())
);
}
#[test]
fn apply_rewrites_body() {
let body = json!({"model": "claude-sonnet-4-6", "max_tokens": 1024});
let out = apply_copilot_model_normalization(body);
assert_eq!(out["model"], "claude-sonnet-4.6");
assert_eq!(out["max_tokens"], 1024);
}
#[test]
fn apply_no_change_when_already_normalized() {
let body = json!({"model": "claude-sonnet-4.6"});
let out = apply_copilot_model_normalization(body);
assert_eq!(out["model"], "claude-sonnet-4.6");
}
#[test]
fn apply_handles_missing_model() {
let body = json!({"messages": []});
let out = apply_copilot_model_normalization(body);
assert!(out.get("model").is_none());
}
fn model(id: &str) -> CopilotModel {
CopilotModel {
id: id.to_string(),
name: id.to_string(),
vendor: "anthropic".to_string(),
model_picker_enabled: true,
}
}
#[test]
fn resolve_exact_match_after_normalize() {
let models = vec![
model("claude-sonnet-4.6"),
model("claude-opus-4.6"),
model("claude-haiku-4.5"),
];
assert_eq!(
resolve_against_models("claude-sonnet-4-6", &models),
Some("claude-sonnet-4.6".to_string())
);
}
#[test]
fn resolve_returns_none_when_already_valid() {
let models = vec![model("claude-sonnet-4.6")];
assert_eq!(resolve_against_models("claude-sonnet-4.6", &models), None);
}
#[test]
fn resolve_falls_back_to_highest_family_version() {
// 用户请求 opus 4.7 但 Copilot 账号只有 opus 4.6
let models = vec![
model("claude-opus-4.5"),
model("claude-opus-4.6"),
model("claude-sonnet-4.6"),
];
assert_eq!(
resolve_against_models("claude-opus-4.7", &models),
Some("claude-opus-4.6".to_string())
);
}
#[test]
fn resolve_prefers_1m_when_requested() {
let models = vec![
model("claude-sonnet-4.6"),
model("claude-sonnet-4.6-1m"),
model("claude-opus-4.6"),
];
assert_eq!(
resolve_against_models("claude-sonnet-4-6[1m]", &models),
Some("claude-sonnet-4.6-1m".to_string())
);
}
#[test]
fn resolve_falls_back_to_base_when_1m_unavailable() {
// 账号没开 -1m 变体时降级到 base
let models = vec![model("claude-sonnet-4.6")];
assert_eq!(
resolve_against_models("claude-sonnet-4-6[1m]", &models),
Some("claude-sonnet-4.6".to_string())
);
}
#[test]
fn resolve_returns_none_when_family_absent() {
// 账号完全没有 opus 时不做强行替换,让上游报错
let models = vec![model("claude-sonnet-4.6"), model("claude-haiku-4.5")];
assert_eq!(resolve_against_models("claude-opus-4.6", &models), None);
}
#[test]
fn resolve_handles_non_claude_target() {
let models = vec![model("claude-sonnet-4.6")];
assert_eq!(resolve_against_models("gpt-5", &models), None);
}
}
+8 -15
View File
@@ -17,6 +17,7 @@ mod claude;
mod codex;
pub mod codex_oauth_auth;
pub mod copilot_auth;
pub mod copilot_model_map;
mod gemini;
pub(crate) mod gemini_schema;
pub mod gemini_shadow;
@@ -104,7 +105,7 @@ impl ProviderType {
#[allow(dead_code)]
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
match app_type {
AppType::Claude => {
AppType::Claude | AppType::ClaudeDesktop => {
if get_claude_api_format(provider) == "gemini_native" {
let adapter = ClaudeAdapter::new();
return match adapter.extract_auth(provider).map(|auth| auth.strategy) {
@@ -174,13 +175,9 @@ impl ProviderType {
}
ProviderType::Gemini
}
AppType::OpenCode => {
// OpenCode doesn't support proxy, but return a default type for completeness
ProviderType::Codex // Fallback to Codex-like type
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy, but return a default type for completeness
ProviderType::Codex // Fallback to Codex-like type
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy, fallback to Codex-like type
ProviderType::Codex
}
}
}
@@ -229,15 +226,11 @@ impl std::str::FromStr for ProviderType {
/// 根据 AppType 获取对应的适配器
pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
match app_type {
AppType::Claude => Box::new(ClaudeAdapter::new()),
AppType::Claude | AppType::ClaudeDesktop => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
AppType::OpenCode => {
// OpenCode doesn't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy, fallback to Codex adapter
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
}
+352 -32
View File
@@ -6,14 +6,17 @@ use crate::proxy::sse::{strip_sse_field, take_sse_block};
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
/// OpenAI 流式响应数据结构
#[derive(Debug, Deserialize)]
struct OpenAIStreamChunk {
#[serde(default)]
id: String,
#[serde(default)]
model: String,
#[serde(default)]
choices: Vec<StreamChoice>,
#[serde(default)]
usage: Option<Usage>,
@@ -30,8 +33,9 @@ struct StreamChoice {
struct Delta {
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning: Option<String>, // OpenRouter 的推理内容
// OpenRouter/Kimi/其它 使用 reasoningDeepSeek 使用 reasoning_content
#[serde(default, alias = "reasoning_content")]
reasoning: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<DeltaToolCall>>,
}
@@ -95,6 +99,42 @@ struct ToolBlockState {
/// 无限空白 bug 的连续空白字符阈值
const INFINITE_WHITESPACE_THRESHOLD: usize = 20;
fn build_anthropic_usage_json(usage: &Usage) -> Value {
let mut usage_json = json!({
"input_tokens": usage.prompt_tokens,
"output_tokens": usage.completion_tokens
});
if let Some(cached) = extract_cache_read_tokens(usage) {
usage_json["cache_read_input_tokens"] = json!(cached);
}
if let Some(created) = usage.cache_creation_input_tokens {
usage_json["cache_creation_input_tokens"] = json!(created);
}
usage_json
}
fn default_anthropic_usage_json() -> Value {
json!({
"input_tokens": 0,
"output_tokens": 0
})
}
fn build_message_delta_event(stop_reason: Option<String>, usage_json: Option<Value>) -> Value {
let usage = usage_json
.filter(|usage| usage.is_object())
.unwrap_or_else(default_anthropic_usage_json);
json!({
"type": "message_delta",
"delta": {
"stop_reason": stop_reason,
"stop_sequence": null
},
"usage": usage
})
}
/// 创建 Anthropic SSE 流
pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
@@ -106,6 +146,16 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
let mut current_model = None;
let mut next_content_index: u32 = 0;
let mut has_sent_message_start = false;
// 某些上游 provider(如 OpenRouter 的 kimi-k2.6)会在 tool_use 后发送多个
// 带 finish_reason 的 SSE chunk。Anthropic 协议要求每个消息流只能有一个
// message_delta,重复会导致 Claude Code abort 连接。因此需要:
// 1) has_emitted_message_delta: 去重,只处理第一个 finish_reason
// 2) pending_message_delta: 缓存延迟到 [DONE] 发送,确保 usage 完整
let mut has_emitted_message_delta = false;
let mut pending_message_delta: Option<(Option<String>, Option<Value>)> = None;
let mut has_sent_message_stop = false;
let mut stream_ended_with_error = false;
let mut latest_usage: Option<Value> = None;
let mut current_non_tool_block_type: Option<&'static str> = None;
let mut current_non_tool_block_index: Option<u32> = None;
let mut tool_blocks_by_index: HashMap<usize, ToolBlockState> = HashMap::new();
@@ -127,24 +177,44 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
if let Some(data) = strip_sse_field(l, "data") {
if data.trim() == "[DONE]" {
log::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
// 流正常结束,发出缓存的 message_delta(含完整 usage)。
if let Some((stop_reason, usage_json)) = pending_message_delta.take() {
let event = build_message_delta_event(stop_reason, usage_json);
let sse_data = format!("event: message_delta\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_delta (from pending)");
yield Ok(Bytes::from(sse_data));
}
let event = json!({"type": "message_stop"});
let sse_data = format!("event: message_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
yield Ok(Bytes::from(sse_data));
has_sent_message_stop = true;
continue;
}
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
if message_id.is_none() {
if message_id.is_none() && !chunk.id.is_empty() {
message_id = Some(chunk.id.clone());
}
if current_model.is_none() {
if current_model.is_none() && !chunk.model.is_empty() {
current_model = Some(chunk.model.clone());
}
let chunk_usage_json =
chunk.usage.as_ref().map(build_anthropic_usage_json);
if let Some(usage_json) = &chunk_usage_json {
latest_usage = Some(usage_json.clone());
if let Some((_, pending_usage)) = pending_message_delta.as_mut() {
*pending_usage = Some(usage_json.clone());
}
}
if let Some(choice) = chunk.choices.first() {
if !has_sent_message_start {
// Build usage with cache tokens if available from first chunk
@@ -420,8 +490,24 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
}
}
// 处理 finish_reason
// 处理 finish_reason
// 注意:OpenRouter 某些 provider 会发送多个带 finish_reason 的 chunk
// (第一个 usage 为 null,后续才补全)。此处只做缓存,不立即发送,
// 等到 [DONE] 或流末尾再统一发出,确保 usage 完整且只发一次。
if let Some(finish_reason) = &choice.finish_reason {
let stop_reason = map_stop_reason(Some(finish_reason));
let usage_json =
chunk_usage_json.clone().or_else(|| latest_usage.clone());
if has_emitted_message_delta {
// 更新缓存的 message_delta usage(如果有更完整的 usage
if let (Some((_, ref mut usage)), Some(uj)) = (&mut pending_message_delta, usage_json) {
*usage = Some(uj);
}
continue;
}
has_emitted_message_delta = true;
if let Some(index) = current_non_tool_block_index.take() {
let event = json!({
"type": "content_block_stop",
@@ -511,32 +597,8 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
open_tool_block_indices.clear();
}
let stop_reason = map_stop_reason(Some(finish_reason));
// Build usage with cache token fields
let usage_json = chunk.usage.as_ref().map(|u| {
let mut uj = json!({
"input_tokens": u.prompt_tokens,
"output_tokens": u.completion_tokens
});
if let Some(cached) = extract_cache_read_tokens(u) {
uj["cache_read_input_tokens"] = json!(cached);
}
if let Some(created) = u.cache_creation_input_tokens {
uj["cache_creation_input_tokens"] = json!(created);
}
uj
});
let event = json!({
"type": "message_delta",
"delta": {
"stop_reason": stop_reason,
"stop_sequence": null
},
"usage": usage_json
});
let sse_data = format!("event: message_delta\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
// 缓存 message_delta,等到 [DONE] 时发送(以便收集完整的 usage)
pending_message_delta = Some((stop_reason, usage_json));
}
}
}
@@ -546,6 +608,7 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
}
Err(e) => {
log::error!("Stream error: {e}");
stream_ended_with_error = true;
let error_event = json!({
"type": "error",
"error": {
@@ -560,6 +623,31 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
}
}
}
// 流自然结束但未收到 [DONE] 时,确保发送缓存的 message_delta 和 message_stop。
// 若上游已显式报错,则只保留 error 事件,避免把失败伪装成成功完成。
if !stream_ended_with_error {
let emitted_pending_message_delta = if let Some((stop_reason, usage_json)) =
pending_message_delta.take()
{
let event = build_message_delta_event(stop_reason, usage_json);
let sse_data = format!("event: message_delta\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_delta (at stream end)");
yield Ok(Bytes::from(sse_data));
true
} else {
false
};
if emitted_pending_message_delta && !has_sent_message_stop {
let event = json!({"type": "message_stop"});
let sse_data = format!("event: message_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop (at stream end)");
yield Ok(Bytes::from(sse_data));
}
}
}
}
@@ -602,6 +690,32 @@ mod tests {
use serde_json::Value;
use std::collections::HashMap;
async fn collect_anthropic_events(input: &str) -> Vec<Value> {
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream(upstream);
let chunks: Vec<_> = converted.collect().await;
let merged = chunks
.into_iter()
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
.collect::<String>();
merged
.split("\n\n")
.filter_map(|block| {
let data = block
.lines()
.find_map(|line| strip_sse_field(line, "data"))?;
serde_json::from_str::<Value>(data).ok()
})
.collect()
}
fn event_type(event: &Value) -> Option<&str> {
event.get("type").and_then(|v| v.as_str())
}
#[test]
fn test_map_stop_reason_legacy_and_filtered_values() {
assert_eq!(
@@ -818,4 +932,210 @@ mod tests {
"output must not contain U+FFFD replacement characters"
);
}
#[tokio::test]
async fn test_duplicate_finish_reason_emits_only_one_message_delta() {
// Simulates OpenRouter behavior where two chunks carry finish_reason:
// first with null usage, second with populated usage.
let input = concat!(
"data: {\"id\":\"chatcmpl_dup\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: {\"id\":\"chatcmpl_dup\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5}}\n\n",
"data: [DONE]\n\n"
);
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream(upstream);
let chunks: Vec<_> = converted.collect().await;
let merged = chunks
.into_iter()
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
.collect::<String>();
let events: Vec<Value> = merged
.split("\n\n")
.filter_map(|block| {
let data = block
.lines()
.find_map(|line| strip_sse_field(line, "data"))?;
serde_json::from_str::<Value>(data).ok()
})
.collect();
let message_deltas: Vec<&Value> = events
.iter()
.filter(|e| e.get("type").and_then(|v| v.as_str()) == Some("message_delta"))
.collect();
assert_eq!(
message_deltas.len(),
1,
"duplicate finish_reason chunks must produce exactly one message_delta, got {}: {:?}",
message_deltas.len(),
message_deltas
);
assert_eq!(message_deltas[0]["usage"]["input_tokens"], 10);
assert_eq!(message_deltas[0]["usage"]["output_tokens"], 5);
let message_stops = events
.iter()
.filter(|e| e.get("type").and_then(|v| v.as_str()) == Some("message_stop"))
.count();
assert_eq!(message_stops, 1, "message_stop must only be emitted once");
}
#[tokio::test]
async fn test_usage_only_chunk_after_finish_reason_updates_message_delta_usage() {
let input = concat!(
"data: {\"id\":\"chatcmpl_split\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"tool-0924\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"{\\\"command\\\":\\\"pwd\\\"}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_split\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":13312,\"completion_tokens\":79,\"prompt_tokens_details\":{\"cached_tokens\":100}}}\n\n",
"data: [DONE]\n\n"
);
let events = collect_anthropic_events(input).await;
let message_deltas: Vec<&Value> = events
.iter()
.filter(|event| event_type(event) == Some("message_delta"))
.collect();
let message_stops = events
.iter()
.filter(|event| event_type(event) == Some("message_stop"))
.count();
assert_eq!(message_deltas.len(), 1);
assert_eq!(message_stops, 1);
let message_delta = message_deltas[0];
assert_eq!(
message_delta
.pointer("/delta/stop_reason")
.and_then(|v| v.as_str()),
Some("tool_use")
);
assert_eq!(
message_delta
.pointer("/usage/input_tokens")
.and_then(|v| v.as_u64()),
Some(13312)
);
assert_eq!(
message_delta
.pointer("/usage/output_tokens")
.and_then(|v| v.as_u64()),
Some(79)
);
assert_eq!(
message_delta
.pointer("/usage/cache_read_input_tokens")
.and_then(|v| v.as_u64()),
Some(100)
);
}
#[tokio::test]
async fn test_message_delta_includes_zero_usage_when_stream_has_no_usage() {
let input = concat!(
"data: {\"id\":\"chatcmpl_no_usage\",\"model\":\"gpt-5.5\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"get_time\",\"arguments\":\"{}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_no_usage\",\"model\":\"gpt-5.5\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n"
);
let events = collect_anthropic_events(input).await;
let message_deltas: Vec<&Value> = events
.iter()
.filter(|event| event_type(event) == Some("message_delta"))
.collect();
assert_eq!(message_deltas.len(), 1);
let message_delta = message_deltas[0];
assert_eq!(
message_delta
.pointer("/delta/stop_reason")
.and_then(|v| v.as_str()),
Some("tool_use")
);
assert_eq!(
message_delta
.pointer("/usage/input_tokens")
.and_then(|v| v.as_u64()),
Some(0)
);
assert_eq!(
message_delta
.pointer("/usage/output_tokens")
.and_then(|v| v.as_u64()),
Some(0)
);
}
#[tokio::test]
async fn test_streaming_finalizes_after_finish_when_done_is_missing() {
let input = concat!(
"data: {\"id\":\"chatcmpl_no_done\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_no_done\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n"
);
let events = collect_anthropic_events(input).await;
assert!(events.iter().any(|event| {
event_type(event) == Some("message_delta")
&& event.pointer("/delta/stop_reason").and_then(|v| v.as_str()) == Some("end_turn")
}));
assert_eq!(
events.last().and_then(|event| event_type(event)),
Some("message_stop")
);
}
#[tokio::test]
async fn test_stream_end_without_finish_reason_does_not_emit_success_terminal_events() {
let input = "data: {\"id\":\"chatcmpl_truncated\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n";
let events = collect_anthropic_events(input).await;
assert!(!events
.iter()
.any(|event| event_type(event) == Some("message_delta")));
assert!(!events
.iter()
.any(|event| event_type(event) == Some("message_stop")));
}
#[tokio::test]
async fn test_stream_error_does_not_emit_success_terminal_events() {
let upstream = stream::iter(vec![Err::<Bytes, _>(std::io::Error::other(
"upstream disconnected",
))]);
let converted = create_anthropic_sse_stream(upstream);
let chunks: Vec<_> = converted.collect().await;
let merged = chunks
.into_iter()
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
.collect::<String>();
let events: Vec<Value> = merged
.split("\n\n")
.filter_map(|block| {
let data = block
.lines()
.find_map(|line| strip_sse_field(line, "data"))?;
serde_json::from_str::<Value>(data).ok()
})
.collect();
assert!(events
.iter()
.any(|e| e.get("type").and_then(|v| v.as_str()) == Some("error")));
assert!(!events
.iter()
.any(|e| e.get("type").and_then(|v| v.as_str()) == Some("message_delta")));
assert!(!events
.iter()
.any(|e| e.get("type").and_then(|v| v.as_str()) == Some("message_stop")));
}
}
@@ -8,7 +8,10 @@
//!
//! 与 Chat Completions 的 delta chunk 模型完全不同,需要独立的状态机处理。
use super::transform_responses::{build_anthropic_usage_from_responses, map_responses_stop_reason};
use super::transform_responses::{
build_anthropic_usage_from_responses, map_responses_stop_reason,
sanitize_anthropic_tool_use_input_json,
};
use crate::proxy::sse::{strip_sse_field, take_sse_block};
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
@@ -112,6 +115,8 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
let mut fallback_open_index: Option<u32> = None;
let mut current_text_index: Option<u32> = None;
let mut tool_index_by_item_id: HashMap<String, u32> = HashMap::new();
let mut tool_name_by_index: HashMap<u32, String> = HashMap::new();
let mut tool_args_by_index: HashMap<u32, String> = HashMap::new();
let mut last_tool_index: Option<u32> = None;
tokio::pin!(stream);
@@ -170,9 +175,12 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
}
has_sent_message_start = true;
// Build usage with cache tokens if available
// Build usage with defensive null handling
// Some() wrapper ensures build function always receives valid input
// Fallback to empty object {} if usage field missing, ensuring message_start
// event always has valid usage structure for VSCode Extension compatibility
let start_usage = build_anthropic_usage_from_responses(
response_obj.get("usage"),
Some(response_obj.get("usage").unwrap_or(&json!({}))),
);
let event = json!({
@@ -410,12 +418,15 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
{
tool_index_by_item_id.insert(item_id.to_string(), index);
}
tool_name_by_index.insert(index, name.to_string());
last_tool_index = Some(index);
if open_indices.contains(&index) {
continue;
}
tool_args_by_index.insert(index, String::new());
let event = json!({
"type": "content_block_start",
"index": index,
@@ -479,6 +490,14 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
open_indices.insert(index);
}
if tool_name_by_index.get(&index).map(String::as_str) == Some("Read") {
tool_args_by_index
.entry(index)
.or_default()
.push_str(delta);
continue;
}
let event = json!({
"type": "content_block_delta",
"index": index,
@@ -512,6 +531,32 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
if !open_indices.remove(&index) {
continue;
}
if tool_name_by_index.get(&index).map(String::as_str) == Some("Read") {
let raw = data
.get("arguments")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| {
tool_args_by_index
.get(&index)
.cloned()
.unwrap_or_default()
});
let sanitized = sanitize_anthropic_tool_use_input_json("Read", &raw);
if !sanitized.is_empty() {
let event = json!({
"type": "content_block_delta",
"index": index,
"delta": {
"type": "input_json_delta",
"partial_json": sanitized
}
});
let sse = format!("event: content_block_delta\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse));
}
}
let event = json!({
"type": "content_block_stop",
"index": index
@@ -522,6 +567,8 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
if let Some(item_id) = item_id {
tool_index_by_item_id.remove(item_id);
}
tool_name_by_index.remove(&index);
tool_args_by_index.remove(&index);
}
}
@@ -670,9 +717,12 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
}
fallback_open_index = None;
let usage_json = response_obj.get("usage").map(|u| {
build_anthropic_usage_from_responses(Some(u))
});
// Defensive: Always build usage_json, even if usage field missing
// Some() wrapper with fallback to {} ensures build_anthropic_usage_from_responses
// always receives valid input, preventing null pointer errors in VSCode Extension
let usage_json = build_anthropic_usage_from_responses(
Some(response_obj.get("usage").unwrap_or(&json!({})))
);
// Emit message_delta (with usage + stop_reason)
let delta_event = json!({
@@ -820,6 +870,71 @@ mod tests {
assert!(merged.contains("\"type\":\"message_stop\""));
}
#[tokio::test]
async fn test_streaming_read_tool_drops_empty_pages() {
let input = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_read\",\"model\":\"gpt-5.5\"}}\n\n",
"event: response.output_item.added\n",
"data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_read\",\"type\":\"function_call\",\"call_id\":\"call_read\",\"name\":\"Read\"}}\n\n",
"event: response.function_call_arguments.delta\n",
"data: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_read\",\"delta\":\"{\\\"file_path\\\":\\\"/tmp/demo.py\\\",\\\"limit\\\":2000,\\\"offset\\\":0,\\\"pages\\\":\\\"\\\"}\"}\n\n",
"event: response.function_call_arguments.done\n",
"data: {\"type\":\"response.function_call_arguments.done\",\"item_id\":\"fc_read\",\"arguments\":\"{\\\"file_path\\\":\\\"/tmp/demo.py\\\",\\\"limit\\\":2000,\\\"offset\\\":0,\\\"pages\\\":\\\"\\\"}\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n"
);
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream_from_responses(upstream);
let chunks: Vec<_> = converted.collect().await;
let merged = chunks
.into_iter()
.map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())
.collect::<String>();
assert!(merged.contains("\"name\":\"Read\""));
assert!(merged.contains("\"partial_json\":\"{\\\"file_path\\\":\\\"/tmp/demo.py\\\",\\\"limit\\\":2000,\\\"offset\\\":0}"));
assert!(!merged.contains("\\\"pages\\\":\\\"\\\""));
}
#[tokio::test]
async fn test_streaming_read_tool_duplicate_start_preserves_buffered_args() {
let input = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_read\",\"model\":\"gpt-5.5\"}}\n\n",
"event: response.output_item.added\n",
"data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_read\",\"type\":\"function_call\",\"call_id\":\"call_read\",\"name\":\"Read\"}}\n\n",
"event: response.function_call_arguments.delta\n",
"data: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_read\",\"delta\":\"{\\\"file_path\\\":\\\"/tmp/demo.py\\\",\\\"limit\\\":2000,\\\"offset\\\":0,\\\"pages\\\":\\\"\\\"}\"}\n\n",
"event: response.output_item.added\n",
"data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_read\",\"type\":\"function_call\",\"call_id\":\"call_read\",\"name\":\"Read\"}}\n\n",
"event: response.function_call_arguments.done\n",
"data: {\"type\":\"response.function_call_arguments.done\",\"item_id\":\"fc_read\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n"
);
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream_from_responses(upstream);
let chunks: Vec<_> = converted.collect().await;
let merged = chunks
.into_iter()
.map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())
.collect::<String>();
assert_eq!(merged.matches("event: content_block_start").count(), 1);
assert_eq!(merged.matches("event: content_block_stop").count(), 1);
assert!(merged.contains("\"partial_json\":\"{\\\"file_path\\\":\\\"/tmp/demo.py\\\",\\\"limit\\\":2000,\\\"offset\\\":0}"));
assert!(!merged.contains("\\\"pages\\\":\\\"\\\""));
}
#[tokio::test]
async fn test_streaming_conversion_interleaved_tool_deltas_by_item_id() {
let input = concat!(
+327 -7
View File
@@ -3,9 +3,49 @@
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
//! 参考: anthropic-proxy-rs
use crate::proxy::error::ProxyError;
use crate::proxy::{error::ProxyError, json_canonical::canonical_json_string};
use serde_json::{json, Value};
const ANTHROPIC_BILLING_HEADER_PREFIX: &str = "x-anthropic-billing-header:";
/// Strip only a leading Claude Code attribution line from system text.
///
/// Claude Code can send dynamic `x-anthropic-billing-header` metadata at the
/// start of `system`. If forwarded into OpenAI Chat messages or Responses
/// `instructions`, the rotating `cch=` value changes the prompt prefix on every
/// request and prevents prefix cache reuse (#2350). Later occurrences are kept
/// to avoid deleting user-authored prompt text.
pub(crate) fn strip_leading_anthropic_billing_header(text: &str) -> &str {
if !text.starts_with(ANTHROPIC_BILLING_HEADER_PREFIX) {
return text;
}
let Some(line_end) = text
.as_bytes()
.iter()
.position(|byte| *byte == b'\n' || *byte == b'\r')
else {
return "";
};
let bytes = text.as_bytes();
let mut rest_start = line_end + 1;
if bytes[line_end] == b'\r' && bytes.get(line_end + 1) == Some(&b'\n') {
rest_start += 1;
}
let rest = &text[rest_start..];
if let Some(stripped) = rest.strip_prefix("\r\n") {
stripped
} else if let Some(stripped) = rest.strip_prefix('\n') {
stripped
} else if let Some(stripped) = rest.strip_prefix('\r') {
stripped
} else {
rest
}
}
/// Detect OpenAI o-series reasoning models (o1, o3, o4-mini, etc.)
/// These models require `max_completion_tokens` instead of `max_tokens`.
pub fn is_openai_o_series(model: &str) -> bool {
@@ -73,6 +113,18 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
/// Anthropic 请求 → OpenAI Chat Completions 请求
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
anthropic_to_openai_with_reasoning_content(body, false)
}
/// Anthropic 请求 → OpenAI Chat Completions 请求
///
/// `preserve_reasoning_content` 仅用于明确需要 Moonshot/Kimi/DeepSeek
/// `reasoning_content` 兼容字段的 provider。默认转换保持通用 OpenAI-compatible
/// 请求体,避免向严格后端发送未知字段。
pub fn anthropic_to_openai_with_reasoning_content(
body: Value,
preserve_reasoning_content: bool,
) -> Result<Value, ProxyError> {
let mut result = json!({});
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
@@ -85,12 +137,18 @@ pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
// 处理 system prompt
if let Some(system) = body.get("system") {
if let Some(text) = system.as_str() {
// 单个字符串
messages.push(json!({"role": "system", "content": text}));
let text = strip_leading_anthropic_billing_header(text);
if !text.is_empty() {
messages.push(json!({"role": "system", "content": text}));
}
} else if let Some(arr) = system.as_array() {
// 多个 system message — preserve cache_control for compatible proxies
for msg in arr {
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
let text = strip_leading_anthropic_billing_header(text);
if text.is_empty() {
continue;
}
let mut sys_msg = json!({"role": "system", "content": text});
if let Some(cc) = msg.get("cache_control") {
sys_msg["cache_control"] = cc.clone();
@@ -106,7 +164,7 @@ pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
for msg in msgs {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
let content = msg.get("content");
let converted = convert_message_to_openai(role, content)?;
let converted = convert_message_to_openai(role, content, preserve_reasoning_content)?;
messages.extend(converted);
}
}
@@ -252,6 +310,7 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
fn convert_message_to_openai(
role: &str,
content: Option<&Value>,
preserve_reasoning_content: bool,
) -> Result<Vec<Value>, ProxyError> {
let mut result = Vec::new();
@@ -273,6 +332,9 @@ fn convert_message_to_openai(
if let Some(blocks) = content.as_array() {
let mut content_parts = Vec::new();
let mut tool_calls = Vec::new();
// reasoning_parts: 仅在兼容 Moonshot/Kimi/DeepSeek thinking tool-call 路径时
// 生成 reasoning_content,通用 OpenAI-compatible 路径不发送该非标准字段。
let mut reasoning_parts = Vec::new();
for block in blocks {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
@@ -309,7 +371,7 @@ fn convert_message_to_openai(
"type": "function",
"function": {
"name": name,
"arguments": serde_json::to_string(&input).unwrap_or_default()
"arguments": canonical_json_string(&input)
}
}));
}
@@ -322,7 +384,7 @@ fn convert_message_to_openai(
let content_val = block.get("content");
let content_str = match content_val {
Some(Value::String(s)) => s.clone(),
Some(v) => serde_json::to_string(v).unwrap_or_default(),
Some(v) => canonical_json_string(v),
None => String::new(),
};
result.push(json!({
@@ -332,7 +394,12 @@ fn convert_message_to_openai(
}));
}
"thinking" => {
// 跳过 thinking blocks
// 提取 thinking 内容,后续可作为 reasoning_content 传给需要它的上游。
if let Some(thinking) = block.get("thinking").and_then(|t| t.as_str()) {
if !thinking.is_empty() {
reasoning_parts.push(thinking.to_string());
}
}
}
_ => {}
}
@@ -366,6 +433,15 @@ fn convert_message_to_openai(
msg["tool_calls"] = json!(tool_calls);
}
if preserve_reasoning_content && role == "assistant" && !tool_calls.is_empty() {
let reasoning_content = if reasoning_parts.is_empty() {
"tool call".to_string()
} else {
reasoning_parts.join("\n")
};
msg["reasoning_content"] = json!(reasoning_content);
}
result.push(msg);
}
@@ -417,6 +493,13 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
let mut content = Vec::new();
let mut has_tool_use = false;
// DeepSeek provider 会把思考内容放在 message.reasoning_content。
if let Some(reasoning_content) = message.get("reasoning_content").and_then(|r| r.as_str()) {
if !reasoning_content.is_empty() {
content.push(json!({"type": "thinking", "thinking": reasoning_content}));
}
}
// 文本/拒绝内容
if let Some(msg_content) = message.get("content") {
if let Some(text) = msg_content.as_str() {
@@ -608,6 +691,80 @@ mod tests {
assert_eq!(result["messages"][1]["role"], "user");
}
#[test]
fn test_anthropic_to_openai_strips_leading_billing_header_from_system_string() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n\nYou are a helpful assistant.",
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
"You are a helpful assistant."
);
assert_eq!(result["messages"][1]["role"], "user");
}
#[test]
fn test_anthropic_to_openai_strips_billing_header_from_system_array_parts() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": [
{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n"},
{"type": "text", "text": "Stable prompt"}
],
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(result["messages"][0]["content"], "Stable prompt");
assert_eq!(result["messages"][1]["role"], "user");
}
#[test]
fn test_anthropic_to_openai_preserves_prompt_after_billing_header_in_same_part() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": [
{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n\nStable prompt part 1"},
{"type": "text", "text": "Stable prompt part 2"}
],
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
"Stable prompt part 1\nStable prompt part 2"
);
assert_eq!(result["messages"][1]["role"], "user");
}
#[test]
fn test_anthropic_to_openai_keeps_non_leading_billing_header_text() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": "Keep this literal:\nx-anthropic-billing-header: example",
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
"Keep this literal:\nx-anthropic-billing-header: example"
);
}
#[test]
fn test_anthropic_to_openai_with_tools() {
let input = json!({
@@ -710,6 +867,88 @@ mod tests {
assert_eq!(msg["role"], "assistant");
assert!(msg.get("tool_calls").is_some());
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
assert!(msg.get("reasoning_content").is_none());
}
#[test]
fn test_anthropic_to_openai_tool_use_preserves_reasoning_content() {
let input = json!({
"model": "kimi-k2.6",
"max_tokens": 1024,
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "I should call the tool."},
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
]
}]
});
let result = anthropic_to_openai_with_reasoning_content(input, true).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "assistant");
assert_eq!(msg["reasoning_content"], "I should call the tool.");
assert!(msg.get("tool_calls").is_some());
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
}
#[test]
fn test_anthropic_to_openai_tool_use_injects_placeholder_reasoning_content_when_missing() {
let input = json!({
"model": "kimi-k2.6",
"max_tokens": 1024,
"messages": [{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
]
}]
});
let result = anthropic_to_openai_with_reasoning_content(input, true).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "assistant");
assert_eq!(msg["reasoning_content"], "tool call");
assert!(msg.get("tool_calls").is_some());
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
}
#[test]
fn test_anthropic_to_openai_does_not_emit_reasoning_content_by_default() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "I should call the tool."},
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
]
}]
});
let result = anthropic_to_openai(input).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "assistant");
assert!(msg.get("tool_calls").is_some());
assert!(msg.get("reasoning_content").is_none());
}
#[test]
fn test_anthropic_to_openai_skips_thinking_only_message() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "No visible content yet."}
]
}]
});
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["messages"].as_array().unwrap().len(), 0);
}
#[test]
@@ -757,6 +996,34 @@ mod tests {
assert_eq!(result["usage"]["output_tokens"], 5);
}
#[test]
fn test_openai_to_anthropic_preserves_id_for_usage_dedup() {
let input = json!({
"id": "chatcmpl-claude-compatible",
"object": "chat.completion",
"model": "claude-sonnet-4-5",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let result = openai_to_anthropic(input).unwrap();
let usage = crate::proxy::usage::parser::TokenUsage::from_claude_response(&result)
.expect("converted Anthropic response should parse usage");
assert_eq!(
usage.message_id.as_deref(),
Some("chatcmpl-claude-compatible")
);
assert_eq!(
usage.dedup_request_id(),
"session:chatcmpl-claude-compatible"
);
}
#[test]
fn test_openai_to_anthropic_with_tool_calls() {
let input = json!({
@@ -788,6 +1055,59 @@ mod tests {
assert_eq!(result["stop_reason"], "tool_use");
}
#[test]
fn test_deepseek_reasoning_content_round_trips_for_tool_calls() {
let upstream_response = json!({
"id": "chatcmpl-deepseek",
"object": "chat.completion",
"created": 1234567890,
"model": "deepseek-v4-flash",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"reasoning_content": "Need the current date before calling weather.",
"content": "Let me check the date first.",
"tool_calls": [{
"id": "call_date",
"type": "function",
"function": {"name": "get_date", "arguments": "{}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let anthropic_response = openai_to_anthropic(upstream_response).unwrap();
assert_eq!(anthropic_response["content"][0]["type"], "thinking");
assert_eq!(
anthropic_response["content"][0]["thinking"],
"Need the current date before calling weather."
);
assert_eq!(anthropic_response["content"][1]["type"], "text");
assert_eq!(anthropic_response["content"][2]["type"], "tool_use");
assert_eq!(anthropic_response["content"][2]["id"], "call_date");
let follow_up_request = json!({
"model": "deepseek-v4-flash",
"max_tokens": 1024,
"messages": [{
"role": "assistant",
"content": anthropic_response["content"].clone()
}]
});
let replayed = anthropic_to_openai_with_reasoning_content(follow_up_request, true).unwrap();
let msg = &replayed["messages"][0];
assert_eq!(
msg["reasoning_content"],
"Need the current date before calling weather."
);
assert_eq!(msg["tool_calls"][0]["id"], "call_date");
assert_eq!(msg["tool_calls"][0]["function"]["name"], "get_date");
}
#[test]
fn test_model_passthrough() {
// 格式转换层只做结构转换,模型映射由上游 proxy::model_mapper 处理
@@ -8,19 +8,51 @@
//! - system prompt 使用 `instructions` 字段而非 system role message
//! - usage 字段命名与 Anthropic 一致 (input_tokens/output_tokens)
use crate::proxy::error::ProxyError;
use crate::proxy::{error::ProxyError, json_canonical::canonical_json_string};
use serde_json::{json, Value};
pub(crate) fn sanitize_anthropic_tool_use_input(name: &str, input: Value) -> Value {
if name != "Read" {
return input;
}
match input {
Value::Object(mut object) => {
if matches!(object.get("pages"), Some(Value::String(value)) if value.is_empty()) {
object.remove("pages");
}
Value::Object(object)
}
other => other,
}
}
pub(crate) fn sanitize_anthropic_tool_use_input_json(name: &str, raw: &str) -> String {
if name != "Read" || raw.is_empty() {
return raw.to_string();
}
let Ok(input) = serde_json::from_str::<Value>(raw) else {
return raw.to_string();
};
serde_json::to_string(&sanitize_anthropic_tool_use_input(name, input))
.unwrap_or_else(|_| raw.to_string())
}
/// Anthropic 请求 → OpenAI Responses 请求
///
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
/// `is_codex_oauth`: 当目标后端是 ChatGPT Plus/Pro 反代 (`chatgpt.com/backend-api/codex`) 时为 true。
/// 该后端强制要求 `store: false`,并要求 `include` 包含 `reasoning.encrypted_content`
/// 以便在无服务端状态下保持多轮 reasoning 上下文。
/// `codex_fast_mode`: 仅在 `is_codex_oauth` 为 true 时生效,控制是否注入
/// `service_tier = "priority"`。
pub fn anthropic_to_responses(
body: Value,
cache_key: Option<&str>,
is_codex_oauth: bool,
codex_fast_mode: bool,
) -> Result<Value, ProxyError> {
let mut result = json!({});
@@ -32,10 +64,12 @@ pub fn anthropic_to_responses(
// system → instructions (Responses API 使用 instructions 字段)
if let Some(system) = body.get("system") {
let instructions = if let Some(text) = system.as_str() {
text.to_string()
super::transform::strip_leading_anthropic_billing_header(text).to_string()
} else if let Some(arr) = system.as_array() {
arr.iter()
.filter_map(|msg| msg.get("text").and_then(|t| t.as_str()))
.map(super::transform::strip_leading_anthropic_billing_header)
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n\n")
} else {
@@ -125,10 +159,15 @@ pub fn anthropic_to_responses(
// (codex-rs 结构体根本没有这三个字段,OpenAI 自己的客户端不发它们)
// - instructions / tools / parallel_tool_calls: 必填字段,缺则兜底默认值
// cc-switch 的 transform 当前是"条件写入",可能产生缺失)
// - service_tier: 仅在 FAST mode 开启时写入 "priority"
// (与 OpenAI 官方 codex-rs 当前请求结构保持一致)
// - stream: 必须永远 truecodex-rs 硬编码 true,且 cc-switch 的
// SSE 解析层只处理流式响应,强制覆盖避免客户端误传 false)
if is_codex_oauth {
result["store"] = json!(false);
if codex_fast_mode {
result["service_tier"] = json!("priority");
}
const REASONING_MARKER: &str = "reasoning.encrypted_content";
let mut includes: Vec<Value> = body
@@ -203,18 +242,35 @@ pub(crate) fn map_responses_stop_reason(
{
"max_tokens"
}
"incomplete" => "end_turn",
_ => "end_turn",
})
}
/// Build Anthropic-style usage JSON from Responses API usage, including cache tokens.
///
/// Priority order:
/// **Robustness Features**:
/// - Handles null, missing, empty objects, and partial objects gracefully
/// - Supports OpenAI field name variants (prompt_tokens/completion_tokens) as fallbacks
/// - Always returns valid structure: {"input_tokens": N, "output_tokens": N}
/// - Preserves cache token fields even when input/output tokens are missing
///
/// **Field Name Resolution Priority**:
/// 1. input_tokens: Anthropic `input_tokens` → OpenAI `prompt_tokens` → default 0
/// 2. output_tokens: Anthropic `output_tokens` → OpenAI `completion_tokens` → default 0
/// 3. cache_read_input_tokens: Direct field → nested input_tokens_details.cached_tokens → prompt_tokens_details.cached_tokens
/// 4. cache_creation_input_tokens: Direct field only
///
/// **Cache Token Priority Order**:
/// 1. OpenAI nested details (`input_tokens_details.cached_tokens`, `prompt_tokens_details.cached_tokens`) as initial value
/// 2. Direct Anthropic-style fields (`cache_read_input_tokens`, `cache_creation_input_tokens`) override if present
///
/// **Logging**:
/// - Warns on empty objects {} or partial objects (only one field present)
/// - Debug logs when using OpenAI field name fallbacks
pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Value {
let u = match usage {
Some(v) if !v.is_null() => v,
Some(v) if !v.is_null() && v.is_object() => v,
_ => {
return json!({
"input_tokens": 0,
@@ -223,15 +279,56 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val
}
};
let input = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let output = u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
// Detect empty object {} and log warning
if u.as_object().map(|obj| obj.is_empty()).unwrap_or(false) {
log::warn!("[Responses] Empty usage object received, using defaults");
return json!({
"input_tokens": 0,
"output_tokens": 0
});
}
// Extract input_tokens with OpenAI field name fallback
// Priority: input_tokens (Anthropic) → prompt_tokens (OpenAI) → 0
let input = u
.get("input_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
let prompt_tokens = u.get("prompt_tokens").and_then(|v| v.as_u64());
if prompt_tokens.is_some() {
log::debug!(
"[Responses] Using OpenAI field name fallback 'prompt_tokens' for input_tokens"
);
}
prompt_tokens
})
.unwrap_or(0);
// Extract output_tokens with OpenAI field name fallback
// Priority: output_tokens (Anthropic) → completion_tokens (OpenAI) → 0
let output = u.get("output_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
let completion_tokens = u.get("completion_tokens").and_then(|v| v.as_u64());
if completion_tokens.is_some() {
log::debug!("[Responses] Using OpenAI field name fallback 'completion_tokens' for output_tokens");
}
completion_tokens
})
.unwrap_or(0);
// Log if only one field present (partial object). Streaming chunks legitimately
// arrive with partial usage, so this stays at debug level to avoid noise.
if (input == 0 && output > 0) || (input > 0 && output == 0) {
log::debug!("[Responses] Partial usage object: {:?}", u);
}
let mut result = json!({
"input_tokens": input,
"output_tokens": output
});
// Step 1: OpenAI nested details as fallback
// Step 1: OpenAI nested details as fallback for cache tokens
// OpenAI Responses API: input_tokens_details.cached_tokens
if let Some(cached) = u
.pointer("/input_tokens_details/cached_tokens")
@@ -250,6 +347,7 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val
}
// Step 2: Direct Anthropic-style fields override (authoritative if present)
// These preserve cache tokens even if input/output_tokens are missing
if let Some(v) = u.get("cache_read_input_tokens") {
result["cache_read_input_tokens"] = v.clone();
}
@@ -343,7 +441,7 @@ fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyErro
"type": "function_call",
"call_id": id,
"name": name,
"arguments": serde_json::to_string(&arguments).unwrap_or_default()
"arguments": canonical_json_string(&arguments)
}));
}
@@ -364,7 +462,7 @@ fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyErro
.unwrap_or("");
let output = match block.get("content") {
Some(Value::String(s)) => s.clone(),
Some(v) => serde_json::to_string(v).unwrap_or_default(),
Some(v) => canonical_json_string(v),
None => String::new(),
};
@@ -445,6 +543,7 @@ pub fn responses_to_anthropic(body: Value) -> Result<Value, ProxyError> {
.and_then(|a| a.as_str())
.unwrap_or("{}");
let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
let input = sanitize_anthropic_tool_use_input(name, input);
content.push(json!({
"type": "tool_use",
@@ -519,7 +618,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["model"], "gpt-4o");
assert_eq!(result["max_output_tokens"], 1024);
assert_eq!(result["input"][0]["role"], "user");
@@ -538,12 +637,54 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["instructions"], "You are a helpful assistant.");
// system should not appear in input
assert_eq!(result["input"].as_array().unwrap().len(), 1);
}
#[test]
fn test_anthropic_to_responses_strips_leading_billing_header_from_system_string() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"system": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n\nYou are a helpful assistant.",
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["instructions"], "You are a helpful assistant.");
}
#[test]
fn test_anthropic_to_responses_strips_billing_header_with_crlf() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"system": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\r\n\r\nYou are a helpful assistant.",
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["instructions"], "You are a helpful assistant.");
}
#[test]
fn test_anthropic_to_responses_keeps_non_leading_billing_header_text() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"system": "Keep this literal:\nx-anthropic-billing-header: example",
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(
result["instructions"],
"Keep this literal:\nx-anthropic-billing-header: example"
);
}
#[test]
fn test_anthropic_to_responses_with_system_array() {
let input = json!({
@@ -556,10 +697,45 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["instructions"], "Part 1\n\nPart 2");
}
#[test]
fn test_anthropic_to_responses_strips_billing_header_from_system_array_parts() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"system": [
{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n"},
{"type": "text", "text": "Stable prompt"}
],
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["instructions"], "Stable prompt");
}
#[test]
fn test_anthropic_to_responses_preserves_prompt_after_billing_header_in_same_part() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"system": [
{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n\nStable prompt part 1"},
{"type": "text", "text": "Stable prompt part 2"}
],
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(
result["instructions"],
"Stable prompt part 1\n\nStable prompt part 2"
);
}
#[test]
fn test_anthropic_to_responses_with_tools() {
let input = json!({
@@ -573,7 +749,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["name"], "get_weather");
assert!(result["tools"][0].get("parameters").is_some());
@@ -590,7 +766,7 @@ mod tests {
"tool_choice": {"type": "any"}
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["tool_choice"], "required");
}
@@ -603,7 +779,7 @@ mod tests {
"tool_choice": {"type": "tool", "name": "get_weather"}
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["tool_choice"]["type"], "function");
assert_eq!(result["tool_choice"]["name"], "get_weather");
}
@@ -622,7 +798,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
let input_arr = result["input"].as_array().unwrap();
// Should produce: assistant message (text) + function_call item
@@ -652,7 +828,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
let input_arr = result["input"].as_array().unwrap();
// Should produce: function_call_output item (lifted)
@@ -676,7 +852,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
let input_arr = result["input"].as_array().unwrap();
// thinking should be discarded, only text remains
@@ -699,7 +875,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
let content = result["input"][0]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "input_text");
@@ -759,6 +935,56 @@ mod tests {
assert_eq!(result["stop_reason"], "tool_use");
}
#[test]
fn test_responses_to_anthropic_read_drops_empty_pages() {
let input = json!({
"id": "resp_read",
"object": "response",
"status": "completed",
"model": "gpt-5.5",
"output": [{
"type": "function_call",
"id": "fc_read",
"call_id": "call_read",
"name": "Read",
"arguments": "{\"file_path\":\"/tmp/demo.py\",\"limit\":2000,\"offset\":0,\"pages\":\"\"}",
"status": "completed"
}]
});
let result = responses_to_anthropic(input).unwrap();
let tool_input = &result["content"][0]["input"];
assert_eq!(result["content"][0]["type"], "tool_use");
assert_eq!(result["content"][0]["name"], "Read");
assert_eq!(tool_input["file_path"], "/tmp/demo.py");
assert_eq!(tool_input["limit"], 2000);
assert_eq!(tool_input["offset"], 0);
assert!(tool_input.get("pages").is_none());
}
#[test]
fn test_responses_to_anthropic_preserves_empty_strings_for_other_tools() {
let input = json!({
"id": "resp_other",
"object": "response",
"status": "completed",
"model": "gpt-5.5",
"output": [{
"type": "function_call",
"id": "fc_other",
"call_id": "call_other",
"name": "search",
"arguments": "{\"query\":\"\"}",
"status": "completed"
}]
});
let result = responses_to_anthropic(input).unwrap();
assert_eq!(result["content"][0]["input"]["query"], "");
}
#[test]
fn test_responses_to_anthropic_with_refusal_block() {
let input = json!({
@@ -857,7 +1083,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["model"], "o3-mini");
}
@@ -869,7 +1095,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, Some("my-provider-id"), false).unwrap();
let result = anthropic_to_responses(input, Some("my-provider-id"), false, false).unwrap();
assert_eq!(result["prompt_cache_key"], "my-provider-id");
}
@@ -887,7 +1113,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert!(result["tools"][0].get("cache_control").is_none());
}
@@ -904,7 +1130,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert!(result["input"][0]["content"][0]
.get("cache_control")
.is_none());
@@ -966,7 +1192,7 @@ mod tests {
"max_tokens": 4096,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["max_output_tokens"], 4096);
assert!(result.get("max_completion_tokens").is_none());
}
@@ -980,7 +1206,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "xhigh");
}
@@ -994,7 +1220,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "low");
}
@@ -1007,7 +1233,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "low");
}
@@ -1020,7 +1246,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "medium");
}
@@ -1033,7 +1259,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "high");
}
@@ -1046,7 +1272,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "xhigh");
}
@@ -1059,7 +1285,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert!(result.get("reasoning").is_none());
}
@@ -1073,10 +1299,11 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
// store 必须显式为 falseChatGPT 后端拒绝 true
assert_eq!(result["store"], json!(false));
assert_eq!(result["service_tier"], json!("priority"));
// include 必须包含 reasoning.encrypted_content(无服务端状态下保持多轮 reasoning)
assert_eq!(result["include"], json!(["reasoning.encrypted_content"]));
@@ -1092,9 +1319,10 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert!(result.get("store").is_none());
assert!(result.get("service_tier").is_none());
assert!(result.get("include").is_none());
}
@@ -1108,7 +1336,7 @@ mod tests {
"include": ["something.else", "reasoning.encrypted_content"]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
let includes = result["include"]
.as_array()
.expect("include should be array");
@@ -1129,6 +1357,21 @@ mod tests {
assert_eq!(marker_count, 1, "marker 不应被重复添加(idempotent 失败)");
}
#[test]
fn test_anthropic_to_responses_codex_oauth_fast_mode_can_be_disabled() {
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true, false).unwrap();
assert_eq!(result["store"], json!(false));
assert!(result.get("service_tier").is_none());
assert_eq!(result["include"], json!(["reasoning.encrypted_content"]));
}
#[test]
fn test_anthropic_to_responses_codex_oauth_strips_max_output_tokens() {
// ChatGPT Plus/Pro 反代不接受 max_output_tokensOpenAI 官方 codex-rs 的
@@ -1140,7 +1383,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
assert!(
result.get("max_output_tokens").is_none(),
@@ -1158,7 +1401,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["max_output_tokens"], json!(1024));
}
@@ -1176,7 +1419,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
assert!(
result.get("temperature").is_none(),
@@ -1194,7 +1437,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
assert!(
result.get("top_p").is_none(),
@@ -1211,7 +1454,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
assert_eq!(
result["instructions"],
@@ -1245,7 +1488,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
assert_eq!(
result["instructions"],
@@ -1269,7 +1512,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
assert_eq!(
result["stream"],
@@ -1290,7 +1533,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert_eq!(result["temperature"], json!(0.7));
assert_eq!(result["top_p"], json!(0.9));
@@ -1306,7 +1549,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, false).unwrap();
assert!(
result.get("parallel_tool_calls").is_none(),
@@ -1326,4 +1569,106 @@ mod tests {
"非 Codex OAuth 路径下 tools 在客户端未送时不应被注入"
);
}
// ==================== Usage Field Robustness Tests ====================
#[test]
fn test_build_usage_from_null_parameter() {
let result = build_anthropic_usage_from_responses(None);
assert_eq!(result["input_tokens"], json!(0));
assert_eq!(result["output_tokens"], json!(0));
}
#[test]
fn test_build_usage_from_null_json_value() {
let result = build_anthropic_usage_from_responses(Some(&json!(null)));
assert_eq!(result["input_tokens"], json!(0));
assert_eq!(result["output_tokens"], json!(0));
}
#[test]
fn test_build_usage_from_empty_object() {
let result = build_anthropic_usage_from_responses(Some(&json!({})));
assert_eq!(result["input_tokens"], json!(0));
assert_eq!(result["output_tokens"], json!(0));
}
#[test]
fn test_build_usage_from_partial_input_only() {
let result = build_anthropic_usage_from_responses(Some(&json!({
"input_tokens": 100
})));
assert_eq!(result["input_tokens"], json!(100));
assert_eq!(result["output_tokens"], json!(0));
}
#[test]
fn test_build_usage_from_partial_output_only() {
let result = build_anthropic_usage_from_responses(Some(&json!({
"output_tokens": 50
})));
assert_eq!(result["input_tokens"], json!(0));
assert_eq!(result["output_tokens"], json!(50));
}
#[test]
fn test_build_usage_with_openai_field_names() {
let result = build_anthropic_usage_from_responses(Some(&json!({
"prompt_tokens": 120,
"completion_tokens": 45
})));
assert_eq!(result["input_tokens"], json!(120));
assert_eq!(result["output_tokens"], json!(45));
}
#[test]
fn test_build_usage_anthropic_names_precedence() {
let result = build_anthropic_usage_from_responses(Some(&json!({
"input_tokens": 100,
"prompt_tokens": 120,
"output_tokens": 50,
"completion_tokens": 45
})));
assert_eq!(result["input_tokens"], json!(100)); // Anthropic name takes precedence
assert_eq!(result["output_tokens"], json!(50)); // Anthropic name takes precedence
}
#[test]
fn test_build_usage_cache_tokens_from_nested_details() {
let result = build_anthropic_usage_from_responses(Some(&json!({
"input_tokens": 100,
"output_tokens": 50,
"input_tokens_details": {
"cached_tokens": 80
}
})));
assert_eq!(result["input_tokens"], json!(100));
assert_eq!(result["output_tokens"], json!(50));
assert_eq!(result["cache_read_input_tokens"], json!(80));
}
#[test]
fn test_build_usage_cache_tokens_direct_override() {
let result = build_anthropic_usage_from_responses(Some(&json!({
"input_tokens": 100,
"output_tokens": 50,
"input_tokens_details": {
"cached_tokens": 80
},
"cache_read_input_tokens": 100
})));
assert_eq!(result["cache_read_input_tokens"], json!(100)); // Direct field overrides nested
}
#[test]
fn test_build_usage_cache_tokens_without_input_output() {
let result = build_anthropic_usage_from_responses(Some(&json!({
"cache_read_input_tokens": 60,
"cache_creation_input_tokens": 20
})));
assert_eq!(result["input_tokens"], json!(0));
assert_eq!(result["output_tokens"], json!(0));
assert_eq!(result["cache_read_input_tokens"], json!(60));
assert_eq!(result["cache_creation_input_tokens"], json!(20));
}
}
+163 -121
View File
@@ -3,7 +3,7 @@
//! 统一处理流式和非流式 API 响应
use super::{
handler_config::UsageParserConfig,
handler_config::{StreamUsageEventFilter, UsageParserConfig},
handler_context::{RequestContext, StreamingTimeoutConfig},
hyper_client::ProxyResponse,
server::ProxyState,
@@ -211,7 +211,7 @@ pub async fn handle_streaming(
// 创建字节流
let stream = response.bytes_stream();
// 创建使用量收集器
// 创建使用量收集器;关闭 usage logging 时不要在流式热路径上解析每个 SSE event。
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
// 获取流式超时配置
@@ -219,7 +219,7 @@ pub async fn handle_streaming(
// 创建带日志和超时的透传流
let logged_stream =
create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector), timeout_config);
create_logged_passthrough_stream(stream, ctx.tag, usage_collector, timeout_config);
let body = axum::body::Body::from_stream(logged_stream);
match builder.body(body) {
@@ -255,63 +255,67 @@ pub async fn handle_non_streaming(
String::from_utf8_lossy(&body_bytes)
);
// 解析并记录使用量
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
// 解析使用量
if let Some(usage) = (parser_config.response_parser)(&json_value) {
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
let model = if let Some(ref m) = usage.model {
m.clone()
} else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) {
m.to_string()
} else {
ctx.request_model.clone()
};
// 解析并记录使用量。关闭 usage logging 时直接跳过,避免非流式响应整包 JSON parse。
if usage_logging_enabled(state) {
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
// 解析使用量
if let Some(usage) = (parser_config.response_parser)(&json_value) {
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
let model = if let Some(ref m) = usage.model {
m.clone()
} else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) {
m.to_string()
} else {
ctx.request_model.clone()
};
spawn_log_usage(
state,
ctx,
usage,
&model,
&ctx.request_model,
status.as_u16(),
false,
);
spawn_log_usage(
state,
ctx,
usage,
&model,
&ctx.request_model,
status.as_u16(),
false,
);
} else {
let model = json_value
.get("model")
.and_then(|m| m.as_str())
.unwrap_or(&ctx.request_model)
.to_string();
spawn_log_usage(
state,
ctx,
TokenUsage::default(),
&model,
&ctx.request_model,
status.as_u16(),
false,
);
log::debug!(
"[{}] 未能解析 usage 信息,跳过记录",
parser_config.app_type_str
);
}
} else {
let model = json_value
.get("model")
.and_then(|m| m.as_str())
.unwrap_or(&ctx.request_model)
.to_string();
log::debug!(
"[{}] <<< 响应 (非 JSON): {} bytes",
ctx.tag,
body_bytes.len()
);
spawn_log_usage(
state,
ctx,
TokenUsage::default(),
&model,
&ctx.request_model,
&ctx.request_model,
status.as_u16(),
false,
);
log::debug!(
"[{}] 未能解析 usage 信息,跳过记录",
parser_config.app_type_str
);
}
} else {
log::debug!(
"[{}] <<< 响应 (非 JSON): {} bytes",
ctx.tag,
body_bytes.len()
);
spawn_log_usage(
state,
ctx,
TokenUsage::default(),
&ctx.request_model,
&ctx.request_model,
status.as_u16(),
false,
);
log::debug!("[{}] usage logging 已关闭,跳过非流式 usage 解析", ctx.tag);
}
// 构建响应
@@ -360,13 +364,15 @@ struct SseUsageCollectorInner {
first_event_time: Mutex<Option<std::time::Instant>>,
start_time: std::time::Instant,
on_complete: UsageCallbackWithTiming,
should_collect: Option<StreamUsageEventFilter>,
finished: AtomicBool,
}
impl SseUsageCollector {
/// 创建新的使用量收集器
/// 创建使用量收集器`should_collect` 用来在 hot path 跳过与 usage 无关的事件。
pub fn new(
start_time: std::time::Instant,
should_collect: Option<StreamUsageEventFilter>,
callback: impl Fn(Vec<Value>, Option<u64>) + Send + Sync + 'static,
) -> Self {
let on_complete: UsageCallbackWithTiming = Arc::new(callback);
@@ -376,11 +382,19 @@ impl SseUsageCollector {
first_event_time: Mutex::new(None),
start_time,
on_complete,
should_collect,
finished: AtomicBool::new(false),
}),
}
}
pub fn should_collect(&self, data: &str) -> bool {
self.inner
.should_collect
.map(|filter| filter(data))
.unwrap_or(true)
}
/// 推送 SSE 事件
pub async fn push(&self, event: Value) {
// 记录首个事件时间
@@ -424,12 +438,16 @@ fn create_usage_collector(
state: &ProxyState,
status_code: u16,
parser_config: &UsageParserConfig,
) -> SseUsageCollector {
) -> Option<SseUsageCollector> {
let logging_enabled = state
.config
.try_read()
.map(|c| c.enable_logging)
.unwrap_or(true);
if !logging_enabled {
return None;
}
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let request_model = ctx.request_model.clone();
@@ -440,62 +458,63 @@ fn create_usage_collector(
let model_extractor = parser_config.model_extractor;
let session_id = ctx.session_id.clone();
SseUsageCollector::new(start_time, move |events, first_token_ms| {
if !logging_enabled {
return;
}
if let Some(usage) = stream_parser(&events) {
let model = model_extractor(&events, &request_model);
let latency_ms = start_time.elapsed().as_millis() as u64;
Some(SseUsageCollector::new(
start_time,
parser_config.stream_event_filter,
move |events, first_token_ms| {
if let Some(usage) = stream_parser(&events) {
let model = model_extractor(&events, &request_model);
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
let request_model = request_model.clone();
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
let request_model = request_model.clone();
tokio::spawn(async move {
log_usage_internal(
&state,
&provider_id,
app_type_str,
&model,
&request_model,
usage,
latency_ms,
first_token_ms,
true, // is_streaming
status_code,
Some(session_id),
)
.await;
});
} else {
let model = model_extractor(&events, &request_model);
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
let request_model = request_model.clone();
tokio::spawn(async move {
log_usage_internal(
&state,
&provider_id,
app_type_str,
&model,
&request_model,
usage,
latency_ms,
first_token_ms,
true, // is_streaming
status_code,
Some(session_id),
)
.await;
});
} else {
let model = model_extractor(&events, &request_model);
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
let request_model = request_model.clone();
tokio::spawn(async move {
log_usage_internal(
&state,
&provider_id,
app_type_str,
&model,
&request_model,
TokenUsage::default(),
latency_ms,
first_token_ms,
true, // is_streaming
status_code,
Some(session_id),
)
.await;
});
log::debug!("[{tag}] 流式响应缺少 usage 统计,跳过消费记录");
}
})
tokio::spawn(async move {
log_usage_internal(
&state,
&provider_id,
app_type_str,
&model,
&request_model,
TokenUsage::default(),
latency_ms,
first_token_ms,
true, // is_streaming
status_code,
Some(session_id),
)
.await;
});
log::debug!("[{tag}] 流式响应缺少 usage 统计,跳过消费记录");
}
},
))
}
/// 异步记录使用量
@@ -541,6 +560,14 @@ fn spawn_log_usage(
});
}
pub(crate) fn usage_logging_enabled(state: &ProxyState) -> bool {
state
.config
.try_read()
.map(|config| config.enable_logging)
.unwrap_or(true)
}
/// 内部使用量记录函数
#[allow(clippy::too_many_arguments)]
async fn log_usage_internal(
@@ -609,6 +636,8 @@ pub fn create_logged_passthrough_stream(
let mut buffer = String::new();
let mut utf8_remainder: Vec<u8> = Vec::new();
let mut collector = usage_collector;
let inspect_sse_events =
collector.is_some() || log::log_enabled!(log::Level::Debug);
let mut is_first_chunk = true;
// 超时配置
@@ -659,25 +688,36 @@ pub fn create_logged_passthrough_stream(
);
}
is_first_chunk = false;
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
if inspect_sse_events {
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
// 尝试解析并记录完整的 SSE 事件
while let Some(event_text) = take_sse_block(&mut buffer) {
if !event_text.trim().is_empty() {
// 提取 data 部分并尝试解析 JSON
for line in event_text.lines() {
if let Some(data) = strip_sse_field(line, "data") {
if data.trim() != "[DONE]" {
if let Ok(json_value) = serde_json::from_str::<Value>(data) {
if let Some(c) = &collector {
c.push(json_value.clone()).await;
// 尝试解析并记录完整的 SSE 事件
while let Some(event_text) = take_sse_block(&mut buffer) {
if !event_text.trim().is_empty() {
// 提取 data 部分;只有 usage collector 存在时才解析 JSON
for line in event_text.lines() {
if let Some(data) = strip_sse_field(line, "data") {
if data.trim() != "[DONE]" {
let collected = match &collector {
Some(c) if c.should_collect(data) => {
match serde_json::from_str::<Value>(data) {
Ok(json_value) => {
c.push(json_value).await;
true
}
Err(_) => false,
}
}
_ => false,
};
if collected {
log::debug!("[{tag}] <<< SSE 事件: {data}");
} else {
log::debug!("[{tag}] <<< SSE 数据: {data}");
}
log::debug!("[{tag}] <<< SSE 事件: {data}");
} else {
log::debug!("[{tag}] <<< SSE 数据: {data}");
log::debug!("[{tag}] <<< SSE: [DONE]");
}
} else {
log::debug!("[{tag}] <<< SSE: [DONE]");
}
}
}
@@ -894,9 +934,11 @@ mod tests {
db.set_pricing_model_source(app_type, "response").await?;
seed_pricing(&db)?;
let mut meta = ProviderMeta::default();
meta.cost_multiplier = Some("2".to_string());
meta.pricing_model_source = Some("request".to_string());
let meta = ProviderMeta {
cost_multiplier: Some("2".to_string()),
pricing_model_source: Some("request".to_string()),
..ProviderMeta::default()
};
insert_provider(&db, "provider-1", app_type, meta)?;
let state = build_state(db.clone());
+9
View File
@@ -285,6 +285,15 @@ impl ProxyServer {
// Claude API (支持带前缀和不带前缀两种格式)
.route("/v1/messages", post(handlers::handle_messages))
.route("/claude/v1/messages", post(handlers::handle_messages))
// Claude Desktop 3P 本地 gateway(独立 provider namespace
.route(
"/claude-desktop/v1/models",
get(handlers::handle_claude_desktop_models),
)
.route(
"/claude-desktop/v1/messages",
post(handlers::handle_claude_desktop_messages),
)
// OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀)
.route("/chat/completions", post(handlers::handle_chat_completions))
.route(
+2 -2
View File
@@ -7,7 +7,7 @@ use serde_json::{json, Value};
///
/// 三路径分发:
/// - skip: haiku 模型直接跳过
/// - adaptive: opus-4-6 / sonnet-4-6 使用 adaptive thinking
/// - adaptive: opus-4-7 / 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 {
@@ -24,7 +24,7 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
return;
}
if model.contains("opus-4-6") || model.contains("sonnet-4-6") {
if model.contains("opus-4-7") || 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"});
+9 -2
View File
@@ -298,9 +298,15 @@ pub struct CopilotOptimizerConfig {
/// Warmup 小模型降级(默认开启 — 与参考实现对齐,避免探针请求消耗 premium quota
#[serde(default = "default_true")]
pub warmup_downgrade: bool,
/// Warmup 降级使用的模型(默认 "gpt-4o-mini"
/// Warmup 降级使用的模型(默认 "gpt-5-mini"
#[serde(default = "default_warmup_model")]
pub warmup_model: String,
/// 请求前主动剥离 assistant 消息里的 thinking / redacted_thinking block
///
/// Copilot 走 OpenAI 兼容端点,thinking block 会被上游拒绝并触发 rectifier 反应式
/// 重试,那时第一次请求已经消耗了一次 premium quota。主动剥离避免这次浪费。
#[serde(default = "default_true")]
pub strip_thinking: bool,
}
fn default_warmup_model() -> String {
@@ -317,7 +323,8 @@ impl Default for CopilotOptimizerConfig {
deterministic_request_id: true,
subagent_detection: true,
warmup_downgrade: true,
warmup_model: "gpt-4o-mini".to_string(),
warmup_model: "gpt-5-mini".to_string(),
strip_thinking: true,
}
}
}
+9 -2
View File
@@ -233,14 +233,21 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult {
let total_balance = parse_f64_field(data, "totalBalance").unwrap_or(0.0);
let unit = if is_cn { "CNY" } else { "USD" };
let plan_name = if is_cn {
"SiliconFlow"
} else {
"SiliconFlow (EN)"
};
UsageResult {
success: true,
data: Some(vec![UsageData {
plan_name: Some("SiliconFlow".to_string()),
plan_name: Some(plan_name.to_string()),
remaining: Some(total_balance),
total: None,
used: None,
unit: Some("CNY".to_string()),
unit: Some(unit.to_string()),
is_valid: Some(true),
invalid_message: None,
extra: None,
+190 -29
View File
@@ -3,7 +3,9 @@
//! 支持 Kimi For Coding、智谱 GLM、MiniMax 的 Token Plan 额度查询。
//! 复用 subscription 模块的 SubscriptionQuota / QuotaTier 类型。
use super::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota};
use super::subscription::{
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT,
};
use std::time::{SystemTime, UNIX_EPOCH};
// ── 供应商检测 ──────────────────────────────────────────────
@@ -181,6 +183,60 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
// ── 智谱 GLM ────────────────────────────────────────────────
/// 把智谱 `data` 里的 `limits[]` 解析成 tier 列表。
///
/// 按 `nextResetTime` 升序后:第 0 条 = 五小时桶(`five_hour`)、
/// 第 1 条 = 每周桶(`weekly_limit`)。老套餐(2026-02-12 前订阅)只回 1 条
/// `TOKENS_LIMIT`,自然降级为仅展示 `five_hour`;新套餐回 2 条。
/// 缺失 `nextResetTime` 时按 `i64::MAX` 排到末位。
fn parse_zhipu_token_tiers(data: &serde_json::Value) -> Vec<QuotaTier> {
let mut token_limits: Vec<(i64, f64, Option<String>)> = Vec::new();
if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) {
for limit_item in limits {
let limit_type = limit_item
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("");
// 大小写不敏感比较:上游若把 "TOKENS_LIMIT" 改成小写或驼峰,依然能识别
if !limit_type.eq_ignore_ascii_case("TOKENS_LIMIT") {
continue;
}
let percentage = limit_item
.get("percentage")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let reset_ms = limit_item
.get("nextResetTime")
.and_then(|v| v.as_i64())
.unwrap_or(i64::MAX);
let reset_iso = if reset_ms == i64::MAX {
None
} else {
millis_to_iso8601(reset_ms)
};
token_limits.push((reset_ms, percentage, reset_iso));
}
}
token_limits.sort_by_key(|(reset, _, _)| *reset);
token_limits
.into_iter()
.enumerate()
.filter_map(|(idx, (_, percentage, resets_at))| {
let name = match idx {
0 => TIER_FIVE_HOUR,
1 => TIER_WEEKLY_LIMIT,
_ => return None, // 智谱当前最多两条 TOKENS_LIMIT,多余的忽略
};
Some(QuotaTier {
name: name.to_string(),
utilization: percentage,
resets_at,
})
})
.collect()
}
async fn query_zhipu(api_key: &str) -> SubscriptionQuota {
let client = crate::proxy::http_client::get();
@@ -237,34 +293,7 @@ async fn query_zhipu(api_key: &str) -> SubscriptionQuota {
None => return make_error("Missing 'data' field in response".to_string()),
};
let mut tiers = Vec::new();
if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) {
for limit_item in limits {
let limit_type = limit_item
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("");
let percentage = limit_item
.get("percentage")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let next_reset = limit_item
.get("nextResetTime")
.and_then(|v| v.as_i64())
.and_then(millis_to_iso8601);
if limit_type != "TOKENS_LIMIT" {
continue;
}
tiers.push(QuotaTier {
name: "five_hour".to_string(),
utilization: percentage,
resets_at: next_reset,
});
}
}
let tiers = parse_zhipu_token_tiers(data);
// 套餐等级存入 credential_message
let level = data
@@ -449,3 +478,135 @@ pub async fn get_coding_plan_quota(
Ok(quota)
}
#[cfg(test)]
mod tests {
use super::{parse_zhipu_token_tiers, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT};
use serde_json::json;
#[test]
fn zhipu_new_plan_two_tiers_sorted_by_reset_time() {
// 新套餐:两条 TOKENS_LIMITnextResetTime 较近的归 five_hour、较远的归 weekly_limit。
// 故意把"周限"放数组前面,验证不依赖输入顺序。
let data = json!({
"limits": [
{ "type": "TOKENS_LIMIT", "percentage": 53.0, "nextResetTime": 2_000_000_000_000_i64 },
{ "type": "TOKENS_LIMIT", "percentage": 44.0, "nextResetTime": 1_000_000_000_000_i64 },
{ "type": "TIME_LIMIT", "percentage": 7.0 },
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 44.0);
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
assert_eq!(tiers[1].utilization, 53.0);
}
#[test]
fn zhipu_old_plan_single_tier_falls_back_to_five_hour() {
// 老套餐(2026-02-12 前订阅):仅一条 TOKENS_LIMIT,无周限。
let data = json!({
"limits": [
{
"type": "TOKENS_LIMIT",
"percentage": 2.0,
"nextResetTime": 1_774_967_594_803_i64
},
{ "type": "TIME_LIMIT", "percentage": 0.0 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 1);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 2.0);
}
#[test]
fn zhipu_no_token_limits_returns_empty() {
let data = json!({ "limits": [{ "type": "TIME_LIMIT", "percentage": 5.0 }] });
assert!(parse_zhipu_token_tiers(&data).is_empty());
}
#[test]
fn zhipu_missing_reset_time_sorts_last() {
// 防御性:没有 nextResetTime 的条目排到末位,避免抢占 five_hour 槽位。
let data = json!({
"limits": [
{ "type": "TOKENS_LIMIT", "percentage": 99.0 },
{ "type": "TOKENS_LIMIT", "percentage": 10.0, "nextResetTime": 1_000_000_000_000_i64 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 10.0);
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
assert_eq!(tiers[1].utilization, 99.0);
assert!(tiers[1].resets_at.is_none());
}
#[test]
fn zhipu_type_is_case_insensitive() {
// 防御性:上游若把 "TOKENS_LIMIT" 改成 "tokens_limit"(仅大小写变化)仍能识别。
// 注意:分隔符差异(如 "TokensLimit" 去掉下划线)不在兼容范围。
let data = json!({
"limits": [
{ "type": "tokens_limit", "percentage": 12.0, "nextResetTime": 1_000_000_000_000_i64 },
{ "type": "Tokens_Limit", "percentage": 34.0, "nextResetTime": 2_000_000_000_000_i64 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 12.0);
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
assert_eq!(tiers[1].utilization, 34.0);
}
#[test]
fn zhipu_invalid_percentage_falls_back_to_zero() {
// percentage 为字符串或 null 时不应崩溃,按 0 处理(仍展示 tier,但用量为 0)。
let data = json!({
"limits": [
{ "type": "TOKENS_LIMIT", "percentage": "invalid", "nextResetTime": 1_000_000_000_000_i64 },
{ "type": "TOKENS_LIMIT", "percentage": null, "nextResetTime": 2_000_000_000_000_i64 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].utilization, 0.0);
assert_eq!(tiers[1].utilization, 0.0);
}
#[test]
fn zhipu_extreme_percentage_values_pass_through() {
// 负数 / 超 100 不做范围裁剪——下游渲染层负责显示策略,解析层只负责忠实搬运。
let data = json!({
"limits": [
{ "type": "TOKENS_LIMIT", "percentage": -5.0, "nextResetTime": 1_000_000_000_000_i64 },
{ "type": "TOKENS_LIMIT", "percentage": 150.0, "nextResetTime": 2_000_000_000_000_i64 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].utilization, -5.0);
assert_eq!(tiers[1].utilization, 150.0);
}
#[test]
fn zhipu_more_than_two_token_limits_keeps_first_two() {
// 防御性:智谱当前最多两条 TOKENS_LIMIT,若上游意外增加第三条应被丢弃,避免命名空缺。
let data = json!({
"limits": [
{ "type": "TOKENS_LIMIT", "percentage": 1.0, "nextResetTime": 1_000_000_000_000_i64 },
{ "type": "TOKENS_LIMIT", "percentage": 2.0, "nextResetTime": 2_000_000_000_000_i64 },
{ "type": "TOKENS_LIMIT", "percentage": 3.0, "nextResetTime": 3_000_000_000_000_i64 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
}
}
+7 -1
View File
@@ -121,6 +121,9 @@ impl ConfigService {
match app_type {
AppType::Codex => Self::sync_codex_live(config, &current_id, &provider)?,
AppType::Claude => Self::sync_claude_live(config, &current_id, &provider)?,
AppType::ClaudeDesktop => {
// Claude Desktop 3P profiles are managed by claude_desktop_config.
}
AppType::Gemini => Self::sync_gemini_live(config, &current_id, &provider)?,
AppType::OpenCode => {
// OpenCode uses additive mode, no live sync needed
@@ -130,6 +133,9 @@ impl ConfigService {
// OpenClaw uses additive mode, no live sync needed
// OpenClaw providers are managed directly in the config file
}
AppType::Hermes => {
// Hermes uses additive mode, no live sync needed
}
}
Ok(())
@@ -153,7 +159,7 @@ impl ConfigService {
}
let cfg_text = settings.get("config").and_then(Value::as_str);
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
crate::codex_config::write_codex_live_atomic_with_stable_provider(auth, cfg_text)?;
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
+62 -9
View File
@@ -40,6 +40,9 @@ impl McpService {
if prev_apps.opencode && !server.apps.opencode {
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
}
if prev_apps.hermes && !server.apps.hermes {
Self::remove_server_from_app(state, &server.id, &AppType::Hermes)?;
}
// 同步到各个启用的应用
Self::sync_server_to_apps(state, &server)?;
@@ -109,6 +112,9 @@ impl McpService {
AppType::Claude => {
mcp::sync_single_server_to_claude(&Default::default(), &server.id, &server.server)?;
}
AppType::ClaudeDesktop => {
log::debug!("Claude Desktop 3P profiles do not use CC Switch MCP sync, skipping");
}
AppType::Codex => {
// Codex uses TOML format, must use the correct function
mcp::sync_single_server_to_codex(&Default::default(), &server.id, &server.server)?;
@@ -128,6 +134,9 @@ impl McpService {
// Skip for now
log::debug!("OpenClaw MCP support is still in development, skipping sync");
}
AppType::Hermes => {
mcp::sync_single_server_to_hermes(&Default::default(), &server.id, &server.server)?;
}
}
Ok(())
}
@@ -148,6 +157,9 @@ impl McpService {
fn remove_server_from_app(_state: &AppState, id: &str, app: &AppType) -> Result<(), AppError> {
match app {
AppType::Claude => mcp::remove_server_from_claude(id)?,
AppType::ClaudeDesktop => {
log::debug!("Claude Desktop 3P profiles do not use CC Switch MCP sync, skipping");
}
AppType::Codex => mcp::remove_server_from_codex(id)?,
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
AppType::OpenCode => {
@@ -157,6 +169,9 @@ impl McpService {
// OpenClaw MCP support is still in development
log::debug!("OpenClaw MCP support is still in development, skipping remove");
}
AppType::Hermes => {
mcp::remove_server_from_hermes(id)?;
}
}
Ok(())
}
@@ -166,7 +181,7 @@ impl McpService {
let servers = Self::get_all_servers(state)?;
for app in AppType::all() {
if matches!(app, AppType::OpenClaw) {
if matches!(app, AppType::OpenClaw | AppType::ClaudeDesktop) {
continue;
}
@@ -259,8 +274,8 @@ impl McpService {
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
// 导入是读取已有配置,不应反向写回任何应用 live 配置
// 显式编辑、启用/禁用或手动同步时再执行写回。
}
}
}
@@ -297,8 +312,8 @@ impl McpService {
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
// 导入是读取已有配置,不应反向写回任何应用 live 配置
// 显式编辑、启用/禁用或手动同步时再执行写回。
}
}
}
@@ -335,8 +350,8 @@ impl McpService {
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
// 导入是读取已有配置,不应反向写回任何应用 live 配置
// 显式编辑、启用/禁用或手动同步时再执行写回。
}
}
}
@@ -373,8 +388,46 @@ impl McpService {
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
// 导入是读取已有配置,不应反向写回任何应用 live 配置
// 显式编辑、启用/禁用或手动同步时再执行写回。
}
}
}
Ok(new_count)
}
/// 从 Hermes 导入 MCP
pub fn import_from_hermes(state: &AppState) -> Result<usize, AppError> {
// 创建临时 MultiAppConfig 用于导入
let mut temp_config = crate::app_config::MultiAppConfig::default();
// 调用导入逻辑(从 mcp/hermes.rs
let count = crate::mcp::import_from_hermes(&mut temp_config)?;
let mut new_count = 0;
// 如果有导入的服务器,保存到数据库
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() {
// 已存在:仅启用 Hermes,不覆盖其他字段(与导入模块语义保持一致)
let to_save = if let Some(existing_server) = existing.get(&server.id) {
let mut merged = existing_server.clone();
merged.apps.hermes = true;
merged
} else {
// 真正的新服务器
new_count += 1;
server.clone()
};
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 导入是读取已有配置,不应反向写回任何应用的 live 配置。
// 显式编辑、启用/禁用或手动同步时再执行写回。
}
}
}
+2
View File
@@ -16,6 +16,7 @@ pub mod skill;
pub mod speedtest;
pub mod stream_check;
pub mod subscription;
pub mod usage_cache;
pub mod usage_stats;
pub mod webdav;
pub mod webdav_auto_sync;
@@ -30,6 +31,7 @@ pub use proxy::ProxyService;
#[allow(unused_imports)]
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
pub use speedtest::{EndpointLatency, SpeedtestService};
pub use usage_cache::UsageCache;
#[allow(unused_imports)]
pub use usage_stats::{
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
+295 -60
View File
@@ -1,8 +1,10 @@
//! 模型列表获取服务
//!
//! 通过 OpenAI 兼容的 GET /v1/models 端点获取供应商可用模型列表。
//! 主要面向第三方聚合站(硅基流动、OpenRouter 等)
//! 主要面向第三方聚合站(硅基流动、OpenRouter 等),以及把 Anthropic
//! 协议挂在兼容子路径上的官方供应商(DeepSeek、Kimi、智谱 GLM 等)。
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use std::time::Duration;
@@ -28,85 +30,184 @@ struct ModelEntry {
const FETCH_TIMEOUT_SECS: u64 = 15;
/// 404/405 响应体截断长度:避免把几十 KB HTML 404 页整页保留到错误串里。
const ERROR_BODY_MAX_CHARS: usize = 512;
/// 已知的「Anthropic 协议兼容子路径」后缀;按长度降序,最长前缀优先匹配。
/// baseURL 命中这些后缀时,候选列表会追加「剥离后缀再拼 /v1/models / /models」的版本。
const KNOWN_COMPAT_SUFFIXES: &[&str] = &[
"/api/claudecode",
"/api/anthropic",
"/apps/anthropic",
"/api/coding",
"/claudecode",
"/anthropic",
"/step_plan",
"/coding",
"/claude",
];
/// 获取供应商的可用模型列表
///
/// 使用 OpenAI 兼容的 GET /v1/models 端点。
/// 使用 OpenAI 兼容的 GET /v1/models 端点,按候选列表顺序尝试
pub async fn fetch_models(
base_url: &str,
api_key: &str,
is_full_url: bool,
models_url_override: Option<&str>,
) -> Result<Vec<FetchedModel>, String> {
if api_key.is_empty() {
return Err("API Key is required to fetch models".to_string());
}
let models_url = build_models_url(base_url, is_full_url)?;
let candidates = build_models_url_candidates(base_url, is_full_url, models_url_override)?;
let client = crate::proxy::http_client::get();
let mut last_err: Option<String> = None;
let response = client
.get(&models_url)
.header("Authorization", format!("Bearer {api_key}"))
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
.send()
.await
.map_err(|e| format!("Request failed: {e}"))?;
for url in &candidates {
log::debug!("[ModelFetch] Trying endpoint: {url}");
let response = match client
.get(url)
.header("Authorization", format!("Bearer {api_key}"))
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
.send()
.await
{
Ok(r) => r,
Err(e) => {
return Err(format!("Request failed: {e}"));
}
};
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
let status = response.status();
if status.is_success() {
let resp: ModelsResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {e}"))?;
let mut models: Vec<FetchedModel> = resp
.data
.unwrap_or_default()
.into_iter()
.map(|m| FetchedModel {
id: m.id,
owned_by: m.owned_by,
})
.collect();
models.sort_by(|a, b| a.id.cmp(&b.id));
return Ok(models);
}
if status == StatusCode::NOT_FOUND || status == StatusCode::METHOD_NOT_ALLOWED {
let body = truncate_body(response.text().await.unwrap_or_default());
last_err = Some(format!("HTTP {status}: {body}"));
continue;
}
let body = truncate_body(response.text().await.unwrap_or_default());
return Err(format!("HTTP {status}: {body}"));
}
let resp: ModelsResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {e}"))?;
let mut models: Vec<FetchedModel> = resp
.data
.unwrap_or_default()
.into_iter()
.map(|m| FetchedModel {
id: m.id,
owned_by: m.owned_by,
})
.collect();
models.sort_by(|a, b| a.id.cmp(&b.id));
Ok(models)
Err(format!(
"All candidates failed: {}",
last_err.unwrap_or_else(|| "no candidates".to_string())
))
}
/// 构造 /v1/models 的完整 URL
fn build_models_url(base_url: &str, is_full_url: bool) -> Result<String, String> {
let trimmed = base_url.trim().trim_end_matches('/');
/// 构造「模型列表端点」的候选 URL 列表
///
/// 候选顺序:
/// 1. `models_url_override` 非空 → 只返回它
/// 2. baseURL 直接拼 `/v1/models`(若已有 `/v1` 结尾则拼 `/models`
/// 3. 若 baseURL 命中 [`KNOWN_COMPAT_SUFFIXES`],剥离后缀再拼 `/v1/models`
/// 4. 同上,但拼 `/models`(部分站点如 DeepSeek 官方只暴露 `/models`
///
/// 结果已去重且保持首次出现顺序。
pub fn build_models_url_candidates(
base_url: &str,
is_full_url: bool,
models_url_override: Option<&str>,
) -> Result<Vec<String>, String> {
if let Some(raw) = models_url_override {
let trimmed = raw.trim();
if !trimmed.is_empty() {
return Ok(vec![trimmed.to_string()]);
}
}
let trimmed = base_url.trim().trim_end_matches('/');
if trimmed.is_empty() {
return Err("Base URL is empty".to_string());
}
let mut candidates: Vec<String> = Vec::new();
if is_full_url {
// 尝试从完整端点 URL 推导 API 根路径
// 例如: https://proxy.example.com/v1/chat/completions → https://proxy.example.com/v1/models
if let Some(idx) = trimmed.find("/v1/") {
return Ok(format!("{}/v1/models", &trimmed[..idx]));
}
// 如果没有 /v1/ 路径,直接去掉最后一段路径
if let Some(idx) = trimmed.rfind('/') {
candidates.push(format!("{}/v1/models", &trimmed[..idx]));
} else if let Some(idx) = trimmed.rfind('/') {
let root = &trimmed[..idx];
if root.contains("://") && root.len() > root.find("://").unwrap() + 3 {
return Ok(format!("{root}/v1/models"));
candidates.push(format!("{root}/v1/models"));
}
}
return Err("Cannot derive models endpoint from full URL".to_string());
if candidates.is_empty() {
return Err("Cannot derive models endpoint from full URL".to_string());
}
return Ok(candidates);
}
// 常规情况: base_url 是 API 根路径
// 如果已经包含 /v1 路径,直接追加 /models
if trimmed.ends_with("/v1") {
return Ok(format!("{trimmed}/models"));
let primary = if trimmed.ends_with("/v1") {
format!("{trimmed}/models")
} else {
format!("{trimmed}/v1/models")
};
candidates.push(primary);
if let Some(stripped) = strip_compat_suffix(trimmed) {
let root = stripped.trim_end_matches('/');
if !root.is_empty() && root.contains("://") {
candidates.push(format!("{root}/v1/models"));
candidates.push(format!("{root}/models"));
}
}
Ok(format!("{trimmed}/v1/models"))
// 候选最多 3 条,线性去重即可,不值得上 HashSet。
let mut unique: Vec<String> = Vec::with_capacity(candidates.len());
for url in candidates {
if !unique.iter().any(|u| u == &url) {
unique.push(url);
}
}
Ok(unique)
}
/// 截断响应体到 [`ERROR_BODY_MAX_CHARS`] 字符,避免 HTML 404 页占用错误串。
fn truncate_body(body: String) -> String {
if body.chars().count() <= ERROR_BODY_MAX_CHARS {
body
} else {
let mut s: String = body.chars().take(ERROR_BODY_MAX_CHARS).collect();
s.push('…');
s
}
}
/// 若 baseURL 以任一已知兼容子路径结尾,返回剥离后的剩余部分;否则 `None`。
///
/// 依赖 [`KNOWN_COMPAT_SUFFIXES`] 按长度降序排列,确保最长前缀优先命中
/// (否则 `/anthropic` 会提前匹配掉 `/api/anthropic` 的场景)。
fn strip_compat_suffix(base_url: &str) -> Option<&str> {
for suffix in KNOWN_COMPAT_SUFFIXES {
if base_url.ends_with(*suffix) {
return Some(&base_url[..base_url.len() - suffix.len()]);
}
}
None
}
#[cfg(test)]
@@ -114,40 +215,174 @@ mod tests {
use super::*;
#[test]
fn test_build_models_url_basic() {
fn test_candidates_plain_root() {
let c = build_models_url_candidates("https://api.siliconflow.cn", false, None).unwrap();
assert_eq!(c, vec!["https://api.siliconflow.cn/v1/models"]);
}
#[test]
fn test_candidates_trailing_slash() {
let c = build_models_url_candidates("https://api.example.com/", false, None).unwrap();
assert_eq!(c, vec!["https://api.example.com/v1/models"]);
}
#[test]
fn test_candidates_with_v1() {
let c = build_models_url_candidates("https://api.example.com/v1", false, None).unwrap();
assert_eq!(c, vec!["https://api.example.com/v1/models"]);
}
#[test]
fn test_candidates_full_url() {
let c = build_models_url_candidates(
"https://proxy.example.com/v1/chat/completions",
true,
None,
)
.unwrap();
assert_eq!(c, vec!["https://proxy.example.com/v1/models"]);
}
#[test]
fn test_candidates_empty() {
assert!(build_models_url_candidates("", false, None).is_err());
}
#[test]
fn test_candidates_override_returns_single() {
let c = build_models_url_candidates(
"https://api.deepseek.com/anthropic",
false,
Some("https://api.deepseek.com/models"),
)
.unwrap();
assert_eq!(c, vec!["https://api.deepseek.com/models"]);
}
#[test]
fn test_candidates_override_empty_falls_through() {
let c =
build_models_url_candidates("https://api.siliconflow.cn", false, Some(" ")).unwrap();
assert_eq!(c, vec!["https://api.siliconflow.cn/v1/models"]);
}
#[test]
fn test_candidates_deepseek_strip_anthropic() {
let c =
build_models_url_candidates("https://api.deepseek.com/anthropic", false, None).unwrap();
assert_eq!(
build_models_url("https://api.siliconflow.cn", false).unwrap(),
"https://api.siliconflow.cn/v1/models"
c,
vec![
"https://api.deepseek.com/anthropic/v1/models",
"https://api.deepseek.com/v1/models",
"https://api.deepseek.com/models",
]
);
}
#[test]
fn test_build_models_url_trailing_slash() {
fn test_candidates_zhipu_strip_api_anthropic() {
let c = build_models_url_candidates("https://open.bigmodel.cn/api/anthropic", false, None)
.unwrap();
assert_eq!(
build_models_url("https://api.example.com/", false).unwrap(),
"https://api.example.com/v1/models"
c,
vec![
"https://open.bigmodel.cn/api/anthropic/v1/models",
"https://open.bigmodel.cn/v1/models",
"https://open.bigmodel.cn/models",
]
);
}
#[test]
fn test_build_models_url_with_v1() {
fn test_candidates_bailian_strip_apps_anthropic() {
let c = build_models_url_candidates(
"https://dashscope.aliyuncs.com/apps/anthropic",
false,
None,
)
.unwrap();
assert_eq!(
build_models_url("https://api.example.com/v1", false).unwrap(),
"https://api.example.com/v1/models"
c,
vec![
"https://dashscope.aliyuncs.com/apps/anthropic/v1/models",
"https://dashscope.aliyuncs.com/v1/models",
"https://dashscope.aliyuncs.com/models",
]
);
}
#[test]
fn test_build_models_url_full_url() {
fn test_candidates_stepfun_strip_step_plan() {
let c =
build_models_url_candidates("https://api.stepfun.com/step_plan", false, None).unwrap();
assert_eq!(
build_models_url("https://proxy.example.com/v1/chat/completions", true).unwrap(),
"https://proxy.example.com/v1/models"
c,
vec![
"https://api.stepfun.com/step_plan/v1/models",
"https://api.stepfun.com/v1/models",
"https://api.stepfun.com/models",
]
);
}
#[test]
fn test_build_models_url_empty() {
assert!(build_models_url("", false).is_err());
fn test_candidates_doubao_strip_api_coding() {
let c = build_models_url_candidates(
"https://ark.cn-beijing.volces.com/api/coding",
false,
None,
)
.unwrap();
assert_eq!(
c,
vec![
"https://ark.cn-beijing.volces.com/api/coding/v1/models",
"https://ark.cn-beijing.volces.com/v1/models",
"https://ark.cn-beijing.volces.com/models",
]
);
}
#[test]
fn test_candidates_rightcode_strip_claude() {
let c = build_models_url_candidates("https://www.right.codes/claude", false, None).unwrap();
assert_eq!(
c,
vec![
"https://www.right.codes/claude/v1/models",
"https://www.right.codes/v1/models",
"https://www.right.codes/models",
]
);
}
#[test]
fn test_candidates_longer_suffix_wins() {
// baseURL 以 /api/anthropic 结尾时,应剥离整个 /api/anthropic
// 而不是只剥离 /anthropic(那样会得到残缺的 https://.../api 根)。
let c = build_models_url_candidates("https://api.z.ai/api/anthropic", false, None).unwrap();
assert_eq!(
c,
vec![
"https://api.z.ai/api/anthropic/v1/models",
"https://api.z.ai/v1/models",
"https://api.z.ai/models",
]
);
}
#[test]
fn test_candidates_no_suffix_no_strip() {
let c = build_models_url_candidates("https://openrouter.ai/api", false, None).unwrap();
assert_eq!(c, vec!["https://openrouter.ai/api/v1/models"]);
}
#[test]
fn test_candidates_deduplicate() {
// 虚构 casebaseURL 就是 "scheme://host",剥不出子路径,应只有一个候选。
let c = build_models_url_candidates("https://host.example.com", false, None).unwrap();
assert_eq!(c.len(), 1);
}
#[test]
+189 -27
View File
@@ -8,7 +8,9 @@ use serde_json::{json, Value};
use toml_edit::{DocumentMut, Item, TableLike};
use crate::app_config::AppType;
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
use crate::codex_config::{
get_codex_auth_path, get_codex_config_path, write_codex_live_atomic_with_stable_provider,
};
use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file};
use crate::database::Database;
use crate::error::AppError;
@@ -42,6 +44,8 @@ pub(crate) fn provider_exists_in_live_config(
.map(|providers| providers.contains_key(provider_id)),
AppType::OpenClaw => crate::openclaw_config::get_providers()
.map(|providers| providers.contains_key(provider_id)),
AppType::Hermes => crate::hermes_config::get_providers()
.map(|providers| providers.contains_key(provider_id)),
_ => Ok(false),
}
}
@@ -345,7 +349,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet:
}
_ => false,
},
AppType::OpenCode | AppType::OpenClaw => false,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => false,
}
}
@@ -415,7 +419,9 @@ pub(crate) fn remove_common_config_from_settings(
}
Ok(result)
}
AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
Ok(settings.clone())
}
}
}
@@ -470,7 +476,9 @@ fn apply_common_config_to_settings(
}
Ok(result)
}
AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
Ok(settings.clone())
}
}
}
@@ -509,6 +517,16 @@ pub(crate) fn write_live_with_common_config(
effective_provider.settings_config =
build_effective_settings_with_common_config(db, app_type, provider)?;
if matches!(app_type, AppType::ClaudeDesktop) {
crate::claude_desktop_config::apply_provider(db, &effective_provider)?;
log::info!(
"Claude Desktop 3P profile '{}' written for provider '{}'",
crate::claude_desktop_config::PROFILE_ID,
effective_provider.id
);
return Ok(());
}
write_live_snapshot(app_type, &effective_provider)
}
@@ -526,29 +544,55 @@ pub(crate) fn strip_common_config_from_live_settings(
app_type.as_str(),
provider.id
);
return live_settings;
return restore_live_settings_for_provider_backfill(app_type, provider, live_settings);
}
};
if !provider_uses_common_config(app_type, provider, snippet.as_deref()) {
return live_settings;
}
let Some(snippet_text) = snippet.as_deref() else {
return live_settings;
let backfill_settings = if provider_uses_common_config(app_type, provider, snippet.as_deref()) {
match snippet.as_deref() {
Some(snippet_text) => {
match remove_common_config_from_settings(app_type, &live_settings, snippet_text) {
Ok(settings) => settings,
Err(err) => {
log::warn!(
"Failed to strip common config for {} provider '{}': {err}",
app_type.as_str(),
provider.id
);
live_settings
}
}
}
None => live_settings,
}
} else {
live_settings
};
match remove_common_config_from_settings(app_type, &live_settings, snippet_text) {
Ok(settings) => settings,
Err(err) => {
log::warn!(
"Failed to strip common config for {} provider '{}': {err}",
app_type.as_str(),
provider.id
);
live_settings
}
restore_live_settings_for_provider_backfill(app_type, provider, backfill_settings)
}
fn restore_live_settings_for_provider_backfill(
app_type: &AppType,
provider: &Provider,
live_settings: Value,
) -> Value {
if !matches!(app_type, AppType::Codex) {
return live_settings;
}
let mut settings = live_settings;
if let Err(err) = crate::codex_config::restore_codex_settings_config_model_provider_for_backfill(
&mut settings,
&provider.settings_config,
) {
log::warn!(
"Failed to restore Codex provider id while backfilling '{}': {err}",
provider.id
);
}
settings
}
pub(crate) fn normalize_provider_common_config_for_storage(
@@ -669,6 +713,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
write_json_file(&path, &settings)?;
}
AppType::ClaudeDesktop => {
return Err(AppError::localized(
"claude_desktop.live.requires_db_context",
"Claude Desktop 配置写入需要通过供应商切换流程执行",
"Claude Desktop configuration must be written through the provider switch flow",
));
}
AppType::Codex => {
let obj = provider
.settings_config
@@ -681,10 +732,7 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
})?;
let auth_path = get_codex_auth_path();
write_json_file(&auth_path, auth)?;
let config_path = get_codex_config_path();
std::fs::write(&config_path, config_str).map_err(|e| AppError::io(&config_path, e))?;
write_codex_live_atomic_with_stable_provider(auth, Some(config_str))?;
}
AppType::Gemini => {
// Delegate to write_gemini_live which handles env file writing correctly
@@ -790,6 +838,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
}
}
}
AppType::Hermes => {
crate::hermes_config::set_provider(&provider.id, provider.settings_config.clone())?;
log::debug!("Hermes provider '{}' written to live config", provider.id);
}
}
Ok(())
}
@@ -922,6 +974,11 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
}
read_json_file(&path)
}
AppType::ClaudeDesktop => Err(AppError::localized(
"claude_desktop.live.read_unsupported",
"Claude Desktop 3P 配置不支持作为通用 live 配置导入,请使用“从 Claude 导入兼容供应商”。",
"Claude Desktop 3P configuration cannot be imported as a generic live config. Use 'Import compatible providers from Claude' instead.",
)),
AppType::Gemini => {
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
@@ -985,6 +1042,19 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
let config = read_openclaw_config()?;
Ok(config)
}
AppType::Hermes => {
let config_path = crate::hermes_config::get_hermes_config_path();
if !config_path.exists() {
return Err(AppError::localized(
"hermes.config.missing",
"Hermes 配置文件不存在",
"Hermes configuration file not found",
));
}
let yaml_config = crate::hermes_config::read_hermes_config()?;
let config = crate::hermes_config::yaml_to_json(&yaml_config)?;
Ok(config)
}
}
}
@@ -1034,6 +1104,13 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
let _ = normalize_claude_models_in_value(&mut v);
v
}
AppType::ClaudeDesktop => {
return Err(AppError::localized(
"claude_desktop.import_unsupported",
"Claude Desktop 3P 配置不能通过通用导入读取,请使用“从 Claude 导入兼容供应商”。",
"Claude Desktop 3P config cannot be imported through the generic import flow. Use 'Import compatible providers from Claude' instead.",
));
}
AppType::Gemini => {
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
@@ -1067,8 +1144,8 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
"config": config_obj
})
}
// OpenCode and OpenClaw use additive mode and are handled by early return above
AppType::OpenCode | AppType::OpenClaw => {
// OpenCode, OpenClaw and Hermes use additive mode and are handled by early return above
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
unreachable!("additive mode apps are handled by early return")
}
};
@@ -1085,10 +1162,27 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
state
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
crate::settings::set_current_provider(&app_type, Some(provider.id.as_str()))?;
Ok(true) // 真正导入了
}
/// Decide whether startup should auto-import the current live config as `default`.
///
/// This is intentionally stricter than the manual import path:
/// if the app already has any provider row at all (including official seeds),
/// startup must skip auto-import to avoid recreating `default` on each launch.
pub fn should_import_default_config_on_startup(
state: &AppState,
app_type: &AppType,
) -> Result<bool, AppError> {
if app_type.is_additive_mode() {
return Ok(false);
}
Ok(!state.db.has_any_provider_for_app(app_type.as_str())?)
}
/// Write Gemini live configuration with authentication handling
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
use crate::gemini_config::{
@@ -1316,6 +1410,74 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
Ok(imported)
}
/// Import all providers from Hermes live config to database
///
/// This imports existing providers from ~/.hermes/config.yaml
/// into the CC Switch database. Each provider found will be added to the
/// database with is_current set to false.
pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppError> {
use crate::hermes_config;
let providers = hermes_config::get_providers()?;
if providers.is_empty() {
return Ok(0);
}
let mut imported = 0;
let existing_ids = state.db.get_provider_ids("hermes")?;
for (name, config) in providers {
// Validate: skip entries with empty name
if name.trim().is_empty() {
log::warn!("Skipping Hermes provider with empty name");
continue;
}
// Skip if already exists in database
if existing_ids.contains(&name) {
log::debug!("Hermes provider '{name}' already exists in database, skipping");
continue;
}
// Create provider
let mut provider = Provider::with_id(name.clone(), name.clone(), config, None);
provider.meta = Some(crate::provider::ProviderMeta {
live_config_managed: Some(true),
..Default::default()
});
// Save to database
if let Err(e) = state.db.save_provider("hermes", &provider) {
log::warn!("Failed to import Hermes provider '{name}': {e}");
continue;
}
imported += 1;
log::info!("Imported Hermes provider '{name}' from live config");
}
Ok(imported)
}
/// Remove a Hermes provider from live config
///
/// This removes a specific provider from ~/.hermes/config.yaml
/// without affecting other providers in the file.
pub fn remove_hermes_provider_from_live(provider_id: &str) -> Result<(), AppError> {
use crate::hermes_config;
// Check if Hermes config directory exists
if !hermes_config::get_hermes_dir().exists() {
log::debug!("Hermes config directory doesn't exist, skipping removal of '{provider_id}'");
return Ok(());
}
hermes_config::remove_provider(provider_id)?;
log::info!("Hermes provider '{provider_id}' removed from live config");
Ok(())
}
/// Remove an OpenClaw provider from live config
///
/// This removes a specific provider from ~/.openclaw/openclaw.json
+66 -7
View File
@@ -21,8 +21,9 @@ use crate::store::AppState;
// Re-export sub-module functions for external access
pub use live::{
import_default_config, import_openclaw_providers_from_live,
import_opencode_providers_from_live, read_live_settings, sync_current_to_live,
import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live,
import_opencode_providers_from_live, read_live_settings,
should_import_default_config_on_startup, sync_current_to_live,
};
// Internal re-exports (pub(crate))
@@ -35,7 +36,8 @@ pub(crate) use live::{
// Internal re-exports
use live::{
remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live,
remove_hermes_provider_from_live, remove_openclaw_provider_from_live,
remove_opencode_provider_from_live, write_gemini_live,
};
use usage::validate_usage_script;
@@ -1282,6 +1284,7 @@ impl ProviderService {
match app_type {
AppType::OpenCode => remove_opencode_provider_from_live(id)?,
AppType::OpenClaw => remove_openclaw_provider_from_live(id)?,
AppType::Hermes => remove_hermes_provider_from_live(id)?,
_ => {}
}
}
@@ -1344,6 +1347,9 @@ impl ProviderService {
AppType::OpenClaw => {
remove_openclaw_provider_from_live(id)?;
}
AppType::Hermes => {
remove_hermes_provider_from_live(id)?;
}
_ => {
return Err(AppError::Message(format!(
"App {} does not support remove from live config",
@@ -1391,6 +1397,10 @@ impl ProviderService {
return Self::switch_normal(state, app_type, id, &providers);
}
if matches!(app_type, AppType::ClaudeDesktop) {
return Self::switch_normal(state, app_type, id, &providers);
}
// Check if proxy takeover mode is active AND proxy server is actually running
// Both conditions must be true to use hot-switch mode
// Use blocking wait since this is a sync function
@@ -1518,6 +1528,25 @@ impl ProviderService {
// Sync to live (write_gemini_live handles security flag internally for Gemini)
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
// Hermes is additive, so "switching" doesn't overwrite a live config file
// — we instead update the top-level `model:` section to point at this
// provider's first declared model. Without this, clicking "switch" would
// only shuffle entries in custom_providers[] while Hermes keeps using
// whatever `model.provider` was set before.
if matches!(app_type, AppType::Hermes) {
if let Err(e) =
crate::hermes_config::apply_switch_defaults(&provider.id, &provider.settings_config)
{
log::warn!(
"Failed to update Hermes model defaults after switching to '{}': {e}",
provider.id
);
result
.warnings
.push(format!("hermes_model_defaults_failed:{}", provider.id));
}
}
// For additive-mode providers that were DB-only (live_config_managed == Some(false)),
// flip the flag to true now that the provider has been successfully written to the live
// file. This ensures sync_all_providers_to_live() will include it on future syncs.
@@ -1532,6 +1561,7 @@ impl ProviderService {
let rollback_result = match app_type {
AppType::OpenCode => remove_opencode_provider_from_live(&provider.id),
AppType::OpenClaw => remove_openclaw_provider_from_live(&provider.id),
AppType::Hermes => remove_hermes_provider_from_live(&provider.id),
_ => Ok(()),
};
@@ -1704,10 +1734,12 @@ impl ProviderService {
match app_type {
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
AppType::ClaudeDesktop => Ok(String::new()),
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),
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
}
}
@@ -1718,10 +1750,12 @@ impl ProviderService {
) -> Result<String, AppError> {
match app_type {
AppType::Claude => Self::extract_claude_common_config(settings_config),
AppType::ClaudeDesktop => Ok(String::new()),
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),
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
}
}
@@ -1734,9 +1768,9 @@ impl ProviderService {
// Auth
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
// Models (5 fields)
// Models (4 fields + 1 legacy)
"ANTHROPIC_MODEL",
"ANTHROPIC_REASONING_MODEL",
"ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
@@ -1906,6 +1940,13 @@ impl ProviderService {
import_default_config(state, app_type)
}
pub fn should_import_default_config_on_startup(
state: &AppState,
app_type: &AppType,
) -> Result<bool, AppError> {
should_import_default_config_on_startup(state, app_type)
}
/// Read current live settings (re-export)
pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
read_live_settings(app_type)
@@ -2021,6 +2062,9 @@ impl ProviderService {
));
}
}
AppType::ClaudeDesktop => {
crate::claude_desktop_config::validate_provider(provider)?;
}
AppType::Codex => {
let settings = provider.settings_config.as_object().ok_or_else(|| {
AppError::localized(
@@ -2087,6 +2131,16 @@ impl ProviderService {
));
}
}
AppType::Hermes => {
// Hermes: accept any JSON object for now
if !provider.settings_config.is_object() {
return Err(AppError::localized(
"provider.hermes.settings.not_object",
"Hermes 配置必须是 JSON 对象",
"Hermes configuration must be a JSON object",
));
}
}
}
// Validate and clean UsageScript configuration (common for all app types)
@@ -2145,6 +2199,11 @@ impl ProviderService {
Ok((api_key, base_url))
}
AppType::ClaudeDesktop => {
let credentials =
crate::claude_desktop_config::direct_gateway_credentials(provider)?;
Ok((credentials.api_key, credentials.base_url))
}
AppType::Codex => {
let auth = provider
.settings_config
@@ -2258,8 +2317,8 @@ impl ProviderService {
Ok((api_key, base_url))
}
AppType::OpenClaw => {
// OpenClaw uses apiKey and baseUrl directly on the object
AppType::OpenClaw | AppType::Hermes => {
// OpenClaw/Hermes use apiKey and baseUrl directly on the object
let api_key = provider
.settings_config
.get("apiKey")
+173 -76
View File
@@ -27,7 +27,7 @@ const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。
const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [
"ANTHROPIC_MODEL",
"ANTHROPIC_REASONING_MODEL",
"ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
@@ -466,14 +466,7 @@ impl ProxyService {
AppType::Claude => self.read_claude_live()?,
AppType::Codex => self.read_codex_live()?,
AppType::Gemini => self.read_gemini_live()?,
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features
return Err("OpenClaw 不支持代理功能".to_string());
}
_ => return Err("该应用不支持代理功能".to_string()),
};
self.sync_live_config_to_provider(app_type, &live_config)
@@ -687,12 +680,7 @@ impl ProxyService {
}
}
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features, skip silently
}
_ => {}
}
Ok(())
@@ -871,14 +859,7 @@ impl ProxyService {
AppType::Claude => ("claude", self.read_claude_live()?),
AppType::Codex => ("codex", self.read_codex_live()?),
AppType::Gemini => ("gemini", self.read_gemini_live()?),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features
return Err("OpenClaw 不支持代理功能".to_string());
}
_ => return Err("该应用不支持代理功能".to_string()),
};
let json_str = serde_json::to_string(&config)
@@ -1019,14 +1000,7 @@ impl ProxyService {
self.write_gemini_live(&live_config)?;
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features
return Err("OpenClaw 不支持代理功能".to_string());
}
_ => return Err("该应用不支持代理功能".to_string()),
}
Ok(())
@@ -1076,12 +1050,7 @@ impl ProxyService {
let _ = self.write_gemini_live(&live_config);
}
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features, skip silently
}
_ => {}
}
Ok(())
@@ -1119,12 +1088,7 @@ impl ProxyService {
log::info!("Gemini Live 配置已恢复");
}
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features, skip silently
}
_ => {}
}
Ok(())
@@ -1213,14 +1177,7 @@ impl ProxyService {
AppType::Claude => self.write_claude_live(config),
AppType::Codex => self.write_codex_live(config),
AppType::Gemini => self.write_gemini_live(config),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
Err("OpenCode 不支持代理功能".to_string())
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features
Err("OpenClaw 不支持代理功能".to_string())
}
_ => Err("该应用不支持代理功能".to_string()),
}
}
@@ -1238,14 +1195,7 @@ impl ProxyService {
Ok(config) => Self::is_gemini_live_taken_over(&config),
Err(_) => false,
},
AppType::OpenCode => {
// OpenCode doesn't support proxy takeover
false
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy takeover
false
}
_ => false,
}
}
@@ -1285,14 +1235,7 @@ impl ProxyService {
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
Ok(())
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features
Ok(())
}
_ => Ok(()),
}
}
@@ -1509,20 +1452,33 @@ impl ProxyService {
.map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?;
if matches!(app_type_enum, AppType::Codex) {
let existing_backup = self
let existing_backup_value = self
.db
.get_live_backup(app_type)
.await
.map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?;
.map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?
.map(|backup| {
serde_json::from_str::<Value>(&backup.original_config)
.map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))
})
.transpose()?;
if let Some(existing_backup) = existing_backup {
let existing_value: Value = serde_json::from_str(&existing_backup.original_config)
.map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))?;
if let Some(existing_value) = existing_backup_value.as_ref() {
Self::preserve_codex_mcp_servers_in_backup(
&mut effective_settings,
&existing_value,
existing_value,
)?;
}
let anchor_config_text = existing_backup_value
.as_ref()
.and_then(|value| value.get("config"))
.and_then(|value| value.as_str());
crate::codex_config::normalize_codex_settings_config_model_provider(
&mut effective_settings,
anchor_config_text,
)
.map_err(|e| format!("归一化 Codex restore backup 失败: {e}"))?;
}
let backup_json = match app_type_enum {
@@ -1540,9 +1496,7 @@ impl ProxyService {
serde_json::to_string(&env_backup)
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?
}
AppType::OpenCode | AppType::OpenClaw => {
return Err(format!("未知的应用类型: {app_type}"));
}
_ => return Err(format!("未知的应用类型: {app_type}")),
};
self.db
@@ -1782,6 +1736,8 @@ impl ProxyService {
let auth = config.get("auth");
let config_str = config.get("config").and_then(|v| v.as_str());
// Proxy restore writes saved live backups verbatim. Provider-driven writes go
// through write_live_with_common_config(), which normalizes Codex provider ids.
match (auth, config_str) {
(Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?,
@@ -2726,6 +2682,147 @@ base_url = "https://new.example/v1"
);
}
#[tokio::test]
#[serial]
async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
let provider_a = Provider::with_id(
"a".to_string(),
"RightCode".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "rightcode-key"
},
"config": r#"model_provider = "rightcode"
model = "gpt-5.4"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
);
let provider_b = Provider::with_id(
"b".to_string(),
"AiHubMix".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "aihubmix-key"
},
"config": r#"model_provider = "aihubmix"
model = "gpt-5.4"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
);
db.save_provider("codex", &provider_a)
.expect("save provider a");
db.save_provider("codex", &provider_b)
.expect("save provider b");
db.set_current_provider("codex", "a")
.expect("set current provider");
crate::settings::set_current_provider(&AppType::Codex, Some("a"))
.expect("set local current provider");
db.save_live_backup(
"codex",
&serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"),
)
.await
.expect("seed live backup");
service
.write_codex_live(&json!({
"auth": {
"OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER
},
"config": r#"model_provider = "rightcode"
model = "gpt-5.4"
[model_providers.rightcode]
name = "RightCode"
base_url = "http://127.0.0.1:15721/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}))
.expect("seed taken-over Codex live config");
service
.hot_switch_provider("codex", "b")
.await
.expect("hot switch Codex provider");
let backup = db
.get_live_backup("codex")
.await
.expect("get live backup")
.expect("backup exists");
let stored: Value =
serde_json::from_str(&backup.original_config).expect("parse backup json");
let backup_config = stored
.get("config")
.and_then(|v| v.as_str())
.expect("backup config string");
let parsed_backup: toml::Value =
toml::from_str(backup_config).expect("parse backup config");
assert_eq!(
parsed_backup.get("model_provider").and_then(|v| v.as_str()),
Some("rightcode"),
"provider-derived restore backup should retain stable Codex model_provider"
);
let backup_model_providers = parsed_backup
.get("model_providers")
.and_then(|v| v.as_table())
.expect("backup model_providers");
assert!(backup_model_providers.get("aihubmix").is_none());
assert_eq!(
backup_model_providers
.get("rightcode")
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str()),
Some("https://aihubmix.example/v1"),
"stable provider id should point at the hot-switched provider endpoint"
);
service
.restore_live_config_for_app_with_fallback(&AppType::Codex)
.await
.expect("restore Codex live config");
let live = service.read_codex_live().expect("read Codex live config");
let live_config = live
.get("config")
.and_then(|v| v.as_str())
.expect("live config string");
let parsed_live: toml::Value = toml::from_str(live_config).expect("parse live config");
assert_eq!(
parsed_live.get("model_provider").and_then(|v| v.as_str()),
Some("rightcode"),
"restored Codex live config should not switch history buckets"
);
assert_eq!(
live.get("auth")
.and_then(|auth| auth.get("OPENAI_API_KEY"))
.and_then(|v| v.as_str()),
Some("aihubmix-key"),
"restore should still use the hot-switched provider auth"
);
}
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {
+88 -25
View File
@@ -13,6 +13,9 @@ use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::proxy::usage::calculator::{CostCalculator, ModelPricing};
use crate::proxy::usage::parser::TokenUsage;
use crate::services::usage_stats::{
effective_usage_log_filter, should_skip_session_insert, DedupKey,
};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -305,8 +308,10 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr
Ok((imported, skipped))
}
/// 获取文件的同步状态
fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> {
/// 获取 session_log_sync 表中某条目的同步进度。
///
/// Shared by all session_usage_* parsers.
pub(crate) fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> {
let conn = lock_conn!(db.conn);
let result = conn.query_row(
"SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1",
@@ -316,8 +321,10 @@ fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError
Ok(result.unwrap_or((0, 0)))
}
/// 更新文件的同步状态
fn update_sync_state(
/// 更新 session_log_sync 表中某条目的同步进度。
///
/// Shared by all session_usage_* parsers.
pub(crate) fn update_sync_state(
db: &Database,
file_path: &str,
last_modified: i64,
@@ -346,25 +353,10 @@ fn insert_session_log_entry(
) -> Result<bool, AppError> {
let conn = lock_conn!(db.conn);
// 检查是否已存在
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1",
rusqlite::params![request_id],
|row| row.get::<_, i64>(0).map(|c| c > 0),
)
.unwrap_or(false);
if exists {
return Ok(false);
}
// 解析时间戳
let created_at = msg
.timestamp
.as_ref()
.and_then(|ts| {
// 尝试解析 ISO 8601 时间戳
chrono::DateTime::parse_from_rfc3339(ts)
.ok()
.map(|dt| dt.timestamp())
@@ -376,6 +368,19 @@ fn insert_session_log_entry(
.unwrap_or(0)
});
let dedup_key = DedupKey {
app_type: "claude",
model: &msg.model,
input_tokens: msg.input_tokens,
output_tokens: msg.output_tokens,
cache_read_tokens: msg.cache_read_tokens,
cache_creation_tokens: msg.cache_creation_tokens,
created_at,
};
if should_skip_session_insert(&conn, request_id, &dedup_key)? {
return Ok(false);
}
// 计算费用
let usage = TokenUsage {
input_tokens: msg.input_tokens,
@@ -531,13 +536,17 @@ fn try_find_pricing(
pub fn get_data_source_breakdown(db: &Database) -> Result<Vec<DataSourceSummary>, AppError> {
let conn = lock_conn!(db.conn);
let mut stmt = conn.prepare(
"SELECT COALESCE(data_source, 'proxy') as ds, COUNT(*) as cnt,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as cost
FROM proxy_request_logs
let effective_filter = effective_usage_log_filter("l");
let sql = format!(
"SELECT COALESCE(l.data_source, 'proxy') as ds, COUNT(*) as cnt,
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as cost
FROM proxy_request_logs l
WHERE {effective_filter}
GROUP BY ds
ORDER BY cnt DESC",
)?;
ORDER BY cnt DESC"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], |row| {
Ok(DataSourceSummary {
@@ -636,4 +645,58 @@ mod tests {
messages.insert("msg_1".to_string(), final_entry);
assert_eq!(messages.get("msg_1").unwrap().output_tokens, 1349);
}
#[test]
fn test_insert_claude_session_skips_matching_proxy_log() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
total_cost_usd, latency_ms, status_code, created_at, data_source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
rusqlite::params![
"proxy-different-id",
"openai-compatible",
"claude",
"claude-sonnet-4-5",
"claude-sonnet-4-5",
100,
20,
10,
5,
"0.10",
100,
200,
1000,
"proxy"
],
)?;
}
let msg = ParsedAssistantUsage {
message_id: "msg_1".to_string(),
model: "claude-sonnet-4-5".to_string(),
input_tokens: 100,
output_tokens: 20,
cache_read_tokens: 10,
cache_creation_tokens: 5,
stop_reason: Some("end_turn".to_string()),
timestamp: Some("1970-01-01T00:16:45Z".to_string()),
session_id: Some("session-1".to_string()),
};
let inserted = insert_session_log_entry(&db, "session:msg_1", &msg)?;
assert!(!inserted);
let conn = lock_conn!(db.conn);
let count: i64 = conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
row.get(0)
})?;
assert_eq!(count, 1);
Ok(())
}
}
+75 -54
View File
@@ -18,7 +18,8 @@ use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::proxy::usage::calculator::{CostCalculator, ModelPricing};
use crate::proxy::usage::parser::TokenUsage;
use crate::services::session_usage::SessionSyncResult;
use crate::services::session_usage::{get_sync_state, update_sync_state, SessionSyncResult};
use crate::services::usage_stats::{should_skip_session_insert, DedupKey};
use rust_decimal::Decimal;
use std::fs;
use std::io::{BufRead, BufReader};
@@ -171,7 +172,7 @@ pub fn sync_codex_usage(db: &Database) -> Result<SessionSyncResult, AppError> {
if result.imported > 0 {
log::info!(
"[CODEX-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 描 {} 个文件",
"[CODEX-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 描 {} 个文件",
result.imported,
result.skipped,
result.files_scanned
@@ -185,7 +186,7 @@ pub fn sync_codex_usage(db: &Database) -> Result<SessionSyncResult, AppError> {
fn collect_codex_session_files(codex_dir: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
// 1. 扫描 sessions/YYYY/MM/DD/*.jsonl(日期分区目录
// 1. 扫描 sessions/YYYY/MM/DD/*.jsonl(日期分区目录
let sessions_dir = codex_dir.join("sessions");
if sessions_dir.is_dir() {
collect_jsonl_recursive(&sessions_dir, &mut files, 0, 3);
@@ -224,13 +225,13 @@ fn collect_jsonl_recursive(dir: &Path, files: &mut Vec<PathBuf>, depth: u32, max
}
}
/// 同步单 Codex JSONL 文件,返回 (imported, skipped)
/// 同步单 Codex JSONL 文件,返回 (imported, skipped)
fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> {
let file_path_str = file_path.to_string_lossy().to_string();
// 获取文件元数据
let metadata = fs::metadata(file_path)
.map_err(|e| AppError::Config(format!("无法读取文元数据: {e}")))?;
.map_err(|e| AppError::Config(format!("无法读取文元数据: {e}")))?;
let file_modified = metadata
.modified()
.ok()
@@ -333,7 +334,7 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32),
let info = match payload.get("info") {
Some(i) if !i.is_null() => i,
_ => continue, // info 为 null 的首个事件
_ => continue, // 跳过 info 为 null 的首个事件
};
// 提取模型(token_count 事件也可能携带 model
@@ -438,20 +439,6 @@ fn insert_codex_session_entry(
) -> Result<bool, AppError> {
let conn = lock_conn!(db.conn);
// 检查是否已存在
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1",
rusqlite::params![request_id],
|row| row.get::<_, i64>(0).map(|c| c > 0),
)
.unwrap_or(false);
if exists {
return Ok(false);
}
// 解析时间戳
let created_at = timestamp
.and_then(|ts| {
chrono::DateTime::parse_from_rfc3339(ts)
@@ -465,6 +452,19 @@ fn insert_codex_session_entry(
.unwrap_or(0)
});
let dedup_key = DedupKey {
app_type: "codex",
model,
input_tokens: delta.input,
output_tokens: delta.output,
cache_read_tokens: delta.cached_input,
cache_creation_tokens: 0,
created_at,
};
if should_skip_session_insert(&conn, request_id, &dedup_key)? {
return Ok(false);
}
// 计算费用
let usage = TokenUsage {
input_tokens: delta.input,
@@ -538,40 +538,7 @@ fn insert_codex_session_entry(
Ok(true)
}
/// 获取文件的同步状态
fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> {
let conn = lock_conn!(db.conn);
let result = conn.query_row(
"SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1",
rusqlite::params![file_path],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
);
Ok(result.unwrap_or((0, 0)))
}
/// 更新文件的同步状态
fn update_sync_state(
db: &Database,
file_path: &str,
last_modified: i64,
last_offset: i64,
) -> Result<(), AppError> {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![file_path, last_modified, last_offset, now],
)
.map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?;
Ok(())
}
/// 找 Codex 模型定价(带归一化)
/// 查找 Codex 模型定价(带归一化)
fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
let normalized = normalize_codex_model(model_id);
@@ -733,6 +700,60 @@ mod tests {
assert!(files.is_empty());
}
#[test]
fn test_insert_codex_session_skips_matching_proxy_log() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
total_cost_usd, latency_ms, status_code, created_at, data_source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
rusqlite::params![
"codex-proxy",
"openai",
"codex",
"gpt-5.4",
"gpt-5.4",
10,
2,
1,
7,
"0.01",
100,
200,
1000,
"proxy"
],
)?;
}
let delta = DeltaTokens {
input: 10,
cached_input: 1,
output: 2,
};
let inserted = insert_codex_session_entry(
&db,
"codex-session-dup",
&delta,
"gpt-5.4",
Some("session-1"),
Some("1970-01-01T00:16:45Z"),
)?;
assert!(!inserted);
let conn = lock_conn!(db.conn);
let count: i64 = conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
row.get(0)
})?;
assert_eq!(count, 1);
Ok(())
}
// ── 模型名归一化测试 ──
#[test]
+70 -35
View File
@@ -18,7 +18,8 @@ use crate::error::AppError;
use crate::gemini_config::get_gemini_dir;
use crate::proxy::usage::calculator::{CostCalculator, ModelPricing};
use crate::proxy::usage::parser::TokenUsage;
use crate::services::session_usage::SessionSyncResult;
use crate::services::session_usage::{get_sync_state, update_sync_state, SessionSyncResult};
use crate::services::usage_stats::{should_skip_session_insert, DedupKey};
use rust_decimal::Decimal;
use std::fs;
use std::path::{Path, PathBuf};
@@ -237,7 +238,6 @@ fn insert_gemini_session_entry(
) -> Result<bool, AppError> {
let conn = lock_conn!(db.conn);
// 解析时间戳
let created_at = timestamp
.and_then(|ts| {
chrono::DateTime::parse_from_rfc3339(ts)
@@ -254,6 +254,19 @@ fn insert_gemini_session_entry(
// 合并 thoughts 到 output(思考 token 按输出计费)
let output_tokens = tokens.output + tokens.thoughts;
let dedup_key = DedupKey {
app_type: "gemini",
model,
input_tokens: tokens.input,
output_tokens,
cache_read_tokens: tokens.cached,
cache_creation_tokens: 0,
created_at,
};
if should_skip_session_insert(&conn, request_id, &dedup_key)? {
return Ok(false);
}
// 计算费用
let usage = TokenUsage {
input_tokens: tokens.input,
@@ -343,39 +356,6 @@ fn insert_gemini_session_entry(
Ok(conn.changes() > 0)
}
/// 获取文件的同步状态
fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> {
let conn = lock_conn!(db.conn);
let result = conn.query_row(
"SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1",
rusqlite::params![file_path],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
);
Ok(result.unwrap_or((0, 0)))
}
/// 更新文件的同步状态
fn update_sync_state(
db: &Database,
file_path: &str,
last_modified: i64,
last_offset: i64,
) -> Result<(), AppError> {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![file_path, last_modified, last_offset, now],
)
.map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?;
Ok(())
}
/// 查找 Gemini 模型定价
fn find_gemini_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
// 精确匹配
@@ -433,6 +413,61 @@ mod tests {
assert!(files.is_empty());
}
#[test]
fn test_insert_gemini_session_skips_matching_proxy_log() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
total_cost_usd, latency_ms, status_code, created_at, data_source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
rusqlite::params![
"gemini-proxy",
"google",
"gemini",
"gemini-2.5-pro",
"gemini-2.5-pro",
10,
7,
1,
0,
"0.01",
100,
200,
1000,
"proxy"
],
)?;
}
let tokens = GeminiTokens {
input: 10,
output: 2,
cached: 1,
thoughts: 5,
};
let inserted = insert_gemini_session_entry(
&db,
"gemini-session-dup",
&tokens,
"gemini-2.5-pro",
Some("session-1"),
Some("1970-01-01T00:16:45Z"),
)?;
assert!(!inserted);
let conn = lock_conn!(db.conn);
let count: i64 = conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
row.get(0)
})?;
assert_eq!(count, 1);
Ok(())
}
#[test]
fn test_parse_gemini_tokens() {
let json: serde_json::Value = serde_json::json!({
+125 -54
View File
@@ -509,6 +509,7 @@ impl SkillService {
return Ok(custom.join("skills"));
}
}
AppType::ClaudeDesktop => {}
AppType::Codex => {
if let Some(custom) = crate::settings::get_codex_override_dir() {
return Ok(custom.join("skills"));
@@ -529,6 +530,11 @@ impl SkillService {
return Ok(custom.join("skills"));
}
}
AppType::Hermes => {
if let Some(custom) = crate::settings::get_hermes_override_dir() {
return Ok(custom.join("skills"));
}
}
}
// 默认路径:回退到用户主目录下的标准位置
@@ -540,10 +546,12 @@ impl SkillService {
Ok(match app {
AppType::Claude => home.join(".claude").join("skills"),
AppType::ClaudeDesktop => home.join(".claude-desktop").join("skills"),
AppType::Codex => home.join(".codex").join("skills"),
AppType::Gemini => home.join(".gemini").join("skills"),
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
AppType::OpenClaw => home.join(".openclaw").join("skills"),
AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"),
})
}
@@ -666,36 +674,16 @@ impl SkillService {
repo_branch = used_branch;
// 复制到 SSOT
let mut source = temp_dir.join(&source_rel);
if !source.exists() {
// 回退:在 temp_dir 中递归查找名称匹配的目录(含 SKILL.md)
let target_name = source_rel
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if let Some(found) = Self::find_skill_dir_by_name(&temp_dir, &target_name) {
log::info!(
"Skill directory '{}' not found at direct path, using fallback: {}",
target_name,
found.display()
);
source = found;
} else if temp_dir.join("SKILL.md").exists() {
// 根级 Skill:仓库本身就是 skillSKILL.md 直接在解压根目录
log::info!(
"Skill directory '{}' not found, but SKILL.md exists at root, using temp_dir",
target_name,
);
source = temp_dir.clone();
} else {
let source =
Self::resolve_skill_source_dir(&temp_dir, &skill.directory).ok_or_else(|| {
let missing = temp_dir.join(&source_rel).display().to_string();
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &source.display().to_string())],
&[("path", &missing)],
Some("checkRepoUrl"),
)));
}
}
))
})?;
let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone());
let canonical_source = source.canonicalize().map_err(|_| {
@@ -948,14 +936,13 @@ impl SkillService {
});
let remote_skill_dir = match remote_match {
Some(rs) => temp_dir.join(&rs.directory),
Some(rs) => match Self::resolve_skill_source_dir(&temp_dir, &rs.directory) {
Some(path) => path,
None => continue,
},
None => continue,
};
if !remote_skill_dir.exists() {
continue;
}
let remote_hash = match Self::compute_dir_hash(&remote_skill_dir) {
Ok(h) => h,
Err(e) => {
@@ -1059,15 +1046,16 @@ impl SkillService {
))
})?;
let source = temp_dir.join(&remote_match.directory);
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &source.display().to_string())],
Some("checkRepoUrl"),
)));
}
let source = Self::resolve_skill_source_dir(&temp_dir, &remote_match.directory)
.ok_or_else(|| {
let missing = temp_dir.join(&remote_match.directory).display().to_string();
let _ = fs::remove_dir_all(&temp_dir);
anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &missing)],
Some("checkRepoUrl"),
))
})?;
// 备份旧文件
let _ = Self::create_uninstall_backup(&skill);
@@ -1550,19 +1538,6 @@ impl SkillService {
// 保存到数据库
db.save_skill(&skill)?;
// 同步到已启用的应用目录(创建 symlink 或复制文件)
for app in AppType::all() {
if skill.apps.is_enabled_for(&app) {
if let Err(e) = Self::sync_to_app_dir(&skill.directory, &app) {
log::warn!(
"导入后同步 Skill '{}' 到 {:?} 失败: {e:#}",
skill.directory,
app
);
}
}
}
imported.push(skill);
}
@@ -1608,6 +1583,10 @@ impl SkillService {
/// - Symlink: 仅使用 symlink
/// - Copy: 仅使用文件复制
pub fn sync_to_app_dir(directory: &str, app: &AppType) -> Result<()> {
if matches!(app, AppType::ClaudeDesktop) {
return Ok(());
}
let ssot_dir = Self::get_ssot_dir()?;
let source = ssot_dir.join(directory);
@@ -1713,6 +1692,10 @@ impl SkillService {
/// 从应用目录删除 Skill(支持 symlink 和真实目录)
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
if matches!(app, AppType::ClaudeDesktop) {
return Ok(());
}
let app_dir = Self::get_app_skills_dir(app)?;
let skill_path = app_dir.join(directory);
@@ -1726,6 +1709,10 @@ impl SkillService {
/// 同步所有已启用的 Skills 到指定应用
pub fn sync_to_app(db: &Arc<Database>, app: &AppType) -> Result<()> {
if matches!(app, AppType::ClaudeDesktop) {
return Ok(());
}
let skills = db.get_all_installed_skills()?;
let ssot_dir = Self::get_ssot_dir()?;
let app_dir = Self::get_app_skills_dir(app)?;
@@ -2102,6 +2089,40 @@ impl SkillService {
walk(root, target_name, 0)
}
/// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。
///
/// 兼容三种情况:
/// 1. `skills/foo` 这类直接相对路径;
/// 2. 仅持有安装名 `foo`,需要在仓库中递归查找真实目录;
/// 3. 仓库根目录本身就是 skill,此时回退到解压根目录。
fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option<PathBuf> {
let source_rel = Self::sanitize_skill_source_path(raw_directory)?;
let direct = root.join(&source_rel);
if direct.is_dir() {
return Some(direct);
}
let target_name = source_rel.file_name()?.to_string_lossy().to_string();
if let Some(found) = Self::find_skill_dir_by_name(root, &target_name) {
log::info!(
"Skill directory '{}' not found at direct path, using fallback: {}",
target_name,
found.display()
);
return Some(found);
}
if root.is_dir() && root.join("SKILL.md").exists() {
log::info!(
"Skill directory '{}' not found, but SKILL.md exists at root, using repo root",
target_name,
);
return Some(root.to_path_buf());
}
None
}
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
let mut seen = HashMap::new();
@@ -2969,3 +2990,53 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn write_skill(dir: &Path, name: &str) {
fs::create_dir_all(dir).expect("create skill dir");
fs::write(
dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: Test skill\n---\n"),
)
.expect("write SKILL.md");
}
#[test]
fn resolve_skill_source_dir_returns_repo_root_for_root_level_skill() {
let temp = tempdir().expect("tempdir");
write_skill(temp.path(), "Root Skill");
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "last30days-skill-cn")
.expect("root-level skill should resolve to the extracted repo root");
assert_eq!(resolved, temp.path());
}
#[test]
fn resolve_skill_source_dir_returns_direct_nested_directory_when_present() {
let temp = tempdir().expect("tempdir");
let nested = temp.path().join("skills").join("nested-skill");
write_skill(&nested, "Nested Skill");
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "skills/nested-skill")
.expect("nested skill should resolve from its relative source path");
assert_eq!(resolved, nested);
}
#[test]
fn resolve_skill_source_dir_falls_back_to_matching_install_name() {
let temp = tempdir().expect("tempdir");
let nested = temp.path().join("skills").join("nested-skill");
write_skill(&nested, "Nested Skill");
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "nested-skill")
.expect("install name should fall back to the matching discovered skill directory");
assert_eq!(resolved, nested);
}
}
+210 -39
View File
@@ -3,7 +3,6 @@
//! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。
use futures::StreamExt;
use regex::Regex;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -17,7 +16,9 @@ use crate::proxy::providers::copilot_auth;
use crate::proxy::providers::transform::anthropic_to_openai;
use crate::proxy::providers::transform_gemini::anthropic_to_gemini;
use crate::proxy::providers::transform_responses::anthropic_to_responses;
use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};
use crate::proxy::providers::{
get_adapter, AuthInfo, AuthStrategy, ClaudeAdapter, ProviderAdapter,
};
/// 健康状态枚举
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -207,11 +208,18 @@ impl StreamCheckService {
// OpenCode / OpenClaw 的 settings_config 结构与 Claude/Codex/Gemini 不同
// baseUrl / apiKey 直接作为根字段而非嵌套在 env),并且协议由 `api`
// 或 `npm` 字段显式指定。它们不走 get_adapter 路径,而是直接分发。
if matches!(app_type, AppType::OpenCode | AppType::OpenClaw) {
if matches!(
app_type,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes
) {
return Self::check_once_without_adapter(app_type, provider, config, start).await;
}
let adapter = get_adapter(app_type);
let adapter: Box<dyn ProviderAdapter> = if matches!(app_type, AppType::ClaudeDesktop) {
Box::new(ClaudeAdapter::new())
} else {
get_adapter(app_type)
};
let base_url = match base_url_override {
Some(base_url) => base_url,
@@ -232,7 +240,7 @@ impl StreamCheckService {
let test_prompt = &config.test_prompt;
let result = match app_type {
AppType::Claude => {
AppType::Claude | AppType::ClaudeDesktop => {
Self::check_claude_stream(
&client,
&base_url,
@@ -270,9 +278,9 @@ impl StreamCheckService {
)
.await
}
AppType::OpenCode | AppType::OpenClaw => {
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// Already handled via early dispatch above
unreachable!("OpenCode/OpenClaw 已通过 check_once_without_adapter 处理")
unreachable!("OpenCode/OpenClaw/Hermes 已通过 check_once_without_adapter 处理")
}
};
@@ -353,15 +361,17 @@ impl StreamCheckService {
});
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要 store:false + include 标记,
// 否则 Stream Check 会和生产路径一样被服务端 400 拒绝。
let is_codex_oauth = provider
.meta
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("codex_oauth");
let is_codex_oauth = provider.is_codex_oauth();
let codex_fast_mode = provider.codex_fast_mode_enabled();
let body = if is_openai_responses {
anthropic_to_responses(anthropic_body, Some(&provider.id), is_codex_oauth)
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
anthropic_to_responses(
anthropic_body,
Some(&provider.id),
is_codex_oauth,
codex_fast_mode,
)
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
} else if is_gemini_native {
anthropic_to_gemini(anthropic_body)
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
@@ -429,12 +439,13 @@ impl StreamCheckService {
let os_name = Self::get_os_name();
let arch_name = Self::get_arch_name();
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);
// 鉴权头复用 ClaudeAdapter::get_auth_headers,与代理路径(forwarder)保持单一真理来源。
// - AuthStrategy::Anthropic → x-api-key
// - AuthStrategy::ClaudeAuth → Authorization: Bearer
// - AuthStrategy::Bearer → Authorization: Bearer
// 避免之前"无条件 Bearer + 条件 x-api-key 双发"导致的假阴性 / auth conflict。
for (name, value) in ClaudeAdapter::new().get_auth_headers(auth) {
request_builder = request_builder.header(name, value);
}
request_builder = request_builder
@@ -681,7 +692,7 @@ impl StreamCheckService {
let result = match app_type {
AppType::OpenClaw => {
Self::check_openclaw_stream(
Self::check_additive_app_stream(
&client,
provider,
&model_to_test,
@@ -700,7 +711,17 @@ impl StreamCheckService {
)
.await
}
_ => unreachable!("check_once_without_adapter 只处理 OpenCode/OpenClaw"),
AppType::Hermes => {
Self::check_hermes_stream(
&client,
provider,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
_ => unreachable!("check_once_without_adapter 只处理 OpenCode/OpenClaw/Hermes"),
};
let response_time = start.elapsed().as_millis() as u64;
@@ -775,6 +796,15 @@ impl StreamCheckService {
return None;
}
let lower = body.to_lowercase();
let qianfan_quota_indicators = [
"coding_plan_hour_quota_exceeded",
"coding_plan_week_quota_exceeded",
"coding_plan_month_quota_exceeded",
];
if qianfan_quota_indicators.iter().any(|s| lower.contains(s)) {
return Some("quotaExceeded");
}
// 必须提到 "model",避免通用 404 / 400 被误判
if !lower.contains("model") {
return None;
@@ -805,7 +835,7 @@ impl StreamCheckService {
/// - `anthropic-messages` → check_claude_stream + api_format="anthropic" (ClaudeAuth 策略)
/// - `google-generative-ai` → check_gemini_stream (Google API Key 策略)
/// - `bedrock-converse-stream` → 不支持(需要 AWS SigV4 签名)
async fn check_openclaw_stream(
async fn check_additive_app_stream(
client: &Client,
provider: &Provider,
model: &str,
@@ -815,7 +845,7 @@ impl StreamCheckService {
// 自定义认证头(如 Longcat 的 `apikey` 头)不走标准 Bearer
// 具体头名由 OpenClaw 网关内部决定,cc-switch 无法准确构造,
// 因此直接返回友好错误而不是让用户看到一个误导性的 401。
if Self::openclaw_uses_auth_header(provider) {
if Self::additive_app_uses_auth_header(provider) {
return Err(AppError::localized(
"openclaw_auth_header_not_supported",
"该供应商使用自定义认证头,暂不支持流式健康检查。建议直接通过 OpenClaw 测试。",
@@ -908,8 +938,8 @@ impl StreamCheckService {
}
}
/// 判断 OpenClaw 供应商是否使用自定义认证头(`authHeader: true`
fn openclaw_uses_auth_header(provider: &Provider) -> bool {
/// 判断 additive-mode 供应商是否使用自定义认证头(`authHeader: true`
fn additive_app_uses_auth_header(provider: &Provider) -> bool {
provider
.settings_config
.get("authHeader")
@@ -969,6 +999,112 @@ impl StreamCheckService {
.filter(|s| !s.is_empty())
}
// Hermes 的 settings_config 用 snake_casebase_url / api_key / api_mode),
// 与 OpenClaw 的 camelCasebaseUrl / apiKey / api)是两套独立命名。
// 见 src/config/hermesProviderPresets.ts 的 HermesProviderSettingsConfig。
fn extract_hermes_base_url(provider: &Provider) -> Result<String, AppError> {
provider
.settings_config
.get("base_url")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
AppError::localized(
"hermes_base_url_missing",
"Hermes 供应商缺少 base_url",
"Hermes provider is missing `base_url`",
)
})
}
fn extract_hermes_api_key(provider: &Provider) -> Result<String, AppError> {
provider
.settings_config
.get("api_key")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
AppError::localized(
"hermes_api_key_missing",
"Hermes 供应商缺少 api_key",
"Hermes provider is missing `api_key`",
)
})
}
fn extract_hermes_api_mode(provider: &Provider) -> Option<String> {
provider
.settings_config
.get("api_mode")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// Hermes 流式检查分发器
///
/// Hermes 以 `api_mode` 字段显式指定协议,取值来自
/// `HermesApiMode`hermesProviderPresets.ts):
/// - `chat_completions` → check_claude_stream + api_format="openai_chat"Bearer
/// - `anthropic_messages` → check_claude_stream + api_format="anthropic"ClaudeAuth,与 OpenClaw 的 anthropic-messages 同策略)
/// - `codex_responses` → check_claude_stream + api_format="openai_responses"Bearer
/// - `bedrock_converse` → 不支持(需要 AWS SigV4 签名)
async fn check_hermes_stream(
client: &Client,
provider: &Provider,
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
// 先把 api_mode 路由出协议格式与认证策略。
// 纯错误路径(bedrock / 未知 / 缺失)直接 return,避免在用户
// 选了 bedrock_converse 时被"缺 base_url"的二级错误盖住真正原因。
let (api_format, auth_strategy) = match Self::extract_hermes_api_mode(provider).as_deref() {
Some("chat_completions") => ("openai_chat", AuthStrategy::Bearer),
Some("anthropic_messages") => ("anthropic", AuthStrategy::ClaudeAuth),
Some("codex_responses") => ("openai_responses", AuthStrategy::Bearer),
Some("bedrock_converse") => {
return Err(AppError::localized(
"hermes_bedrock_not_supported",
"AWS Bedrock 需要 SigV4 签名,当前不支持健康检查。",
"AWS Bedrock requires SigV4 signing and is not supported by stream health check.",
));
}
Some(other) => {
return Err(AppError::localized(
"hermes_protocol_not_yet_supported",
format!("Hermes 暂不支持协议: {other}"),
format!("Hermes protocol not yet supported: {other}"),
));
}
None => {
return Err(AppError::localized(
"hermes_api_mode_missing",
"Hermes 供应商缺少 api_mode 字段",
"Hermes provider is missing the `api_mode` field",
));
}
};
let base_url = Self::extract_hermes_base_url(provider)?;
let api_key = Self::extract_hermes_api_key(provider)?;
let auth = AuthInfo::new(api_key, auth_strategy);
Self::check_claude_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
provider,
Some(api_format),
None,
)
.await
}
/// OpenCode 流式检查分发器
///
/// OpenCode 用 `npm` 字段(AI SDK 包名)隐式指定协议。映射关系参见
@@ -1026,7 +1162,7 @@ impl StreamCheckService {
.await
}
Some("@ai-sdk/anthropic") => {
// 见 check_openclaw_stream 对 anthropic-messages 的注释
// 见 check_additive_app_stream 对 anthropic-messages 的处理
// 用 ClaudeAuthBearer-only)兼容中转服务。
let auth = AuthInfo::new(api_key, AuthStrategy::ClaudeAuth);
Self::check_claude_stream(
@@ -1229,8 +1365,10 @@ impl StreamCheckService {
config: &StreamCheckConfig,
) -> String {
match app_type {
AppType::Claude => Self::extract_env_model(provider, "ANTHROPIC_MODEL")
.unwrap_or_else(|| config.claude_model.clone()),
AppType::Claude | AppType::ClaudeDesktop => {
Self::extract_env_model(provider, "ANTHROPIC_MODEL")
.unwrap_or_else(|| config.claude_model.clone())
}
AppType::Codex => {
Self::extract_codex_model(provider).unwrap_or_else(|| config.codex_model.clone())
}
@@ -1241,8 +1379,8 @@ impl StreamCheckService {
// Try to extract first model from the models object
Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
}
AppType::OpenClaw => {
// OpenClaw uses models array in settings_config
AppType::OpenClaw | AppType::Hermes => {
// OpenClaw/Hermes use models array in settings_config
// Try to extract first model from the models array
Self::extract_openclaw_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
}
@@ -1293,10 +1431,11 @@ impl StreamCheckService {
return None;
}
let re = Regex::new(r#"^model\s*=\s*["']([^"']+)["']"#).ok()?;
re.captures(config_text)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().trim().to_string())
let table = toml::from_str::<toml::Table>(config_text).ok()?;
table
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|value| !value.is_empty())
}
@@ -1406,24 +1545,24 @@ mod tests {
}
#[test]
fn test_openclaw_uses_auth_header_true() {
fn test_additive_app_uses_auth_header_true() {
let p = make_provider(serde_json::json!({
"baseUrl": "https://api.longcat.chat/v1",
"apiKey": "k",
"api": "openai-completions",
"authHeader": true,
}));
assert!(StreamCheckService::openclaw_uses_auth_header(&p));
assert!(StreamCheckService::additive_app_uses_auth_header(&p));
}
#[test]
fn test_openclaw_uses_auth_header_default_false() {
fn test_additive_app_uses_auth_header_default_false() {
let p = make_provider(serde_json::json!({
"baseUrl": "https://api.deepseek.com/v1",
"apiKey": "k",
"api": "openai-completions",
}));
assert!(!StreamCheckService::openclaw_uses_auth_header(&p));
assert!(!StreamCheckService::additive_app_uses_auth_header(&p));
}
#[test]
@@ -1612,6 +1751,22 @@ mod tests {
);
}
#[test]
fn test_detect_qianfan_coding_plan_quota_errors() {
let cases = [
r#"{"error":{"code":"coding_plan_hour_quota_exceeded","message":"hour quota exceeded"}}"#,
r#"{"error":{"code":"coding_plan_week_quota_exceeded","message":"week quota exceeded"}}"#,
r#"{"error":{"code":"coding_plan_month_quota_exceeded","message":"month quota exceeded"}}"#,
];
for body in cases {
assert_eq!(
StreamCheckService::detect_error_category(429, body),
Some("quotaExceeded")
);
}
}
#[test]
fn test_get_os_name() {
let os_name = StreamCheckService::get_os_name();
@@ -1782,6 +1937,22 @@ mod tests {
assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse");
}
#[test]
fn test_resolve_claude_stream_url_for_gemini_native_cloudflare_vertex_full_url() {
let url = StreamCheckService::resolve_claude_stream_url(
"https://gateway.ai.cloudflare.com/v1/account/gateway/google-vertex-ai/v1/projects/project/locations/us-central1/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent",
AuthStrategy::Google,
"gemini_native",
true,
"gemini-2.5-flash",
);
assert_eq!(
url,
"https://gateway.ai.cloudflare.com/v1/account/gateway/google-vertex-ai/v1/projects/project/locations/us-central1/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent?alt=sse"
);
}
/// Regression: Gemini SDK outputs commonly surface model ids as the
/// resource-name form `models/gemini-2.5-pro`. Interpolating that raw
/// value used to produce `/v1beta/models/models/gemini-2.5-pro:...`
+25 -11
View File
@@ -291,12 +291,26 @@ struct ApiExtraUsage {
currency: Option<String>,
}
/// 已知的 Claude 用量窗口名称
/// 已知的 Claude 用量窗口名称。`QuotaTier::name` 会是其中之一。
pub const TIER_FIVE_HOUR: &str = "five_hour";
pub const TIER_SEVEN_DAY: &str = "seven_day";
pub const TIER_SEVEN_DAY_OPUS: &str = "seven_day_opus";
pub const TIER_SEVEN_DAY_SONNET: &str = "seven_day_sonnet";
/// Coding PlanKimi / MiniMax)的周窗口 tier 名。与 `coding_plan::query_*`
/// 写入、tray 渲染、commands::provider 扁平化三处共用同一标识。
pub const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
/// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。
pub const TIER_GEMINI_PRO: &str = "gemini_pro";
pub const TIER_GEMINI_FLASH: &str = "gemini_flash";
pub const TIER_GEMINI_FLASH_LITE: &str = "gemini_flash_lite";
const KNOWN_TIERS: &[&str] = &[
"five_hour",
"seven_day",
"seven_day_opus",
"seven_day_sonnet",
TIER_FIVE_HOUR,
TIER_SEVEN_DAY,
TIER_SEVEN_DAY_OPUS,
TIER_SEVEN_DAY_SONNET,
];
/// 查询 Claude 官方订阅额度
@@ -993,11 +1007,11 @@ fn extract_project_id(value: &serde_json::Value) -> Option<String> {
/// 将 Gemini 模型 ID 分类为 Pro / Flash / Flash Lite
fn classify_gemini_model(model_id: &str) -> &str {
if model_id.contains("flash-lite") {
"gemini_flash_lite"
TIER_GEMINI_FLASH_LITE
} else if model_id.contains("flash") {
"gemini_flash"
TIER_GEMINI_FLASH
} else if model_id.contains("pro") {
"gemini_pro"
TIER_GEMINI_PRO
} else {
model_id
}
@@ -1152,9 +1166,9 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota {
// 转换为 tiersremainingFraction → utilization: 已用百分比)
let sort_order = |name: &str| -> usize {
match name {
"gemini_pro" => 0,
"gemini_flash" => 1,
"gemini_flash_lite" => 2,
TIER_GEMINI_PRO => 0,
TIER_GEMINI_FLASH => 1,
TIER_GEMINI_FLASH_LITE => 2,
_ => 3,
}
};
+143
View File
@@ -0,0 +1,143 @@
//! 托盘展示用的用量缓存(进程内、写穿式)。
//!
//! 各 usage 查询命令成功时写入;系统托盘构建菜单时读取。不持久化,
//! 进程重启即空,由下一次自动查询或托盘悬停触发的刷新重新填充。
use std::collections::HashMap;
use std::sync::RwLock;
use crate::app_config::AppType;
use crate::provider::UsageResult;
use crate::services::subscription::SubscriptionQuota;
#[derive(Default)]
pub struct UsageCache {
subscription: RwLock<HashMap<AppType, SubscriptionQuota>>,
script: RwLock<HashMap<(AppType, String), UsageResult>>,
}
impl UsageCache {
pub fn new() -> Self {
Self::default()
}
pub fn put_subscription(&self, app_type: AppType, quota: SubscriptionQuota) {
if let Ok(mut w) = self.subscription.write() {
w.insert(app_type, quota);
}
}
pub fn put_script(&self, app_type: AppType, provider_id: String, result: UsageResult) {
if let Ok(mut w) = self.script.write() {
w.insert((app_type, provider_id), result);
}
}
/// 以借用形式暴露订阅快照,避免托盘每次重建时深拷贝整个 `SubscriptionQuota`。
pub fn with_subscription<R>(
&self,
app_type: &AppType,
f: impl FnOnce(&SubscriptionQuota) -> R,
) -> Option<R> {
self.subscription
.read()
.ok()
.and_then(|r| r.get(app_type).map(f))
}
/// 以借用形式暴露脚本型用量结果,同上。
pub fn with_script<R>(
&self,
app_type: &AppType,
provider_id: &str,
f: impl FnOnce(&UsageResult) -> R,
) -> Option<R> {
self.script
.read()
.ok()
.and_then(|r| r.get(&(app_type.clone(), provider_id.to_string())).map(f))
}
pub fn invalidate_script(&self, app_type: &AppType, provider_id: &str) {
// 热路径会对每个禁用脚本的 provider 在托盘重建时调用一次:先走读锁
// `contains_key` 快速放行"本来就不在缓存里"的常见情况,避免无谓的写锁升级。
let key = (app_type.clone(), provider_id.to_string());
if !self.script.read().is_ok_and(|r| r.contains_key(&key)) {
return;
}
if let Ok(mut w) = self.script.write() {
w.remove(&key);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::subscription::CredentialStatus;
fn fake_quota() -> SubscriptionQuota {
SubscriptionQuota {
tool: "claude".to_string(),
credential_status: CredentialStatus::Valid,
credential_message: None,
success: true,
tiers: vec![],
extra_usage: None,
error: None,
queried_at: Some(0),
}
}
fn fake_result() -> UsageResult {
UsageResult {
success: true,
data: None,
error: None,
}
}
#[test]
fn subscription_round_trip() {
let cache = UsageCache::new();
assert!(cache
.with_subscription(&AppType::Claude, |q| q.success)
.is_none());
cache.put_subscription(AppType::Claude, fake_quota());
let got = cache
.with_subscription(&AppType::Claude, |q| q.success)
.unwrap();
assert!(got);
assert!(cache
.with_subscription(&AppType::Codex, |q| q.success)
.is_none());
}
#[test]
fn script_round_trip_and_invalidate() {
let cache = UsageCache::new();
assert!(cache
.with_script(&AppType::Codex, "pid", |r| r.success)
.is_none());
cache.put_script(AppType::Codex, "pid".to_string(), fake_result());
assert!(cache
.with_script(&AppType::Codex, "pid", |r| r.success)
.is_some());
cache.invalidate_script(&AppType::Codex, "pid");
assert!(cache
.with_script(&AppType::Codex, "pid", |r| r.success)
.is_none());
}
#[test]
fn script_keys_isolated_by_app_type() {
let cache = UsageCache::new();
cache.put_script(AppType::Claude, "same".to_string(), fake_result());
assert!(cache
.with_script(&AppType::Claude, "same", |r| r.success)
.is_some());
assert!(cache
.with_script(&AppType::Codex, "same", |r| r.success)
.is_none());
}
}
File diff suppressed because it is too large Load Diff
+16 -4
View File
@@ -4,7 +4,7 @@ pub mod terminal;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use providers::{claude, codex, gemini, openclaw, opencode};
use providers::{claude, codex, gemini, hermes, openclaw, opencode};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -56,18 +56,20 @@ pub struct DeleteSessionOutcome {
}
pub fn scan_sessions() -> Vec<SessionMeta> {
let (r1, r2, r3, r4, r5) = std::thread::scope(|s| {
let (r1, r2, r3, r4, r5, r6) = std::thread::scope(|s| {
let h1 = s.spawn(codex::scan_sessions);
let h2 = s.spawn(claude::scan_sessions);
let h3 = s.spawn(opencode::scan_sessions);
let h4 = s.spawn(openclaw::scan_sessions);
let h5 = s.spawn(gemini::scan_sessions);
let h6 = s.spawn(hermes::scan_sessions);
(
h1.join().unwrap_or_default(),
h2.join().unwrap_or_default(),
h3.join().unwrap_or_default(),
h4.join().unwrap_or_default(),
h5.join().unwrap_or_default(),
h6.join().unwrap_or_default(),
)
});
@@ -77,6 +79,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
sessions.extend(r3);
sessions.extend(r4);
sessions.extend(r5);
sessions.extend(r6);
sessions.sort_by(|a, b| {
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
@@ -88,10 +91,13 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
}
pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<SessionMessage>, String> {
// OpenCode SQLite sessions use a "sqlite:" prefixed source_path
// SQLite sessions use a "sqlite:" prefixed source_path
if provider_id == "opencode" && source_path.starts_with("sqlite:") {
return opencode::load_messages_sqlite(source_path);
}
if provider_id == "hermes" && source_path.starts_with("sqlite:") {
return hermes::load_messages_sqlite(source_path);
}
let path = Path::new(source_path);
match provider_id {
@@ -100,6 +106,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
"opencode" => opencode::load_messages(path),
"openclaw" => openclaw::load_messages(path),
"gemini" => gemini::load_messages(path),
"hermes" => hermes::load_messages(path),
_ => Err(format!("Unsupported provider: {provider_id}")),
}
}
@@ -109,10 +116,13 @@ pub fn delete_session(
session_id: &str,
source_path: &str,
) -> Result<bool, String> {
// OpenCode SQLite sessions bypass the file-based deletion path
// SQLite sessions bypass the file-based deletion path
if provider_id == "opencode" && source_path.starts_with("sqlite:") {
return opencode::delete_session_sqlite(session_id, source_path);
}
if provider_id == "hermes" && source_path.starts_with("sqlite:") {
return hermes::delete_session_sqlite(session_id, source_path);
}
let root = provider_root(provider_id)?;
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
@@ -150,6 +160,7 @@ fn delete_session_with_root(
"opencode" => opencode::delete_session(&validated_root, &validated_source, session_id),
"openclaw" => openclaw::delete_session(&validated_root, &validated_source, session_id),
"gemini" => gemini::delete_session(&validated_root, &validated_source, session_id),
"hermes" => hermes::delete_session(&validated_root, &validated_source, session_id),
_ => Err(format!("Unsupported provider: {provider_id}")),
}
}
@@ -161,6 +172,7 @@ fn provider_root(provider_id: &str) -> Result<PathBuf, String> {
"opencode" => opencode::get_opencode_data_dir(),
"openclaw" => crate::openclaw_config::get_openclaw_dir().join("agents"),
"gemini" => crate::gemini_config::get_gemini_dir().join("tmp"),
"hermes" => crate::hermes_config::get_hermes_dir().join("sessions"),
_ => return Err(format!("Unsupported provider: {provider_id}")),
};
@@ -143,6 +143,9 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
}
if value.get("type").and_then(Value::as_str) == Some("session_meta") {
if let Some(payload) = value.get("payload") {
if is_subagent_source(payload.get("source")) {
return None;
}
if session_id.is_none() {
session_id = payload
.get("id")
@@ -170,7 +173,10 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
{
let text = payload.get("content").map(extract_text).unwrap_or_default();
let trimmed = text.trim();
if !trimmed.is_empty() && !trimmed.starts_with("# AGENTS.md") {
if !trimmed.is_empty()
&& !trimmed.starts_with("# AGENTS.md")
&& !trimmed.starts_with("<environment_context>")
{
first_user_message = Some(trimmed.to_string());
}
}
@@ -239,6 +245,13 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
})
}
fn is_subagent_source(source: Option<&Value>) -> bool {
source
.and_then(|value| value.as_object())
.map(|source| source.contains_key("subagent"))
.unwrap_or(false)
}
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
let file_name = path.file_name()?.to_string_lossy();
UUID_RE.find(&file_name).map(|mat| mat.as_str().to_string())
@@ -328,6 +341,41 @@ mod tests {
assert_eq!(meta.title.as_deref(), Some("Fix the login bug"));
}
#[test]
fn parse_session_skips_subagent_sessions() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
std::fs::write(
&path,
concat!(
"{\"timestamp\":\"2026-04-28T10:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"subagent-id\",\"cwd\":\"/tmp/project\",\"originator\":\"codex-tui\",\"source\":{\"subagent\":{\"thread_spawn\":{\"parent_thread_id\":\"parent-id\",\"depth\":1,\"agent_role\":\"explorer\"}}}}}\n",
"{\"timestamp\":\"2026-04-28T10:00:01Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Inspect the project\"}}\n"
),
)
.expect("write");
assert!(parse_session(&path).is_none());
}
#[test]
fn parse_session_skips_environment_context_injection() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
std::fs::write(
&path,
concat!(
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"<environment_context>\\n <cwd>/tmp/project</cwd>\\n</environment_context>\"}}\n",
"{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Fix the login bug\"}}\n"
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
// Should skip environment_context injection and use the real user message
assert_eq!(meta.title.as_deref(), Some("Fix the login bug"));
}
#[test]
fn parse_session_falls_back_to_dir_basename() {
let temp = tempdir().expect("tempdir");
@@ -17,7 +17,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
let mut sessions = Vec::new();
// Iterate over project hash directories: tmp/<project_hash>/chats/session-*.json
// Iterate over project directories: tmp/<project_name>/chats/session-*.json
let project_dirs = match std::fs::read_dir(&tmp_dir) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
@@ -34,13 +34,19 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
Err(_) => continue,
};
let project_root_file = entry.path().join(".project_root");
let project_dir = std::fs::read_to_string(project_root_file).ok();
for file_entry in chat_files.flatten() {
let path = file_entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Some(meta) = parse_session(&path) {
sessions.push(meta);
sessions.push(SessionMeta {
project_dir: project_dir.clone(),
..meta
});
}
}
}
@@ -159,7 +165,7 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
session_id: session_id.clone(),
title: title.clone(),
summary: title,
project_dir: None, // project hash is not reversible
project_dir: None, // (optionally) populated later
created_at,
last_active_at: last_active_at.or(created_at),
source_path: Some(source_path),
@@ -0,0 +1,603 @@
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use rusqlite::Connection;
use serde_json::Value;
use crate::hermes_config::get_hermes_dir;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{
extract_text, parse_timestamp_to_ms, read_head_tail_lines, truncate_summary, TITLE_MAX_CHARS,
};
const PROVIDER_ID: &str = "hermes";
fn get_hermes_db_path() -> PathBuf {
get_hermes_dir().join("state.db")
}
fn get_hermes_sessions_dir() -> PathBuf {
get_hermes_dir().join("sessions")
}
/// Scan sessions from both SQLite database and JSONL transcript files,
/// with SQLite taking precedence on ID conflicts.
pub fn scan_sessions() -> Vec<SessionMeta> {
let sqlite_sessions = scan_sessions_sqlite();
let jsonl_sessions = scan_sessions_jsonl();
if sqlite_sessions.is_empty() {
return jsonl_sessions;
}
if jsonl_sessions.is_empty() {
return sqlite_sessions;
}
let sqlite_ids: std::collections::HashSet<String> = sqlite_sessions
.iter()
.map(|s| s.session_id.clone())
.collect();
let mut merged = sqlite_sessions;
for s in jsonl_sessions {
if !sqlite_ids.contains(&s.session_id) {
merged.push(s);
}
}
merged
}
// ── SQLite scanning ─────────────────────────────────────────────────
fn scan_sessions_sqlite() -> Vec<SessionMeta> {
let db_path = get_hermes_db_path();
if !db_path.exists() {
return Vec::new();
}
let conn = match Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
// Check if sessions table exists
let has_sessions: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='sessions'",
[],
|row| row.get(0),
)
.unwrap_or(false);
if !has_sessions {
return Vec::new();
}
// Query sessions — use flexible column access via pragma
let columns = get_table_columns(&conn, "sessions");
let query = "SELECT * FROM sessions ORDER BY rowid DESC LIMIT 500";
let mut stmt = match conn.prepare(query) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let mut sessions = Vec::new();
let rows = match stmt.query_map([], |row| Ok(row_to_json(row, &columns))) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let db_source = format!("sqlite:{}", db_path.display());
for row_result in rows.flatten() {
if let Some(meta) = sqlite_row_to_session_meta(&row_result, &db_source) {
sessions.push(meta);
}
}
sessions
}
fn sqlite_row_to_session_meta(row: &Value, db_source: &str) -> Option<SessionMeta> {
let obj = row.as_object()?;
let session_id = obj.get("id").and_then(Value::as_str)?.to_string();
let title = obj
.get("title")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(|s| truncate_summary(s, TITLE_MAX_CHARS).to_string());
let cwd = obj
.get("cwd")
.or_else(|| obj.get("directory"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let started_at = obj
.get("started_at")
.or_else(|| obj.get("created_at"))
.and_then(parse_timestamp_to_ms);
let ended_at = obj
.get("ended_at")
.or_else(|| obj.get("updated_at"))
.and_then(parse_timestamp_to_ms);
let source_path = format!("{}#{}", db_source, session_id);
Some(SessionMeta {
provider_id: PROVIDER_ID.to_string(),
session_id,
title,
summary: None,
project_dir: cwd,
created_at: started_at,
last_active_at: ended_at.or(started_at),
source_path: Some(source_path),
resume_command: None,
})
}
/// Get column names for a table.
fn get_table_columns(conn: &Connection, table: &str) -> Vec<String> {
let query = format!("PRAGMA table_info({table})");
let mut stmt = match conn.prepare(&query) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let rows = match stmt.query_map([], |row| {
let name: String = row.get(1)?;
Ok(name)
}) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
rows.flatten().collect()
}
/// Convert a SQLite row to a JSON Value using known column names.
fn row_to_json(row: &rusqlite::Row, columns: &[String]) -> Value {
let mut map = serde_json::Map::new();
for (i, col) in columns.iter().enumerate() {
// Try string first, then integer, then float, then null
if let Ok(val) = row.get::<_, String>(i) {
map.insert(col.clone(), Value::String(val));
} else if let Ok(val) = row.get::<_, i64>(i) {
map.insert(col.clone(), Value::Number(val.into()));
} else if let Ok(val) = row.get::<_, f64>(i) {
if let Some(n) = serde_json::Number::from_f64(val) {
map.insert(col.clone(), Value::Number(n));
}
} else {
map.insert(col.clone(), Value::Null);
}
}
Value::Object(map)
}
/// Load messages from the Hermes SQLite database.
pub fn load_messages_sqlite(source: &str) -> Result<Vec<SessionMessage>, String> {
let (db_path, session_id) = parse_sqlite_source(source)
.ok_or_else(|| format!("Invalid SQLite source reference: {source}"))?;
let conn = Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| format!("Failed to open Hermes database: {e}"))?;
// Try querying with common column names
let query =
"SELECT role, content, created_at FROM messages WHERE session_id = ?1 ORDER BY created_at ASC";
let mut stmt = conn
.prepare(query)
.map_err(|e| format!("Failed to prepare messages query: {e}"))?;
let rows = stmt
.query_map([session_id.as_str()], |row| {
let role: String = row.get(0)?;
let content: String = row.get(1)?;
let ts: Option<i64> = row.get(2).ok();
Ok((role, content, ts))
})
.map_err(|e| format!("Failed to query messages: {e}"))?;
let mut messages = Vec::new();
for row in rows.flatten() {
let (role, content, ts) = row;
if content.trim().is_empty() {
continue;
}
let ts_ms = ts.and_then(|v| parse_timestamp_to_ms(&Value::Number(v.into())));
messages.push(SessionMessage {
role,
content,
ts: ts_ms,
});
}
Ok(messages)
}
/// Delete a session from the Hermes SQLite database.
pub fn delete_session_sqlite(session_id: &str, source: &str) -> Result<bool, String> {
let (db_path, ref_session_id) = parse_sqlite_source(source)
.ok_or_else(|| format!("Invalid SQLite source reference: {source}"))?;
let db_path = db_path
.canonicalize()
.map_err(|e| format!("Failed to canonicalize Hermes database path: {e}"))?;
let expected_db_path = get_hermes_db_path()
.canonicalize()
.map_err(|e| format!("Failed to canonicalize expected Hermes database path: {e}"))?;
if ref_session_id != session_id {
return Err(format!(
"Hermes SQLite session ID mismatch: expected {session_id}, found {ref_session_id}"
));
}
if db_path != expected_db_path {
return Err("SQLite path does not match expected Hermes database".to_string());
}
let conn =
Connection::open(&db_path).map_err(|e| format!("Failed to open Hermes database: {e}"))?;
let tx = conn
.unchecked_transaction()
.map_err(|e| format!("Failed to begin transaction: {e}"))?;
// Delete messages first (child records)
let _ = tx.execute("DELETE FROM messages WHERE session_id = ?1", [session_id]);
let deleted = tx
.execute("DELETE FROM sessions WHERE id = ?1", [session_id])
.map_err(|e| format!("Failed to delete Hermes session: {e}"))?;
tx.commit()
.map_err(|e| format!("Failed to commit session deletion: {e}"))?;
Ok(deleted > 0)
}
fn parse_sqlite_source(source: &str) -> Option<(PathBuf, String)> {
let rest = source.strip_prefix("sqlite:")?;
let hash_pos = rest.rfind('#')?;
let db_path = PathBuf::from(&rest[..hash_pos]);
let session_id = rest[hash_pos + 1..].to_string();
if session_id.is_empty() {
return None;
}
Some((db_path, session_id))
}
// ── JSONL scanning ──────────────────────────────────────────────────
fn scan_sessions_jsonl() -> Vec<SessionMeta> {
let sessions_dir = get_hermes_sessions_dir();
if !sessions_dir.exists() {
return Vec::new();
}
let entries = match std::fs::read_dir(&sessions_dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
let mut sessions = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str());
if ext != Some("jsonl") && ext != Some("json") {
continue;
}
if let Some(meta) = parse_jsonl_session(&path) {
sessions.push(meta);
}
}
sessions
}
fn parse_jsonl_session(path: &Path) -> Option<SessionMeta> {
// Read head (metadata + first user message) and tail (last timestamp)
let (head, tail) = read_head_tail_lines(path, 30, 10).ok()?;
let mut first_user_msg: Option<String> = None;
let mut first_ts: Option<i64> = None;
let mut last_ts: Option<i64> = None;
let mut session_id: Option<String> = None;
let mut title: Option<String> = None;
let mut cwd: Option<String> = None;
// Process head lines for metadata and first user message
for line in &head {
if line.trim().is_empty() {
continue;
}
let value: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
let ts = value
.get("timestamp")
.or_else(|| value.get("ts"))
.and_then(parse_timestamp_to_ms);
if first_ts.is_none() {
first_ts = ts;
}
last_ts = ts.or(last_ts);
let line_type = value.get("type").and_then(Value::as_str).unwrap_or("");
// Extract session metadata from session-type lines
if line_type == "session" || line_type == "init" {
if session_id.is_none() {
session_id = value
.get("id")
.or_else(|| value.get("sessionId"))
.and_then(Value::as_str)
.map(|s| s.to_string());
}
if title.is_none() {
title = value
.get("title")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
}
if cwd.is_none() {
cwd = value
.get("cwd")
.or_else(|| value.get("directory"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
}
}
if first_user_msg.is_none() {
let role = value
.get("role")
.or_else(|| value.get("message").and_then(|m| m.get("role")))
.and_then(Value::as_str);
if role == Some("user") {
let content = value
.get("content")
.or_else(|| value.get("message").and_then(|m| m.get("content")));
if let Some(c) = content {
let text = extract_text(c);
if !text.trim().is_empty() {
first_user_msg = Some(truncate_summary(&text, TITLE_MAX_CHARS).to_string());
}
}
}
}
}
// Process tail lines for the most recent timestamp
for line in tail.iter().rev() {
if line.trim().is_empty() {
continue;
}
let value: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
let ts = value
.get("timestamp")
.or_else(|| value.get("ts"))
.and_then(parse_timestamp_to_ms);
if let Some(t) = ts {
last_ts = Some(t);
break;
}
}
// Fall back to filename as session ID
let session_id = session_id.unwrap_or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string()
});
let source_path = path.to_string_lossy().to_string();
Some(SessionMeta {
provider_id: PROVIDER_ID.to_string(),
session_id,
title: title.or_else(|| first_user_msg.clone()),
summary: first_user_msg,
project_dir: cwd,
created_at: first_ts,
last_active_at: last_ts.or(first_ts),
source_path: Some(source_path),
resume_command: None,
})
}
/// Load messages from a Hermes JSONL transcript file.
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?;
let reader = BufReader::new(file);
let mut messages = Vec::new();
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
if line.trim().is_empty() {
continue;
}
let value: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
// Support both flat messages and nested {type:"message", message:{...}} format
let (role_val, content_val, ts_val) =
if value.get("type").and_then(Value::as_str) == Some("message") {
let msg = match value.get("message") {
Some(m) => m,
None => continue,
};
(
msg.get("role"),
msg.get("content"),
value.get("timestamp").or_else(|| msg.get("ts")),
)
} else {
(
value.get("role"),
value.get("content"),
value.get("timestamp").or_else(|| value.get("ts")),
)
};
let role = match role_val.and_then(Value::as_str) {
Some(r) => r.to_string(),
None => continue,
};
let content = content_val.map(extract_text).unwrap_or_default();
if content.trim().is_empty() {
continue;
}
let ts = ts_val.and_then(parse_timestamp_to_ms);
messages.push(SessionMessage { role, content, ts });
}
Ok(messages)
}
/// Delete a Hermes JSONL session file.
pub fn delete_session(_root: &Path, path: &Path, _session_id: &str) -> Result<bool, String> {
std::fs::remove_file(path).map_err(|e| {
format!(
"Failed to delete Hermes session file {}: {e}",
path.display()
)
})?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn parse_sqlite_source_valid() {
let (path, id) = parse_sqlite_source("sqlite:/home/user/.hermes/state.db#session-123")
.expect("should parse");
assert_eq!(path, PathBuf::from("/home/user/.hermes/state.db"));
assert_eq!(id, "session-123");
}
#[test]
fn parse_sqlite_source_invalid() {
assert!(parse_sqlite_source("not-sqlite").is_none());
assert!(parse_sqlite_source("sqlite:").is_none());
assert!(parse_sqlite_source("sqlite:/path#").is_none());
}
#[test]
fn parse_jsonl_session_extracts_metadata() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("test-session.jsonl");
let mut f = File::create(&path).expect("create");
writeln!(
f,
r#"{{"type":"session","id":"s1","title":"My Session","cwd":"/home/user/project"}}"#
)
.unwrap();
writeln!(f, r#"{{"type":"message","message":{{"role":"user","content":"Hello world"}},"timestamp":"2026-01-01T00:00:00Z"}}"#).unwrap();
writeln!(f, r#"{{"type":"message","message":{{"role":"assistant","content":"Hi there"}},"timestamp":"2026-01-01T00:01:00Z"}}"#).unwrap();
f.flush().unwrap();
let meta = parse_jsonl_session(&path).expect("should parse");
assert_eq!(meta.session_id, "s1");
assert_eq!(meta.title.as_deref(), Some("My Session"));
assert_eq!(meta.project_dir.as_deref(), Some("/home/user/project"));
assert!(meta.created_at.is_some());
assert!(meta.last_active_at.is_some());
}
#[test]
fn parse_jsonl_session_fallback_to_filename() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("my-session.jsonl");
let mut f = File::create(&path).expect("create");
writeln!(f, r#"{{"role":"user","content":"Hello","ts":1700000000}}"#).unwrap();
f.flush().unwrap();
let meta = parse_jsonl_session(&path).expect("should parse");
assert_eq!(meta.session_id, "my-session");
assert!(meta.title.is_some()); // Falls back to first user message
}
#[test]
fn load_messages_flat_format() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("session.jsonl");
let mut f = File::create(&path).expect("create");
writeln!(
f,
r#"{{"role":"user","content":"What is Rust?","ts":1700000000}}"#
)
.unwrap();
writeln!(
f,
r#"{{"role":"assistant","content":"A systems programming language.","ts":1700000001}}"#
)
.unwrap();
f.flush().unwrap();
let msgs = load_messages(&path).expect("should load");
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, "user");
assert_eq!(msgs[1].role, "assistant");
}
#[test]
fn load_messages_nested_format() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("session.jsonl");
let mut f = File::create(&path).expect("create");
writeln!(f, r#"{{"type":"session","id":"s1"}}"#).unwrap();
writeln!(f, r#"{{"type":"message","message":{{"role":"user","content":"Hello"}},"timestamp":"2026-01-01T00:00:00Z"}}"#).unwrap();
writeln!(f, r#"{{"type":"message","message":{{"role":"assistant","content":"Hi"}},"timestamp":"2026-01-01T00:01:00Z"}}"#).unwrap();
f.flush().unwrap();
let msgs = load_messages(&path).expect("should load");
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, "user");
assert!(msgs[0].ts.is_some());
}
#[test]
fn delete_session_removes_file() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("session.jsonl");
File::create(&path).expect("create");
assert!(path.exists());
delete_session(dir.path(), &path, "session").expect("should delete");
assert!(!path.exists());
}
}
@@ -1,6 +1,7 @@
pub mod claude;
pub mod codex;
pub mod gemini;
pub mod hermes;
pub mod openclaw;
pub mod opencode;
mod utils;
+80 -66
View File
@@ -22,6 +22,8 @@ pub fn launch_terminal(
"wezterm" => launch_wezterm(command, cwd),
"kaku" => launch_kaku(command, cwd),
"alacritty" => launch_alacritty(command, cwd),
#[cfg(unix)]
"warp" => launch_warp(command, cwd),
"custom" => launch_custom(command, cwd, custom_config),
_ => Err(format!("Unsupported terminal target: {target}")),
}
@@ -78,22 +80,7 @@ end tell"#
}
fn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> {
let args = build_ghostty_args(command, cwd);
let status = Command::new("open")
.args(args.iter().map(String::as_str))
.status()
.map_err(|e| format!("Failed to launch Ghostty: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Ghostty. Make sure it is installed.".to_string())
}
}
fn build_ghostty_args(command: &str, cwd: Option<&str>) -> Vec<String> {
let input = ghostty_raw_input(command);
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let mut args = vec![
"-na".to_string(),
@@ -108,22 +95,22 @@ fn build_ghostty_args(command: &str, cwd: Option<&str>) -> Vec<String> {
}
}
args.push(format!("--input={input}"));
args
}
args.push("-e".to_string());
args.push(shell);
args.push("-l".to_string());
args.push("-c".to_string());
args.push(command.to_string());
fn ghostty_raw_input(command: &str) -> String {
let mut escaped = String::from("raw:");
for ch in command.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
_ => escaped.push(ch),
}
let status = Command::new("open")
.args(&args)
.status()
.map_err(|e| format!("Failed to launch Ghostty: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Ghostty. Make sure it is installed.".to_string())
}
escaped.push_str("\\n");
escaped
}
fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> {
@@ -216,6 +203,48 @@ fn build_wezterm_compatible_args_with_shell(
args
}
#[cfg(unix)]
fn launch_warp(command: &str, cwd: Option<&str>) -> Result<(), String> {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let cwd = cwd.ok_or("Failed to resume session without cwd")?;
let mut script_file = tempfile::Builder::new()
.disable_cleanup(true)
.permissions(std::fs::Permissions::from_mode(0o755))
.tempfile_in(cwd)
.map_err(|e| format!("Failed to create temporary script file for launching Warp: {e}"))?;
writeln!(
&mut script_file,
r#"#!/usr/bin/env sh
rm -- "$0"
exec {command}
"#,
)
.map_err(|e| format!("Failed to write to temporary script file for Warp: {e}"))?;
let mut warp_url = url::Url::parse("warp://action/new_tab").unwrap();
warp_url
.query_pairs_mut()
.append_pair("path", &script_file.path().to_string_lossy());
let warp_url = warp_url.to_string();
let status = Command::new("open")
.args(["-a", "Warp", &warp_url])
.status()
.map_err(|e| format!("Failed to launch Warp: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Warp.".to_string())
}
}
fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> {
// Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command
let full_command = build_shell_command(command, None);
@@ -300,43 +329,10 @@ mod tests {
use super::*;
#[test]
fn ghostty_uses_shell_mode_for_resume_commands() {
let args = build_ghostty_args("claude --resume abc-123", Some("/tmp/project dir"));
fn build_shell_command_keeps_command_without_cwd_prefix_when_not_provided() {
assert_eq!(
args,
vec![
"-na",
"Ghostty",
"--args",
"--quit-after-last-window-closed=true",
"--working-directory=/tmp/project dir",
"--input=raw:claude --resume abc-123\\n",
]
);
}
#[test]
fn ghostty_keeps_command_without_cwd_prefix_when_not_provided() {
let args = build_ghostty_args("claude --resume abc-123", None);
assert_eq!(
args,
vec![
"-na",
"Ghostty",
"--args",
"--quit-after-last-window-closed=true",
"--input=raw:claude --resume abc-123\\n",
]
);
}
#[test]
fn ghostty_escapes_newlines_and_backslashes_in_input() {
assert_eq!(
ghostty_raw_input("echo foo\\\\bar\npwd"),
"raw:echo foo\\\\\\\\bar\\npwd\\n"
build_shell_command("claude --resume abc-123", None),
"claude --resume abc-123"
);
}
@@ -365,4 +361,22 @@ mod tests {
]
);
}
#[test]
fn ghostty_uses_working_directory_arg_for_cwd() {
// cwd should be passed as --working-directory, not embedded in the shell command string
// This avoids shell expansion of special characters in directory paths
let cwd = "/tmp/project dir";
let command = "claude --resume abc-123";
// Verify build_shell_command does NOT include cwd when used in ghostty context
// (ghostty passes cwd via --working-directory flag instead)
assert_eq!(
build_shell_command(command, None),
"claude --resume abc-123"
);
// Verify shell_escape works correctly for paths with spaces
assert_eq!(shell_escape(cwd), "\"/tmp/project dir\"");
}
}
+80
View File
@@ -28,6 +28,13 @@ fn default_true() -> bool {
pub struct VisibleApps {
#[serde(default = "default_true")]
pub claude: bool,
#[serde(
rename = "claude-desktop",
alias = "claudeDesktop",
alias = "claude_desktop",
default = "default_true"
)]
pub claude_desktop: bool,
#[serde(default = "default_true")]
pub codex: bool,
#[serde(default = "default_true")]
@@ -36,16 +43,20 @@ pub struct VisibleApps {
pub opencode: bool,
#[serde(default = "default_true")]
pub openclaw: bool,
#[serde(default)]
pub hermes: bool,
}
impl Default for VisibleApps {
fn default() -> Self {
Self {
claude: true,
claude_desktop: true,
codex: true,
gemini: true,
opencode: true,
openclaw: true,
hermes: false, // 默认不显示,需用户手动启用
}
}
}
@@ -55,10 +66,12 @@ impl VisibleApps {
pub fn is_visible(&self, app: &AppType) -> bool {
match app {
AppType::Claude => self.claude,
AppType::ClaudeDesktop => self.claude_desktop,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => self.openclaw,
AppType::Hermes => self.hermes,
}
}
}
@@ -231,11 +244,16 @@ pub struct AppSettings {
pub opencode_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openclaw_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hermes_config_dir: Option<String>,
// ===== 当前供应商 ID(设备级)=====
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_claude: Option<String>,
/// 当前 Claude Desktop 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_claude_desktop: Option<String>,
/// 当前 Codex 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_codex: Option<String>,
@@ -248,6 +266,9 @@ pub struct AppSettings {
/// 当前 OpenClaw 供应商 ID(本地存储,对 OpenClaw 可能无意义,但保持结构一致)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_openclaw: Option<String>,
/// 当前 Hermes 供应商 ID(本地存储,保持结构一致)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_hermes: Option<String>,
// ===== Skill 同步设置 =====
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
@@ -315,11 +336,14 @@ impl Default for AppSettings {
gemini_config_dir: None,
opencode_config_dir: None,
openclaw_config_dir: None,
hermes_config_dir: None,
current_provider_claude: None,
current_provider_claude_desktop: None,
current_provider_codex: None,
current_provider_gemini: None,
current_provider_opencode: None,
current_provider_openclaw: None,
current_provider_hermes: None,
skill_sync_method: SyncMethod::default(),
skill_storage_location: SkillStorageLocation::default(),
webdav_sync: None,
@@ -377,6 +401,13 @@ impl AppSettings {
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.hermes_config_dir = self
.hermes_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.language = self
.language
.as_ref()
@@ -577,6 +608,14 @@ pub fn get_openclaw_override_dir() -> Option<PathBuf> {
.map(|p| resolve_override_path(p))
}
pub fn get_hermes_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.hermes_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}
// ===== 当前供应商管理函数 =====
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
@@ -587,10 +626,12 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
let settings = settings_store().read().ok()?;
match app_type {
AppType::Claude => settings.current_provider_claude.clone(),
AppType::ClaudeDesktop => settings.current_provider_claude_desktop.clone(),
AppType::Codex => settings.current_provider_codex.clone(),
AppType::Gemini => settings.current_provider_gemini.clone(),
AppType::OpenCode => settings.current_provider_opencode.clone(),
AppType::OpenClaw => settings.current_provider_openclaw.clone(),
AppType::Hermes => settings.current_provider_hermes.clone(),
}
}
@@ -602,10 +643,12 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
let id_owned = id.map(|s| s.to_string());
mutate_settings(|settings| match app_type {
AppType::Claude => settings.current_provider_claude = id_owned.clone(),
AppType::ClaudeDesktop => settings.current_provider_claude_desktop = id_owned.clone(),
AppType::Codex => settings.current_provider_codex = id_owned.clone(),
AppType::Gemini => settings.current_provider_gemini = id_owned.clone(),
AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(),
AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(),
AppType::Hermes => settings.current_provider_hermes = id_owned.clone(),
})
}
@@ -740,3 +783,40 @@ pub fn update_webdav_sync_status(status: WebDavSyncStatus) -> Result<(), AppErro
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_config::AppType;
#[test]
fn visible_apps_old_settings_default_claude_desktop_visible() {
let visible: VisibleApps = serde_json::from_value(serde_json::json!({
"claude": true,
"codex": true,
"gemini": true,
"opencode": true,
"openclaw": true,
"hermes": true
}))
.expect("visible apps");
assert!(visible.is_visible(&AppType::ClaudeDesktop));
}
#[test]
fn visible_apps_accepts_claude_desktop_aliases() {
let visible: VisibleApps = serde_json::from_value(serde_json::json!({
"claude": true,
"claudeDesktop": false,
"codex": true,
"gemini": true,
"opencode": true,
"openclaw": true,
"hermes": true
}))
.expect("visible apps");
assert!(!visible.is_visible(&AppType::ClaudeDesktop));
}
}
+7 -2
View File
@@ -1,11 +1,12 @@
use crate::database::Database;
use crate::services::ProxyService;
use crate::services::{ProxyService, UsageCache};
use std::sync::Arc;
/// 全局应用状态
pub struct AppState {
pub db: Arc<Database>,
pub proxy_service: ProxyService,
pub usage_cache: Arc<UsageCache>,
}
impl AppState {
@@ -13,6 +14,10 @@ impl AppState {
pub fn new(db: Arc<Database>) -> Self {
let proxy_service = ProxyService::new(db.clone());
Self { db, proxy_service }
Self {
db,
proxy_service,
usage_cache: Arc::new(UsageCache::new()),
}
}
}
+635 -19
View File
@@ -2,17 +2,26 @@
//!
//! 负责系统托盘图标和菜单的创建、更新和事件处理。
use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem, SubmenuBuilder};
use once_cell::sync::Lazy;
use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem, Submenu, SubmenuBuilder};
use tauri::{Emitter, Manager};
use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::error::AppError;
use crate::store::AppState;
/// 每个 app 分区的子菜单句柄,用于 usage 更新时就地改 label 而非整菜单重建。
/// `create_tray_menu` 每次重建都会整表覆盖写入,保证句柄始终指向当前活跃菜单。
static TRAY_SECTION_SUBMENUS: Lazy<
std::sync::Mutex<std::collections::HashMap<AppType, Submenu<tauri::Wry>>>,
> = Lazy::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
/// 托盘菜单文本(国际化)
#[derive(Clone, Copy)]
pub struct TrayTexts {
pub show_main: &'static str,
pub open_website: &'static str,
pub no_providers_label: &'static str,
pub lightweight_mode: &'static str,
pub quit: &'static str,
@@ -24,6 +33,7 @@ impl TrayTexts {
match language {
"en" => Self {
show_main: "Open main window",
open_website: "Open Official Website",
no_providers_label: "(no providers)",
lightweight_mode: "Lightweight Mode",
quit: "Quit",
@@ -31,6 +41,7 @@ impl TrayTexts {
},
"ja" => Self {
show_main: "メインウィンドウを開く",
open_website: "公式サイトを開く",
no_providers_label: "(プロバイダーなし)",
lightweight_mode: "軽量モード",
quit: "終了",
@@ -38,6 +49,7 @@ impl TrayTexts {
},
_ => Self {
show_main: "打开主界面",
open_website: "打开官方网站",
no_providers_label: "(无供应商)",
lightweight_mode: "轻量模式",
quit: "退出",
@@ -58,6 +70,7 @@ pub struct TrayAppSection {
/// Auto 菜单项后缀
pub const AUTO_SUFFIX: &str = "auto";
pub const TRAY_ID: &str = "cc-switch";
pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
TrayAppSection {
@@ -83,6 +96,182 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
},
];
/// 配色阈值(与前端 `utilizationColor` 语义一致)。
const UTIL_WARN_PCT: f64 = 70.0;
const UTIL_DANGER_PCT: f64 = 90.0;
fn emoji_for_utilization(pct: f64) -> &'static str {
if pct >= UTIL_DANGER_PCT {
"\u{1F534}" // 🔴
} else if pct >= UTIL_WARN_PCT {
"\u{1F7E0}" // 🟠
} else {
"\u{1F7E2}" // 🟢
}
}
fn format_subscription_summary(
quota: &crate::services::subscription::SubscriptionQuota,
) -> Option<String> {
use crate::services::subscription::{
TIER_FIVE_HOUR, TIER_GEMINI_FLASH, TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_SEVEN_DAY,
};
if !quota.success {
return None;
}
// 按 tool 选取主卡槽 tier 并映射到短 label
// Claude / Codex 沿用时间窗口(h=5 小时,w=7 天);
// Gemini 用模型维度(p=prof=flashl=flash-lite)——Gemini 后端 tier
// 命名是 gemini_pro / gemini_flash / gemini_flash_lite,与时间窗口不同命名空间。
// flash_lite 必须纳入:否则 lite 利用率最高时色标偏低,与前端 footer 行为不一致。
let parts: Vec<(&'static str, f64)> = match quota.tool.as_str() {
"gemini" => {
let mut v = Vec::new();
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_GEMINI_PRO) {
v.push(("p", t.utilization));
}
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_GEMINI_FLASH) {
v.push(("f", t.utilization));
}
if let Some(t) = quota
.tiers
.iter()
.find(|t| t.name == TIER_GEMINI_FLASH_LITE)
{
v.push(("l", t.utilization));
}
v
}
_ => {
let mut v = Vec::new();
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_FIVE_HOUR) {
v.push(("h", t.utilization));
}
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_SEVEN_DAY) {
v.push(("w", t.utilization));
}
v
}
};
if parts.is_empty() {
return None;
}
// 色标取所有已选 tier 里最高的利用率——用户更关心"离上限多近"。
let worst = parts
.iter()
.map(|(_, u)| *u)
.fold(f64::NEG_INFINITY, f64::max);
if !worst.is_finite() {
return None;
}
let emoji = emoji_for_utilization(worst);
let body = parts
.iter()
.map(|(label, u)| format!("{label}{}%", u.round() as i64))
.collect::<Vec<_>>()
.join(" ");
Some(format!("{emoji} {body}"))
}
fn tier_pct(data: &crate::provider::UsageData) -> Option<f64> {
match (data.used, data.total) {
(Some(used), Some(total)) if total > 0.0 => Some(used / total * 100.0),
_ => None,
}
}
fn format_script_summary(result: &crate::provider::UsageResult) -> Option<String> {
use crate::services::subscription::{TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT};
if !result.success {
return None;
}
let data = result.data.as_ref()?;
if data.is_empty() {
return None;
}
// commands::provider 的 token_plan 分支把 SubscriptionQuota 的每个 tier
// 扁平化为一条 UsageDataplan_name 承载 tier 名),所以这里按 plan_name
// 识别双桶形态,其余 usage 结果(Copilot / balance / 自定义脚本)走 fallback。
const TOKEN_PLAN_LABELS: &[(&str, &str)] = &[(TIER_FIVE_HOUR, "h"), (TIER_WEEKLY_LIMIT, "w")];
let mut parts: Vec<(&'static str, f64)> = Vec::new();
for &(tier_name, label) in TOKEN_PLAN_LABELS {
let Some(d) = data
.iter()
.find(|d| d.plan_name.as_deref() == Some(tier_name))
else {
continue;
};
if let Some(u) = tier_pct(d) {
parts.push((label, u));
}
}
if !parts.is_empty() {
let worst = parts
.iter()
.map(|(_, u)| *u)
.fold(f64::NEG_INFINITY, f64::max);
let emoji = emoji_for_utilization(worst);
let body = parts
.iter()
.map(|(label, u)| format!("{label}{}%", u.round() as i64))
.collect::<Vec<_>>()
.join(" ");
return Some(format!("{emoji} {body}"));
}
let first = data.first()?;
let pct = tier_pct(first)?;
let emoji = emoji_for_utilization(pct);
let plan = first.plan_name.as_deref().unwrap_or("");
let rounded = pct.round() as i64;
if plan.is_empty() {
Some(format!("{} {}%", emoji, rounded))
} else {
Some(format!("{} {} {}%", emoji, plan, rounded))
}
}
fn format_usage_suffix(
app_state: &AppState,
app_type: &AppType,
provider: &crate::provider::Provider,
provider_id: &str,
) -> Option<String> {
// 当前脚本是否启用:禁用/删除时不再沿用旧 UsageCache 结果,
// 并顺手 invalidate,防止后续重建继续命中过期数据。
if provider.has_usage_script_enabled() {
// 脚本缓存优先(覆盖 Copilot/coding_plan/balance/自定义脚本),借用访问避免克隆整条 UsageResult。
if let Some(Some(s)) =
app_state
.usage_cache
.with_script(app_type, provider_id, format_script_summary)
{
return Some(format!(" · {s}"));
}
} else {
app_state
.usage_cache
.invalidate_script(app_type, provider_id);
}
if provider.category.as_deref() == Some("official") {
if let Some(Some(s)) = app_state
.usage_cache
.with_subscription(app_type, format_subscription_summary)
{
return Some(format!(" · {s}"));
}
}
None
}
/// 对供应商列表排序:sort_index → created_at → name
fn sort_providers(
providers: &indexmap::IndexMap<String, crate::provider::Provider>,
@@ -207,7 +396,7 @@ fn handle_auto_click(app: &tauri::AppHandle, app_type: &AppType) -> Result<(), A
// 4) 更新托盘菜单
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Some(tray) = app.tray_by_id(TRAY_ID) {
let _ = tray.set_menu(Some(new_menu));
}
}
@@ -245,17 +434,13 @@ fn handle_provider_click(
.db
.set_proxy_flags_sync(app_type_str, proxy_enabled, false)?;
// 切换供应商
crate::commands::switch_provider(
app_state.clone(),
app_type_str.to_string(),
provider_id.to_string(),
)
.map_err(AppError::Message)?;
// 切换供应商。需要本地路由的供应商也不在这里自动启动代理,
// 由用户在页面/设置中手动开启。
crate::services::ProviderService::switch(app_state.inner(), app_type.clone(), provider_id)?;
// 更新托盘菜单
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Some(tray) = app.tray_by_id(TRAY_ID) {
let _ = tray.set_menu(Some(new_menu));
}
}
@@ -290,12 +475,25 @@ pub fn create_tray_menu(
let visible_apps = app_settings.visible_apps.unwrap_or_default();
let mut menu_builder = MenuBuilder::new(app);
let mut section_handles: std::collections::HashMap<AppType, Submenu<tauri::Wry>> =
std::collections::HashMap::new();
// 顶部:打开主界面
// 顶部:打开主界面 / 打开官方网站
let show_main_item =
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
menu_builder = menu_builder.item(&show_main_item).separator();
let open_website_item = MenuItem::with_id(
app,
"open_website",
tray_texts.open_website,
true,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建打开官方网站菜单失败: {e}")))?;
menu_builder = menu_builder
.item(&show_main_item)
.item(&open_website_item)
.separator();
// Pre-compute proxy running state (used to disable official providers in tray menu)
let is_proxy_running = futures::executor::block_on(app_state.proxy_service.is_running());
@@ -322,10 +520,13 @@ pub fn create_tray_menu(
})?;
menu_builder = menu_builder.item(&empty_item);
} else {
// 有供应商:构建子菜单
let current_name = providers.get(&current_id).map(|p| p.name.as_str());
let submenu_label = match current_name {
Some(name) => format!("{} · {}", section.header_label, name),
let current_provider = providers.get(&current_id);
let submenu_label = match current_provider {
Some(p) => {
let suffix = format_usage_suffix(app_state, &section.app_type, p, &current_id)
.unwrap_or_default();
format!("{} · {}{}", section.header_label, p.name, suffix)
}
None => section.header_label.to_string(),
};
let submenu_id = format!("submenu_{}", app_type_str);
@@ -368,6 +569,7 @@ pub fn create_tray_menu(
let submenu = submenu_builder.build().map_err(|e| {
AppError::Message(format!("构建{}子菜单失败: {e}", section.log_name))
})?;
section_handles.insert(section.app_type.clone(), submenu.clone());
menu_builder = menu_builder.item(&submenu);
}
@@ -392,9 +594,51 @@ pub fn create_tray_menu(
menu_builder = menu_builder.item(&quit_item);
menu_builder
let menu = menu_builder
.build()
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))?;
*TRAY_SECTION_SUBMENUS
.lock()
.unwrap_or_else(|p| p.into_inner()) = section_handles;
Ok(menu)
}
/// 就地更新各 app 分区子菜单的标题(usage 后缀变化时走这条),
/// 避免 `set_menu` 导致用户打开中的菜单被关闭。
/// 句柄由上一次 `create_tray_menu` 填充;为空(从未构建过菜单)时无事发生。
fn update_tray_usage_labels(app: &tauri::AppHandle) {
let Some(app_state) = app.try_state::<AppState>() else {
return;
};
let handles = match TRAY_SECTION_SUBMENUS.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
for section in TRAY_SECTIONS.iter() {
let Some(submenu) = handles.get(&section.app_type) else {
continue;
};
let Ok(providers) = app_state.db.get_all_providers(section.app_type.as_str()) else {
continue;
};
let Ok(Some(current_id)) =
crate::settings::get_effective_current_provider(&app_state.db, &section.app_type)
else {
continue;
};
let Some(provider) = providers.get(&current_id) else {
continue;
};
let suffix = format_usage_suffix(&app_state, &section.app_type, provider, &current_id)
.unwrap_or_default();
let new_label = format!("{} · {}{}", section.header_label, provider.name, suffix);
if let Err(e) = submenu.set_text(&new_label) {
log::debug!("[Tray] 更新{}子菜单标题失败: {e}", section.log_name);
}
}
}
pub fn refresh_tray_menu(app: &tauri::AppHandle) {
@@ -402,7 +646,7 @@ pub fn refresh_tray_menu(app: &tauri::AppHandle) {
if let Some(state) = app.try_state::<AppState>() {
if let Ok(new_menu) = create_tray_menu(app, state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Some(tray) = app.tray_by_id(TRAY_ID) {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("刷新托盘菜单失败: {e}");
}
@@ -458,6 +702,11 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
}
}
}
"open_website" => {
if let Err(e) = app.opener().open_url("https://ccswitch.io", None::<String>) {
log::error!("打开官方网站失败: {e}");
}
}
"lightweight_mode" => {
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
@@ -479,3 +728,370 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
}
}
}
static LAST_TRAY_USAGE_REFRESH: std::sync::Mutex<Option<std::time::Instant>> =
std::sync::Mutex::new(None);
const MIN_TRAY_USAGE_REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
/// 合并多次快速触发的"usage 标题软更新":批量刷新期间多个 usage 命令
/// 同时成功时,只会产生一次就地 `set_text` 批量调用。走软更新而不是
/// `refresh_tray_menu` 整建,避免用户打开中的菜单被 macOS 系统关闭。
static TRAY_REBUILD_SCHEDULED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub fn schedule_tray_refresh(app: &tauri::AppHandle) {
use std::sync::atomic::Ordering;
if TRAY_REBUILD_SCHEDULED.swap(true, Ordering::AcqRel) {
return;
}
let app = app.clone();
tauri::async_runtime::spawn_blocking(move || {
// 50ms 合窗:让同一轮 React Query / 托盘批量刷新触发的多个写入
// 共享一次标题更新。
std::thread::sleep(std::time::Duration::from_millis(50));
TRAY_REBUILD_SCHEDULED.store(false, Ordering::Release);
update_tray_usage_labels(&app);
});
}
/// 并行刷新每个可见 app "当前 provider" 的用量;成功 / 失败结果都通过各
/// command 的 write-through 逻辑写入 `UsageCache`,单次重建菜单由
/// `schedule_tray_refresh` 做合并。内部 10 秒节流防止鼠标悬停反复进出时
/// 雪崩请求;互斥锁被毒化时以上次状态为准继续推进,不会永久阻塞。
///
/// 刷新面与 `format_usage_suffix` 的展示面严格对齐 —— 每次悬停最多发
/// `TRAY_SECTIONS.len()` 次外部请求,script 优先(覆盖 coding_plan / balance /
/// Copilot / 自定义脚本),否则当前 provider 必须是 `official` 才查订阅。
pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
use crate::commands::CopilotAuthState;
use futures::future::join_all;
{
let mut guard = LAST_TRAY_USAGE_REFRESH
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let now = std::time::Instant::now();
if let Some(last) = *guard {
if now.duration_since(last) < MIN_TRAY_USAGE_REFRESH_INTERVAL {
return;
}
}
*guard = Some(now);
}
let Some(app_state) = app.try_state::<AppState>() else {
return;
};
// 与 `create_tray_menu` 保持一致:用户隐藏的 app 不参与外部 API 查询,
// 避免在未使用的 app 上浪费请求、撞 rate limit 或反复触发鉴权失败日志。
let visible_apps = crate::settings::get_settings()
.visible_apps
.unwrap_or_default();
let mut subscription_futures = Vec::new();
let mut script_futures = Vec::new();
for section in TRAY_SECTIONS.iter() {
if !visible_apps.is_visible(&section.app_type) {
continue;
}
let app_type_str = section.app_type.as_str();
let log_name = section.log_name;
// 解析 effective current provider;未设置 / 出错都静默跳过,
// 与 create_tray_menu 的行为保持一致。
let current_id =
match crate::settings::get_effective_current_provider(&app_state.db, &section.app_type)
{
Ok(Some(id)) => id,
Ok(None) => continue,
Err(e) => {
log::warn!("[Tray] 读取{log_name}当前供应商失败: {e}");
continue;
}
};
// 只需当前 provider —— by-id 查询避免把整个 app 的 provider 列表加载
// 进内存(每次悬停 × 3 sections 的热路径)。
let current = match app_state.db.get_provider_by_id(&current_id, app_type_str) {
Ok(Some(p)) => p,
Ok(None) => continue,
Err(e) => {
log::warn!("[Tray] 读取{log_name}当前供应商失败: {e}");
continue;
}
};
// 与 format_usage_suffix 同一优先级:脚本启用 → 查脚本;
// 否则当前 provider 是 official → 查订阅;其它情况不发请求。
if current.has_usage_script_enabled() {
let app_clone = app.clone();
let state = app.state::<AppState>();
let copilot_state = app.state::<CopilotAuthState>();
let provider_id = current_id.clone();
let app_str = app_type_str.to_string();
script_futures.push(async move {
if let Err(e) = crate::commands::queryProviderUsage(
app_clone,
state,
copilot_state,
provider_id.clone(),
app_str,
)
.await
{
log::debug!("[Tray] 刷新{log_name}供应商 {provider_id} 用量失败: {e}");
}
});
} else if current.category.as_deref() == Some("official") {
let app_clone = app.clone();
let state = app.state::<AppState>();
let tool = app_type_str.to_string();
subscription_futures.push(async move {
if let Err(e) =
crate::commands::get_subscription_quota(app_clone, state, tool).await
{
log::debug!("[Tray] 刷新{log_name}订阅用量失败(可能未登录): {e}");
}
});
}
}
// 两组并行启动,整体等待 —— 订阅/脚本互不依赖,没必要串行。
futures::future::join(join_all(subscription_futures), join_all(script_futures)).await;
}
#[cfg(test)]
mod tests {
use super::{format_script_summary, format_subscription_summary, TRAY_ID};
use crate::provider::{UsageData, UsageResult};
use crate::services::subscription::{
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT,
};
#[test]
fn tray_id_is_unique_to_app() {
assert_eq!(TRAY_ID, "cc-switch");
assert_ne!(TRAY_ID, "main");
}
fn make_quota(tool: &str, success: bool, tiers: Vec<QuotaTier>) -> SubscriptionQuota {
SubscriptionQuota {
tool: tool.to_string(),
credential_status: CredentialStatus::Valid,
credential_message: None,
success,
tiers,
extra_usage: None,
error: None,
queried_at: Some(0),
}
}
fn tier(name: &str, utilization: f64) -> QuotaTier {
QuotaTier {
name: name.to_string(),
utilization,
resets_at: None,
}
}
#[test]
fn claude_summary_uses_h_and_w_labels() {
let quota = make_quota(
"claude",
true,
vec![tier("five_hour", 9.0), tier("seven_day", 27.0)],
);
let s = format_subscription_summary(&quota).expect("should format");
assert!(s.contains("h9%"), "expected h9% in {s}");
assert!(s.contains("w27%"), "expected w27% in {s}");
}
#[test]
fn gemini_summary_uses_p_and_f_labels() {
let quota = make_quota(
"gemini",
true,
vec![tier("gemini_pro", 15.0), tier("gemini_flash", 42.0)],
);
let s = format_subscription_summary(&quota).expect("should format");
assert!(s.contains("p15%"), "expected p15% in {s}");
assert!(s.contains("f42%"), "expected f42% in {s}");
}
#[test]
fn gemini_summary_includes_all_three_tiers() {
let quota = make_quota(
"gemini",
true,
vec![
tier("gemini_pro", 5.0),
tier("gemini_flash", 42.0),
tier("gemini_flash_lite", 80.0),
],
);
let s = format_subscription_summary(&quota).expect("should format");
assert!(s.contains("p5%"), "expected p5% in {s}");
assert!(s.contains("f42%"), "expected f42% in {s}");
assert!(s.contains("l80%"), "expected l80% in {s}");
}
#[test]
fn gemini_summary_lite_only_still_renders() {
// flash_lite 如果是 API 返回的唯一 tier,仍应显示(避免前端 footer 能看到、
// 托盘空白的不对称)。
let quota = make_quota("gemini", true, vec![tier("gemini_flash_lite", 80.0)]);
let s = format_subscription_summary(&quota).expect("should format");
assert!(s.contains("l80%"), "expected l80% in {s}");
}
#[test]
fn gemini_summary_emoji_reflects_highest_tier_including_lite() {
// lite 是利用率最高的那条 → emoji 必须是红色,不能被 pro/flash 掩盖。
let quota = make_quota(
"gemini",
true,
vec![
tier("gemini_pro", 10.0),
tier("gemini_flash", 20.0),
tier("gemini_flash_lite", 95.0),
],
);
let s = format_subscription_summary(&quota).unwrap();
assert!(
s.starts_with("\u{1F534}"),
"expected red emoji (lite worst) in {s}"
);
}
#[test]
fn worst_emoji_reflects_highest_utilization() {
// 🔴 = \u{1F534}; 任一 tier ≥ 90% 时预期显示红色。
let quota = make_quota(
"claude",
true,
vec![tier("five_hour", 10.0), tier("seven_day", 95.0)],
);
let s = format_subscription_summary(&quota).unwrap();
assert!(s.starts_with("\u{1F534}"), "expected red emoji in {s}");
}
#[test]
fn failure_quota_returns_none() {
let quota = make_quota("claude", false, vec![tier("five_hour", 50.0)]);
assert!(format_subscription_summary(&quota).is_none());
}
#[test]
fn unknown_tiers_return_none() {
let quota = make_quota("claude", true, vec![tier("one_hour", 80.0)]);
assert!(format_subscription_summary(&quota).is_none());
}
#[test]
fn gemini_without_any_known_tiers_returns_none() {
// 完全没有 pro/flash/flash_lite 三种 tier 的退化响应 → None。
let quota = make_quota("gemini", true, vec![tier("some_future_tier", 80.0)]);
assert!(format_subscription_summary(&quota).is_none());
}
fn usage_data(plan_name: Option<&str>, utilization: f64) -> UsageData {
UsageData {
plan_name: plan_name.map(String::from),
extra: None,
is_valid: Some(true),
invalid_message: None,
total: Some(100.0),
used: Some(utilization),
remaining: Some(100.0 - utilization),
unit: Some("%".to_string()),
}
}
fn usage_result(success: bool, data: Vec<UsageData>) -> UsageResult {
UsageResult {
success,
data: if data.is_empty() { None } else { Some(data) },
error: None,
}
}
#[test]
fn script_summary_token_plan_two_tiers() {
let r = usage_result(
true,
vec![
usage_data(Some(TIER_FIVE_HOUR), 12.0),
usage_data(Some(TIER_WEEKLY_LIMIT), 80.0),
],
);
let s = format_script_summary(&r).expect("should format");
assert!(s.contains("h12%"), "expected h12% in {s}");
assert!(s.contains("w80%"), "expected w80% in {s}");
assert!(s.starts_with("\u{1F7E0}"), "expected orange emoji in {s}");
}
#[test]
fn script_summary_token_plan_worst_drives_emoji() {
let r = usage_result(
true,
vec![
usage_data(Some(TIER_FIVE_HOUR), 20.0),
usage_data(Some(TIER_WEEKLY_LIMIT), 95.0),
],
);
let s = format_script_summary(&r).unwrap();
assert!(s.starts_with("\u{1F534}"), "expected red emoji in {s}");
}
#[test]
fn script_summary_token_plan_five_hour_only() {
let r = usage_result(true, vec![usage_data(Some(TIER_FIVE_HOUR), 8.0)]);
let s = format_script_summary(&r).expect("should format");
assert!(s.contains("h8%"), "expected h8% in {s}");
assert!(
!s.contains("plan_name"),
"plan_name should not leak into label: {s}"
);
}
#[test]
fn script_summary_token_plan_weekly_only() {
let r = usage_result(true, vec![usage_data(Some(TIER_WEEKLY_LIMIT), 50.0)]);
let s = format_script_summary(&r).expect("should format");
assert!(s.contains("w50%"), "expected w50% in {s}");
}
#[test]
fn script_summary_single_bucket_fallback_with_plan_name() {
let r = usage_result(true, vec![usage_data(Some("Copilot Pro"), 40.0)]);
let s = format_script_summary(&r).expect("should format");
assert!(s.contains("Copilot Pro"), "expected plan name in {s}");
assert!(s.contains("40%"), "expected 40% in {s}");
assert!(
!s.contains("h40%"),
"must not relabel non-token-plan data as h: {s}"
);
}
#[test]
fn script_summary_single_bucket_fallback_without_plan_name() {
let r = usage_result(true, vec![usage_data(None, 15.0)]);
let s = format_script_summary(&r).expect("should format");
assert_eq!(s, "\u{1F7E2} 15%", "expected emoji + pct only, got {s}");
}
#[test]
fn script_summary_failure_returns_none() {
let r = usage_result(false, vec![usage_data(Some(TIER_FIVE_HOUR), 12.0)]);
assert!(format_script_summary(&r).is_none());
}
#[test]
fn script_summary_empty_data_returns_none() {
let r = usage_result(true, vec![]);
assert!(format_script_summary(&r).is_none());
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.13.0",
"version": "3.14.1",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+3 -13
View File
@@ -1,8 +1,6 @@
use std::sync::Arc;
use cc_switch_lib::{
import_provider_from_deeplink, parse_deeplink_url, AppState, Database, ProxyService,
};
use cc_switch_lib::{import_provider_from_deeplink, parse_deeplink_url, AppState, Database};
#[path = "support.rs"]
mod support;
@@ -18,11 +16,7 @@ fn deeplink_import_claude_provider_persists_to_db() {
let request = parse_deeplink_url(url).expect("parse deeplink url");
let db = Arc::new(Database::memory().expect("create memory db"));
let proxy_service = ProxyService::new(db.clone());
let state = AppState {
db: db.clone(),
proxy_service,
};
let state = AppState::new(db.clone());
let provider_id = import_provider_from_deeplink(&state, request.clone())
.expect("import provider from deeplink");
@@ -58,11 +52,7 @@ fn deeplink_import_codex_provider_builds_auth_and_config() {
let request = parse_deeplink_url(url).expect("parse deeplink url");
let db = Arc::new(Database::memory().expect("create memory db"));
let proxy_service = ProxyService::new(db.clone());
let state = AppState {
db: db.clone(),
proxy_service,
};
let state = AppState::new(db.clone());
let provider_id = import_provider_from_deeplink(&state, request.clone())
.expect("import provider from deeplink");
+124
View File
@@ -0,0 +1,124 @@
mod support;
use cc_switch_lib::{hermes_config, update_settings, AppSettings};
/// 读取并回写 Hermes provider 时,Hermes v12+ 新增或未来才会出现的字段
/// (例如 `rate_limit_delay`、`key_env`)必须透传,不能因为 UI 不感知就静默丢弃。
/// 否则用户在 Hermes Web UI 配置的高级字段会在 CC Switch 编辑后消失。
fn with_temp_hermes_dir<F: FnOnce(&std::path::Path)>(f: F) {
let guard = support::test_mutex().lock().expect("test mutex poisoned");
let home = support::ensure_test_home();
support::reset_test_fs();
let hermes_dir = home.join(".hermes-roundtrip");
let _ = std::fs::remove_dir_all(&hermes_dir);
std::fs::create_dir_all(&hermes_dir).expect("create temp hermes dir");
update_settings(AppSettings {
hermes_config_dir: Some(hermes_dir.to_string_lossy().into_owned()),
..AppSettings::default()
})
.expect("set hermes_config_dir override");
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&hermes_dir)));
// Always restore settings and drop fixture dir, even on test failure.
let _ = update_settings(AppSettings::default());
let _ = std::fs::remove_dir_all(&hermes_dir);
drop(guard);
if let Err(err) = result {
std::panic::resume_unwind(err);
}
}
#[test]
fn set_provider_preserves_unknown_and_future_fields() {
with_temp_hermes_dir(|dir| {
let yaml = r#"custom_providers:
- name: myhost
base_url: https://api.example.com/v1
api_key: sk-old
api_mode: chat_completions
rate_limit_delay: 0.5
key_env: MY_API_KEY
foo_bar: keep-me-around
models:
gpt-4:
context_length: 8192
"#;
let config_path = dir.join("config.yaml");
std::fs::write(&config_path, yaml).expect("seed config.yaml");
// Simulate the UI sending back only the fields it knows about.
let patch = serde_json::json!({
"name": "myhost",
"base_url": "https://api.example.com/v1",
"api_key": "sk-new",
"api_mode": "chat_completions",
"models": [
{ "id": "gpt-4", "context_length": 8192 }
]
});
hermes_config::set_provider("myhost", patch).expect("set_provider");
let written = std::fs::read_to_string(&config_path).expect("read written config");
assert!(
written.contains("rate_limit_delay"),
"rate_limit_delay stripped:\n{written}"
);
assert!(
written.contains("key_env"),
"key_env key stripped:\n{written}"
);
assert!(
written.contains("MY_API_KEY"),
"key_env value stripped:\n{written}"
);
assert!(
written.contains("foo_bar"),
"unknown forward-compat field stripped:\n{written}"
);
assert!(
written.contains("sk-new"),
"api_key was not updated to sk-new:\n{written}"
);
assert!(
!written.contains("sk-old"),
"old api_key still present:\n{written}"
);
});
}
#[test]
fn get_providers_surfaces_rate_limit_delay_and_key_env() {
with_temp_hermes_dir(|dir| {
let yaml = r#"custom_providers:
- name: myhost
base_url: https://api.example.com/v1
api_key: sk-xxx
api_mode: chat_completions
rate_limit_delay: 2.5
key_env: FOO_KEY
models:
m1: {}
"#;
std::fs::write(dir.join("config.yaml"), yaml).expect("seed config.yaml");
let providers = hermes_config::get_providers().expect("get_providers");
let entry = providers.get("myhost").expect("myhost missing");
assert_eq!(
entry.get("rate_limit_delay").and_then(|v| v.as_f64()),
Some(2.5),
"rate_limit_delay not surfaced to DAO payload"
);
assert_eq!(
entry.get("key_env").and_then(|v| v.as_str()),
Some("FOO_KEY"),
"key_env not surfaced to DAO payload"
);
});
}
+89
View File
@@ -139,6 +139,93 @@ fn sync_codex_provider_writes_auth_and_config() {
assert_eq!(synced_cfg, toml_text);
}
#[test]
fn sync_codex_provider_preserves_live_model_provider_id_for_history() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" });
let legacy_config = r#"model_provider = "rightcode"
model = "gpt-5.4"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
cc_switch_lib::write_codex_live_atomic(&legacy_auth, Some(legacy_config))
.expect("seed existing Codex live config");
let mut config = MultiAppConfig::default();
let provider_config = json!({
"auth": {
"OPENAI_API_KEY": "fresh-key"
},
"config": r#"model_provider = "aihubmix"
model = "gpt-5.4"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
});
let provider = Provider::with_id(
"codex-1".to_string(),
"Codex Test".to_string(),
provider_config,
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 toml_text =
fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
let parsed: toml::Value = toml::from_str(&toml_text).expect("parse config.toml");
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("rightcode"),
"legacy ConfigService sync should use the stable live provider id"
);
let model_providers = parsed
.get("model_providers")
.and_then(|v| v.as_table())
.expect("model_providers should exist");
assert!(
model_providers.get("aihubmix").is_none(),
"provider-specific target id should not be written to live config"
);
assert_eq!(
model_providers
.get("rightcode")
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str()),
Some("https://aihubmix.example/v1")
);
let synced_cfg = config
.get_manager(&AppType::Codex)
.and_then(|manager| manager.providers.get("codex-1"))
.and_then(|provider| provider.settings_config.get("config"))
.and_then(|v| v.as_str())
.expect("synced config string");
assert!(
synced_cfg.contains("[model_providers.rightcode]"),
"ConfigService keeps its existing behavior of syncing provider config from live"
);
}
#[test]
fn sync_enabled_to_codex_writes_enabled_servers() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -554,6 +641,7 @@ command = "echo"
codex: false, // 初始未启用
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
@@ -682,6 +770,7 @@ fn import_from_claude_merges_into_config() {
codex: false,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
+112
View File
@@ -150,6 +150,110 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
);
}
#[test]
fn import_mcp_from_codex_does_not_rewrite_codex_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
let config_path = codex_dir.join("config.toml");
let original = r#"# keep user formatting intact
model = "gpt-5"
[mcp.servers.legacy]
type = "stdio"
command = "echo"
[mcp_servers.echo]
type = "stdio"
command = "echo"
"#;
fs::write(&config_path, original).expect("seed codex config");
let state = create_test_state().expect("create test state");
let changed = McpService::import_from_codex(&state).expect("import from codex");
assert!(changed > 0, "should import servers from Codex config");
let after = fs::read_to_string(&config_path).expect("read codex config");
assert_eq!(
after, original,
"importing from Codex should not rewrite ~/.codex/config.toml"
);
}
#[test]
fn import_mcp_from_claude_does_not_sync_existing_codex_enabled_server() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
let codex_config_path = codex_dir.join("config.toml");
let codex_original = r#"[mcp.servers.keep_me]
type = "stdio"
command = "echo"
"#;
fs::write(&codex_config_path, codex_original).expect("seed codex config");
let claude_json = json!({
"mcpServers": {
"shared": {
"type": "stdio",
"command": "echo"
}
}
});
fs::write(
get_claude_mcp_path(),
serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"),
)
.expect("seed claude mcp");
let state = create_test_state().expect("create test state");
state
.db
.save_mcp_server(&McpServer {
id: "shared".to_string(),
name: "shared".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: true,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
})
.expect("seed existing mcp server");
let changed = McpService::import_from_claude(&state).expect("import from claude");
assert_eq!(changed, 0, "existing server should not count as new");
let after = fs::read_to_string(&codex_config_path).expect("read codex config");
assert_eq!(
after, codex_original,
"importing from Claude should not sync an existing Codex-enabled server"
);
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let shared = servers.get("shared").expect("shared server exists");
assert!(
shared.apps.claude,
"import should enable Claude in database"
);
assert!(shared.apps.codex, "existing Codex flag should be preserved");
}
#[test]
fn import_mcp_from_claude_invalid_json_preserves_state() {
use support::create_test_state;
@@ -217,6 +321,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
codex: false, // 初始未启用
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
@@ -281,6 +386,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() {
codex: false,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
@@ -325,6 +431,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
codex: false,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
@@ -358,6 +465,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
codex: false,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
@@ -490,6 +598,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
codex: false,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
@@ -544,6 +653,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
codex: false,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
@@ -604,6 +714,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries()
codex: false,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
@@ -625,6 +736,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries()
codex: false,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
+136 -3
View File
@@ -1,14 +1,146 @@
use serde_json::json;
use std::path::{Path, PathBuf};
use cc_switch_lib::{
get_codex_auth_path, get_codex_config_path, read_json_file, switch_provider_test_hook,
write_codex_live_atomic, AppError, AppType, McpApps, McpServer, MultiAppConfig, Provider,
get_codex_auth_path, get_codex_config_path, import_default_config_test_hook, read_json_file,
switch_provider_test_hook, write_codex_live_atomic, AppError, AppType, McpApps, McpServer,
MultiAppConfig, Provider, ProviderService,
};
#[path = "support.rs"]
mod support;
use std::collections::HashMap;
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
use support::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
};
fn settings_path(home: &Path) -> PathBuf {
home.join(".cc-switch").join("settings.json")
}
#[test]
fn codex_startup_import_fresh_install_imports_once_and_syncs_current_setting() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let auth = json!({"OPENAI_API_KEY": "fresh-key"});
let config = r#"model = "gpt-5"
"#;
write_codex_live_atomic(&auth, Some(config)).expect("seed codex live config");
let state = create_test_state().expect("create test state");
assert!(
ProviderService::should_import_default_config_on_startup(&state, &AppType::Codex)
.expect("check startup import eligibility"),
"empty Codex provider set should import on startup"
);
import_default_config_test_hook(&state, AppType::Codex).expect("import codex default");
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get codex providers after import");
assert_eq!(
providers.len(),
1,
"fresh install import should create exactly one Codex provider before seeding"
);
assert!(
providers.contains_key("default"),
"fresh install import should create default provider"
);
let current_id = state
.db
.get_current_provider(AppType::Codex.as_str())
.expect("get codex current provider");
assert_eq!(current_id.as_deref(), Some("default"));
let settings: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(settings_path(home)).expect("read settings.json"),
)
.expect("parse settings.json");
assert_eq!(
settings
.get("currentProviderCodex")
.and_then(|value| value.as_str()),
Some("default"),
"live import should also sync device-local currentProviderCodex"
);
state
.db
.init_default_official_providers()
.expect("seed official providers");
let providers_after_seed = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get codex providers after seed");
assert_eq!(
providers_after_seed.len(),
2,
"official seeding should add codex-official alongside imported default"
);
assert!(providers_after_seed.contains_key("codex-official"));
assert!(
!ProviderService::should_import_default_config_on_startup(&state, &AppType::Codex)
.expect("re-check startup import eligibility"),
"subsequent startup should skip once Codex already has providers"
);
}
#[test]
fn codex_startup_import_skips_when_only_official_seed_exists() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let auth = json!({"OPENAI_API_KEY": "fresh-key"});
let config = r#"model = "gpt-5"
"#;
write_codex_live_atomic(&auth, Some(config)).expect("seed codex live config");
let state = create_test_state().expect("create test state");
state
.db
.init_default_official_providers()
.expect("seed official providers");
let providers_before = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get codex providers before restart check");
assert_eq!(
providers_before.len(),
1,
"fixture should start with only codex-official present"
);
assert!(providers_before.contains_key("codex-official"));
assert!(
!ProviderService::should_import_default_config_on_startup(&state, &AppType::Codex)
.expect("check startup import eligibility"),
"startup should skip import when codex-official already exists"
);
let providers_after = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get codex providers after restart check");
assert_eq!(
providers_after.len(),
providers_before.len(),
"skipping startup import should not grow the Codex provider set"
);
assert!(
!providers_after.contains_key("default"),
"restart path should not create a new default provider"
);
}
#[test]
fn switch_provider_updates_codex_live_and_state() {
@@ -75,6 +207,7 @@ command = "say"
codex: true, // 启用 Codex
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
+236
View File
@@ -163,6 +163,7 @@ command = "say"
codex: true,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
@@ -238,6 +239,241 @@ command = "say"
);
}
#[test]
fn provider_service_switch_codex_preserves_live_model_provider_id_for_history() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" });
let legacy_config = r#"model_provider = "rightcode"
model = "gpt-5.4"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
write_codex_live_atomic(&legacy_auth, Some(legacy_config))
.expect("seed existing codex live config");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "old-provider".to_string();
manager.providers.insert(
"old-provider".to_string(),
Provider::with_id(
"old-provider".to_string(),
"RightCode".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "stale"},
"config": legacy_config
}),
None,
),
);
manager.providers.insert(
"new-provider".to_string(),
Provider::with_id(
"new-provider".to_string(),
"AiHubMix".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "fresh-key"},
"config": r#"model_provider = "aihubmix"
model = "gpt-5.4"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "new-provider")
.expect("switch provider should succeed");
let config_text =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
let parsed: toml::Value = toml::from_str(&config_text).expect("parse config.toml");
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("rightcode"),
"live Codex model_provider should stay stable so resume history remains visible"
);
let model_providers = parsed
.get("model_providers")
.and_then(|v| v.as_table())
.expect("model_providers table exists");
assert!(
model_providers.get("aihubmix").is_none(),
"target provider-specific id should be rewritten in live config"
);
assert_eq!(
model_providers
.get("rightcode")
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str()),
Some("https://aihubmix.example/v1"),
"stable provider id should point at the newly selected supplier endpoint"
);
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("read providers after switch");
let new_config_text = providers
.get("new-provider")
.expect("new provider exists")
.settings_config
.get("config")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(
new_config_text.contains("[model_providers.aihubmix]"),
"stored provider template should remain provider-specific"
);
}
#[test]
fn provider_service_switch_codex_backfill_keeps_provider_specific_model_provider_id() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" });
let provider_a_config = r#"model_provider = "rightcode"
model = "gpt-5.4"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
write_codex_live_atomic(&legacy_auth, Some(provider_a_config))
.expect("seed existing codex live config");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "provider-a".to_string();
manager.providers.insert(
"provider-a".to_string(),
Provider::with_id(
"provider-a".to_string(),
"RightCode".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "rightcode-key"},
"config": provider_a_config
}),
None,
),
);
manager.providers.insert(
"provider-b".to_string(),
Provider::with_id(
"provider-b".to_string(),
"AiHubMix".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "aihubmix-key"},
"config": r#"model_provider = "aihubmix"
model = "gpt-5.4"
profile = "work"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
[profiles.work]
model_provider = "aihubmix"
model = "gpt-5.4"
"#
}),
None,
),
);
manager.providers.insert(
"provider-c".to_string(),
Provider::with_id(
"provider-c".to_string(),
"Vendor C".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "vendor-c-key"},
"config": r#"model_provider = "vendor_c"
model = "gpt-5.4"
[model_providers.vendor_c]
name = "Vendor C"
base_url = "https://vendor-c.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "provider-b")
.expect("switch to provider b should succeed");
ProviderService::switch(&state, AppType::Codex, "provider-c")
.expect("switch to provider c should succeed");
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("read providers after switches");
let provider_b_config = providers
.get("provider-b")
.expect("provider b exists")
.settings_config
.get("config")
.and_then(|v| v.as_str())
.expect("provider b config");
let parsed: toml::Value = toml::from_str(provider_b_config).expect("parse provider b config");
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("aihubmix"),
"backfill should restore provider b's storage-specific model_provider id"
);
assert!(
parsed
.get("model_providers")
.and_then(|v| v.get("aihubmix"))
.is_some(),
"provider b should keep its own model_providers table after backfill"
);
assert_eq!(
parsed
.get("profiles")
.and_then(|v| v.get("work"))
.and_then(|v| v.get("model_provider"))
.and_then(|v| v.as_str()),
Some("aihubmix"),
"profile overrides should be restored to provider b's storage-specific id"
);
}
#[test]
fn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
+52 -18
View File
@@ -53,10 +53,8 @@ fn import_from_apps_respects_explicit_app_selection() {
vec![ImportSkillSelection {
directory: "shared-skill".to_string(),
apps: SkillApps {
claude: false,
codex: false,
gemini: false,
opencode: true,
..Default::default()
},
}],
)
@@ -74,6 +72,53 @@ fn import_from_apps_respects_explicit_app_selection() {
);
}
#[test]
fn import_from_apps_does_not_rewrite_selected_app_directory() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let ssot_skill_dir = home.join(".cc-switch").join("skills").join("codex-skill");
write_skill(&ssot_skill_dir, "Stale SSOT Skill");
fs::write(ssot_skill_dir.join("prompt.md"), "stale ssot").expect("write stale ssot prompt");
let codex_skill_dir = home.join(".codex").join("skills").join("codex-skill");
write_skill(&codex_skill_dir, "Live Codex Skill");
fs::write(codex_skill_dir.join("prompt.md"), "live codex").expect("write live codex prompt");
let state = create_test_state().expect("create test state");
let imported = SkillService::import_from_apps(
&state.db,
vec![ImportSkillSelection {
directory: "codex-skill".to_string(),
apps: SkillApps {
codex: true,
..Default::default()
},
}],
)
.expect("import skills");
assert_eq!(imported.len(), 1, "expected exactly one imported skill");
assert!(
imported[0].apps.codex,
"import should preserve the selected Codex app state"
);
assert_eq!(
fs::read_to_string(codex_skill_dir.join("prompt.md")).expect("read live codex prompt"),
"live codex",
"import should not replace the app skill directory with SSOT contents"
);
assert!(
!fs::symlink_metadata(&codex_skill_dir)
.expect("read codex skill metadata")
.file_type()
.is_symlink(),
"import should not replace the app skill directory with a managed symlink"
);
}
#[test]
fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -103,12 +148,7 @@ fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() {
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps {
claude: false,
codex: false,
gemini: false,
opencode: false,
},
apps: SkillApps::default(),
installed_at: 0,
content_hash: None,
updated_at: 0,
@@ -151,9 +191,7 @@ fn uninstall_skill_creates_backup_before_removing_ssot() {
readme_url: None,
apps: SkillApps {
claude: true,
codex: false,
gemini: false,
opencode: false,
..Default::default()
},
installed_at: 123,
content_hash: None,
@@ -221,9 +259,7 @@ fn restore_skill_backup_restores_files_to_ssot_and_current_app() {
readme_url: None,
apps: SkillApps {
claude: true,
codex: false,
gemini: false,
opencode: false,
..Default::default()
},
installed_at: 456,
content_hash: None,
@@ -304,9 +340,7 @@ fn delete_skill_backup_removes_backup_directory() {
readme_url: None,
apps: SkillApps {
claude: true,
codex: false,
gemini: false,
opencode: false,
..Default::default()
},
installed_at: 789,
content_hash: None,
+3 -7
View File
@@ -1,9 +1,7 @@
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use cc_switch_lib::{
update_settings, AppSettings, AppState, Database, MultiAppConfig, ProxyService,
};
use cc_switch_lib::{update_settings, AppSettings, AppState, Database, MultiAppConfig};
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
pub fn ensure_test_home() -> &'static Path {
@@ -62,8 +60,7 @@ pub fn test_mutex() -> &'static Mutex<()> {
#[allow(dead_code)]
pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
let db = Arc::new(Database::init()?);
let proxy_service = ProxyService::new(db.clone());
Ok(AppState { db, proxy_service })
Ok(AppState::new(db))
}
/// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据
@@ -73,6 +70,5 @@ pub fn create_test_state_with_config(
) -> Result<AppState, Box<dyn std::error::Error>> {
let db = Arc::new(Database::init()?);
db.migrate_from_json(config)?;
let proxy_service = ProxyService::new(db.clone());
Ok(AppState { db, proxy_service })
Ok(AppState::new(db))
}