mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 10:25:05 +08:00
Merge branch 'main' into codex/issue-1888-interception-matrix
This commit is contained in:
Generated
+17
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"))?
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
// ========== 统一管理命令 ==========
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制文件
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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 子查询、首条命中即返回。
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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} 不能为空")));
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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!("已在退出前保存窗口状态");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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() {
|
||||
// 仅处理 assistant,user 的 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"#);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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-key(Anthropic 官方协议)。
|
||||
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 而不是 Anthropic(x-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_TOKEN,strategy 推断也必须保持一致。
|
||||
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 列表做精确匹配,找不到时
|
||||
//! 按 family(haiku/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. 找不到时按 family(haiku/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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/其它 使用 reasoning,DeepSeek 使用 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!(
|
||||
|
||||
@@ -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: 必须永远 true(codex-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 必须显式为 false(ChatGPT 后端拒绝 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_tokens(OpenAI 官方 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"});
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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_LIMIT,nextResetTime 较近的归 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@ impl ConfigService {
|
||||
match app_type {
|
||||
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
||||
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
||||
AppType::ClaudeDesktop => {
|
||||
// Claude Desktop 3P profiles are managed by claude_desktop_config.
|
||||
}
|
||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_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 进行
|
||||
|
||||
@@ -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 配置。
|
||||
// 显式编辑、启用/禁用或手动同步时再执行写回。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
// 虚构 case:baseURL 就是 "scheme://host",剥不出子路径,应只有一个候选。
|
||||
let c = build_models_url_candidates("https://host.example.com", false, None).unwrap();
|
||||
assert_eq!(c.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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:仓库本身就是 skill,SKILL.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_case(base_url / api_key / api_mode),
|
||||
// 与 OpenClaw 的 camelCase(baseUrl / 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 的处理:
|
||||
// 用 ClaudeAuth(Bearer-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:...`
|
||||
|
||||
@@ -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 Plan(Kimi / 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 {
|
||||
// 转换为 tiers(remainingFraction → 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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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=pro,f=flash,l=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
|
||||
// 扁平化为一条 UsageData(plan_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(¤t_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(¤t_id);
|
||||
let submenu_label = match current_provider {
|
||||
Some(p) => {
|
||||
let suffix = format_usage_suffix(app_state, §ion.app_type, p, ¤t_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(§ion.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, §ion.app_type)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(provider) = providers.get(¤t_id) else {
|
||||
continue;
|
||||
};
|
||||
let suffix = format_usage_suffix(&app_state, §ion.app_type, provider, ¤t_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(§ion.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, §ion.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(¤t_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("a).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("a).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("a).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("a).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("a).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("a).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("a).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tiers_return_none() {
|
||||
let quota = make_quota("claude", true, vec![tier("one_hour", 80.0)]);
|
||||
assert!(format_subscription_summary("a).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("a).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,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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user